From fa8ca8d2658ae71134a0a30c3f8a3f7bbc660d68 Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:16:28 -0400 Subject: [PATCH] feat: rename and retime a published page with `stele amend` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows stele-pages 570b375, which grew `PATCH /pages/:slug` — the verb that changes the two things about a page that used to be fixed at publication: where it lives and how long it lives. One command mapping 1:1 to the route, the way `publish` maps to POST and `update` to PUT: stele amend [--slug ] [--ttl ] Either flag alone, or both in one request — so a rename-and-retime cannot half apply the way two commands over one route would. The trap this change is mostly built around: an absent `--ttl` here means *leave the deadline exactly where it is*, where the same absence on `publish` means "take the server's default". `PageTTL?` is one optional with one absent case and the two verbs read it in opposite directions, so nothing enforces the difference except neither caller inventing a value to fill the silence with. A `ttl=7` sent because seven looked reasonable would put a week's deadline on a page published to be kept, and the 200 would look perfectly fine. Hence a test asserting the parameter is *absent* rather than empty, and a smoke check that renames a `--ttl never` page and watches its deadline stay null. `SteleError.slugTaken` grows an `advice` field, taken from the route's `Expectation` the way `notFound`'s already was. It was the same mistake found twice: "omit `--slug` and let the server generate one" is the right next move on a publish and a false one on an amendment, where omitting it means *do not rename* and no slug is ever allocated — an agent following that advice would come back with a client-side "nothing to amend" and never reach the server. `Expectation.amend` exists for the same reason on the 404 side: `write`'s advice names `stele update` and says to publish first, but an amendment's 404 is usually a page that has *expired*, where the tempting next move is `--ttl never` and it fails identically. An amendment naming neither flag is refused before a credential is read or a byte is sent — the server answers it with a 400, but it is a mistake in the invocation, and this side can name the two flags that would fix it. Same bargain `PageIO.read` strikes over an empty file. Docs record what a rename costs, because it is the sharper half: the old name is freed the instant it commits, with no redirect and no tombstone, so a link already in circulation breaks — first as an ordinary 404, then, if somebody claims the name, as a link pointing at their page. `update` keeps the URL and replaces the page; `amend` keeps the page and replaces the URL. 0.2.0 -> 0.3.0. Also corrects a pre-existing claim that the server has no delete route — it has had one since stele-pages c83463a; what this CLI has is no command that reaches it. 147 tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018qEtLbpPoxtUtucznPohMG --- README.md | 85 ++++++- Sources/SteleKit/Inputs.swift | 17 +- Sources/SteleKit/Models.swift | 18 +- Sources/SteleKit/SteleClient.swift | 83 ++++++- Sources/SteleKit/SteleError.swift | 62 +++-- Sources/SteleKit/Version.swift | 2 +- Sources/stele/Exit.swift | 9 +- Sources/stele/PageCommands.swift | 121 +++++++++- Sources/stele/Stele.swift | 1 + Tests/SteleKitTests/SteleClientTests.swift | 267 ++++++++++++++++++++- scripts/integration-smoke.sh | 149 +++++++++++- 11 files changed, 761 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index c1c12fc..5eb8a7a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ $ stele update q3-report report.html https://stele.example.com/q3-report kept until deleted +$ stele amend q3-report --slug q3-final +https://stele.example.com/q3-final +kept until deleted + $ stele auth status host https://stele.example.com client claude-code @@ -53,6 +57,7 @@ unpublished, and a caller handed only a URL would find that out when the link br | `stele auth logout` | human | Forgets the local credential. Does not revoke it. | | `stele publish [--slug ] [--ttl ]` | agent | `POST /pages`. Prints the URL on stdout and the page's deadline on stderr. | | `stele update ` | agent | `PUT /pages/:slug`. Never creates, and never changes the deadline. | +| `stele amend [--slug ] [--ttl ]` | agent | `PATCH /pages/:slug`. Renames a page, moves its deadline, or both, without touching a byte of it. A rename is a hard move — the old URL starts 404ing at once. | | `stele skill` | agent | Proxies `GET /skill`, so the binary keeps zero copies of the contract. | | `stele admin clients create ` | operator | Mints a credential and prints the token **once**. `--scopes`, `--expires-in 90d`. | | `stele admin clients list` | operator | Names, scopes, last use, revocation state. | @@ -122,7 +127,7 @@ Pages are ephemeral by default. `--ttl` says otherwise: | `--ttl` | Means | | --- | --- | -| omitted | the server's default lifetime, which is a matter of days | +| omitted | on `publish`, the server's default lifetime, a matter of days; on `amend`, the deadline already in force | | `30`, `30d` | thirty days | | `2w` | fourteen days | | `never` | kept until you delete it | @@ -139,13 +144,52 @@ would be a second source of truth that drifts silently the day the server moves `400` it earns names the real limit. What this side checks is only what the server cannot: that you wrote something meaning a number of days, and that turning it into one does not overflow. -A page's deadline is fixed when it is published. `stele update` has no `--ttl` — replacing a -page's body cannot buy the link another week — and the server answers `?ttl=` on `PUT` with a -`400` rather than a `200` that silently ignored it. Republish if you need a different lifetime. +A deadline is fixed for a page's *body*, not for the page. `stele update` still has no `--ttl` — +replacing a body cannot buy the link another week, and a lifetime quietly reset by every edit +would be a deadline nobody could predict — and the server still answers `?ttl=` on `PUT` with a +`400` rather than a `200` that silently ignored it. That refusal is not a gap waiting to be +filled; it is the rule that a replacement cannot retime a page. Moving a deadline is a separate +act, and `stele amend --ttl` is the one thing that performs it. + +Two things about it want knowing before you type it. An omitted `--ttl` on `amend` means *leave +the deadline exactly where it is* — not "apply the default", which is what omitting it on +`publish` means. The two commands read the same absence in opposite directions deliberately, and +the direction here is the safe one: the alternative is a `--slug` rename that also, silently, +puts a week's deadline on a page somebody published to keep. And the lifetime you do pass is +counted from the moment of the request rather than from publication, so `--ttl 30` on a page +published three weeks ago grants thirty fresh days rather than the nine that were left of them. +It is a new lease, not an adjustment to the old one. The deadline itself is always read off the response, never computed here: the server resolves it against *its* clock at the moment of the upload, applies its own default when you say nothing, -and on `update` reports the deadline the page already had — a date this side never knew. +and on `update` reports the deadline the page already had — a date this side never knew. `amend` +is no different: what comes back is the deadline now in force, whether you moved it or left it +alone, and it is the only honest answer to what happened, because only the server's clock knows +when "thirty days from now" lands. + +## What a rename costs + +A lifetime is one way a link you handed somebody stops working. `stele amend --slug` is the +other, and it is the faster one, so it gets said plainly: a rename is a hard move. The old name +is freed the instant the rename commits. There is no redirect, no tombstone, nothing recording +that the page was ever called that — the old URL begins serving the ordinary `404`, the same +bytes a slug nobody has ever used gets, and the name goes straight back in the pool for the next +`--slug` anybody asks for. A rename is not a forwarding address. It is a deletion and a +publication that happen to preserve the contents. + +Which makes the question to ask beforehand not "would a better name be nice" but "who already +has the old one". A URL that has not left your terminal renames freely, and that is the case +`amend` is for: the generated three-word slug you have decided to replace before sending it +anywhere. A URL already sitting in somebody's inbox is a different matter — renaming it breaks +it for them, with no notice and no trail back. The tool for changing what a circulated link says +is `stele update`, which replaces the contents and leaves the address alone. That is the whole +division of labour between the two: `update` keeps the URL and replaces the page, `amend` keeps +the page and replaces the URL. + +Everything an amendment is not asked to change survives it, including the record of which +credential published the page. That is deliberate rather than an oversight: the column names who +wrote the bytes, and an amendment writes none — so renaming somebody's page cannot make it look +like yours. ## Exit codes @@ -160,7 +204,7 @@ table. | 2 | no usable credential here — ask the user to run `stele auth login` | | 3 | the server rejected the credential — ask the user to log in again | | 4 | valid credential, insufficient scope — an operator has to run this | -| 5 | that slug is taken — choose another `--slug` or omit it | +| 5 | that slug is taken — ask for a different `--slug` | | 6 | the page is too large or the wrong type | | 7 | no such page or client | | 8 | the CLI is too old — reinstall it and retry once | @@ -175,6 +219,12 @@ $ echo $? 5 ``` +The message carries the remedy the *route* has, which is why the table above does not. Dropping +`--slug` is an escape `publish` offers and `amend` does not: there it means "do not rename", the +server allocates nothing, and an amendment asking for neither a name nor a deadline is refused +before it is sent. A 5 from `stele amend` means pick a different name or leave the page where it +is. + Code 8 is the version gate: the CLI sends `User-Agent: stele-cli/` on every request and a server whose `minimumCLIVersion` is higher answers `426`. The remedy is printed with the error — `make -C ~/repos/stele-cli install`, then retry once. @@ -351,20 +401,35 @@ mint the first client. Arguments may also arrive as `STELE_SMOKE_HOST`, `STELE_S | `--psql ` | optional | A command that reaches the server's database, e.g. `docker exec stele-postgres psql -U stele -d stele_smoke`. Only the attribution check needs it, because no route reports who wrote a page — without it that one check prints `skip`. | It walks the whole documented lifecycle — mint, log in, `auth status`, publish, fetch the bytes -back and compare them, update, `--expires-in` there *and* back, expiry actually enforced, a -publish-only credential answered `200` by whoami and `403` by the admin routes, revocation that +back and compare them, update, amend, `--expires-in` there *and* back, expiry actually enforced, +a publish-only credential answered `200` by whoami and `403` by the admin routes, revocation that really stops working, the exit-code vocabulary, the version gate, and the byte-identity of every `404`. It prints each check, stops at the first failure, and exits non-zero. +The amend leg is the one worth naming, because most of what it asserts cannot be seen from +inside this repository: that a rename really does free the old name at once — the old URL 404s +in the same breath, and the name is claimable again — that the contents and the content type +come through the move untouched, and above all that a rename with no `--ttl` leaves the deadline +exactly where it was. That last check is run against a page published `--ttl never`, so a client +that helpfully sent a default lifetime would show up as a permanent page with a week to live. +It also pins the refusals: exit 5 for a name another live page holds, exit 7 for a slug with no +live page behind it, and exit 1 for an amendment that named nothing to amend — that one with the +credential file moved aside, which is how the script proves the refusal happened before anything +was sent. + Two things it does not do, stated because a canary you trust wrongly is worse than none: - **It cannot drive `stele auth login` interactively.** That command demands a TTY by design and refuses a pipe; the script asserts the *refusal* — which is the load-bearing custody rule — and then seeds the credential file at `0600` in the documented format for the rest of the run. The prompt, the echo-off read and the verify-before-write are not covered. -- **It leaves litter.** The server has no delete route, so each run leaves two pages and three +- **It leaves litter.** The server has a delete route but this CLI ships no command that reaches + it, so each run leaves eight pages and three credential rows behind; it revokes every credential it mints, but a revoked row is still a row. - Run it against a throwaway deployment, never against production. + Five of those pages expire on their own — the lifetime checks publish several deliberately — + but three were published or amended to `--ttl never` and will still be there next year. The + footer names those three, since they are the only ones a human ever has to clean up. Run it + against a throwaway deployment, never against production. It also moves `~/.config/stele/credentials.json` aside and restores it on every exit path, including a failed check — see the `HOME` note under Configuration for why it cannot simply use a diff --git a/Sources/SteleKit/Inputs.swift b/Sources/SteleKit/Inputs.swift index 9d28407..e31fba5 100644 --- a/Sources/SteleKit/Inputs.swift +++ b/Sources/SteleKit/Inputs.swift @@ -125,13 +125,20 @@ public enum ExpiryDuration { } } -/// How long a published page lives, as the `?ttl=` value `POST /pages` wants. +/// How long a page lives, as the `?ttl=` value `POST /pages` and `PATCH /pages/:slug` want. /// /// The server measures a page's life in whole days or not at all: `?ttl=30`, or `?ttl=never`. -/// Omitting it is a third thing — the server's own default, seven days — and that default is -/// deliberately not repeated here. A CLI that printed "expires in 7 days" from a constant would -/// keep printing it the day the server changed its mind, and the server tells us the real answer -/// in the response body anyway. +/// Omitting it is a third thing — and this is the trap, because it is not the *same* third thing +/// on both routes. On `POST` an absent `?ttl=` takes the server's own default lifetime, a matter +/// of days; on `PATCH` it means **leave the deadline exactly where it is**. `PageTTL?` cannot +/// carry that difference — it is one optional with one absent case, read in opposite directions +/// by the two verbs — so it rests entirely on neither caller inventing a value to fill the +/// silence with. A `ttl=7` sent on an amendment because seven looked like a reasonable default +/// would put a week's deadline on a page published to be kept, and the `200` would look right. +/// +/// The default itself is deliberately not repeated here. A CLI that printed "expires in 7 days" +/// from a constant would keep printing it the day the server changed its mind, and the server +/// tells us the real answer in the response body anyway. /// /// Note what this type does *not* do: it has no maximum. The server's `PageLifetime` owns that /// bound, for the same reason its `Slug` owns the slug rules — a copy here would be a second diff --git a/Sources/SteleKit/Models.swift b/Sources/SteleKit/Models.swift index f974378..db28f9b 100644 --- a/Sources/SteleKit/Models.swift +++ b/Sources/SteleKit/Models.swift @@ -1,6 +1,7 @@ import Foundation -/// Where a published page lives — the body both writes answer with. +/// Where a published page lives — the body every write answers with: `publish`, `update` and +/// `amend` alike. /// /// `url` is the server's own rendering rather than something assembled here from the host and /// the slug. The deployment knows its public base URL and this client only knows the address @@ -12,9 +13,14 @@ public struct PageLocation: Codable, Sendable, Equatable { /// When the page stops being served, or nil for one that is kept until it is deleted. /// /// Reading this rather than computing it from the `--ttl` that was sent is the point: the - /// server resolves the deadline against *its* clock at the moment of the upload, it applies - /// its own default when the caller expressed no opinion, and on `update` it reports the - /// deadline the page already had — a number this side never knew. + /// server resolves the deadline against *its* clock at the moment of the request, on + /// `publish` it applies its own default when the caller expressed no opinion, and on + /// `update` it reports the deadline the page already had — a number this side never knew. + /// + /// `amend` is the one that reads a silent caller the other way round: with no `?ttl=` it + /// touches nothing and reports the deadline already in force, and with one it reports a + /// deadline counted from *now* rather than from publication. Either way the answer is here + /// and nowhere else, which is exactly why no caller should be computing it. /// /// Nil covers two wire shapes, and they mean the same thing. The server sends an explicit /// `null` for a permanent page; a deployment older than page expiry omits the key, and on @@ -116,7 +122,9 @@ public struct ClientSummary: Codable, Sendable, Equatable { /// exists so a command can ask "does this credential have `admin`?" without a bare string /// literal at the call site, and so the wording in a 403 can name the scope that was missing. public enum Scope: String, Codable, Sendable, CaseIterable { - /// What every agent credential gets, and all it gets: `POST /pages` and `PUT /pages/:slug`. + /// What every agent credential gets, and all it gets: `POST /pages`, `PUT /pages/:slug` and + /// `PATCH /pages/:slug` — write, replace, and rename-or-retime. An amendment is a write like + /// the other two, so it sits under this scope and not behind `admin`. case publish /// The operator's scope: minting, listing and revoking credentials. case admin diff --git a/Sources/SteleKit/SteleClient.swift b/Sources/SteleKit/SteleClient.swift index ddeab7d..4ae5e30 100644 --- a/Sources/SteleKit/SteleClient.swift +++ b/Sources/SteleKit/SteleClient.swift @@ -95,6 +95,22 @@ public struct SteleClient: Sendable { }() } + /// Query parameters this client sends, named once for `Path`'s reason and a sharper version + /// of its failure. A misspelled path is a `404` and says so; a misspelled query parameter is + /// *silence* — the server looks the name up, does not find it, and does whatever it would + /// have done had the caller passed nothing. `?slgu=` on a publish is a generated slug instead + /// of the one asked for, and on an amendment it is a page retimed but never renamed. Both + /// answer `200`. + /// + /// `?ttl=` is spelled by `PageTTL.queryParameter`, which sits with the type that owns the + /// value and its `never` keyword. + public enum Query { + /// The name a page is asked to have — the one on `POST /pages`, and the *new* one on + /// `PATCH /pages/:slug`. One word on the wire in both places, and a literal at each call + /// site would be two strings that have to agree with each other and with the server. + public static let slug = "slug" + } + /// The type a page body is sent as when the caller expresses no opinion. /// /// The CLI owning this decision is the point of the tool: the `curl` recipe the skill used @@ -123,7 +139,7 @@ public struct SteleClient: Sendable { ttl: PageTTL? = nil, using credential: Credential ) async throws -> PageLocation { - var query = slug.map { [URLQueryItem(name: "slug", value: $0)] } ?? [] + var query = slug.map { [URLQueryItem(name: Query.slug, value: $0)] } ?? [] if let ttl { query.append(URLQueryItem(name: PageTTL.queryParameter, value: ttl.queryValue)) } @@ -142,8 +158,11 @@ public struct SteleClient: Sendable { /// `PUT /pages/:slug`. Replaces a page that already exists; never creates one. /// /// Takes no `ttl`, and not by omission — the server answers `?ttl=` on this verb with a - /// `400`. A page's deadline is fixed when it is published, so replacing the body cannot - /// quietly buy the link another week. The response still reports the deadline the page has. + /// `400`. What is fixed is not the deadline itself but the deadline's relationship to *this* + /// verb: a page's expiry belongs to the page rather than to its current body, so replacing + /// the body cannot quietly buy the link another week. A deadline does move — under its own + /// verb, with no bytes attached to it. See `amend`. The response still reports the deadline + /// the page has. public func update( slug: String, page: Data, @@ -161,6 +180,64 @@ public struct SteleClient: Sendable { ) } + /// `PATCH /pages/:slug`. Renames a page, moves its deadline, or both — and writes no bytes. + /// + /// The trap worth reading before anything else: `ttl: nil` here means **leave the deadline + /// exactly where it is**, where the same `nil` on `publish` means "take whatever lifetime + /// the server defaults to". `PageTTL?` cannot express that difference on its own — it is one + /// optional with one absent case, and the two verbs read that absence in opposite ways — so + /// the distinction rests entirely on this method never inventing a value. A `ttl=7` sent + /// because seven looked like a reasonable default would put a week's deadline on a page its + /// author published to keep forever, and nothing about the response would look wrong + /// afterwards. So `?ttl=` travels only when the caller passed one, and `?slug=` only when the + /// caller passed one; neither ever carries a placeholder. Passing neither is the server's + /// `400` ("Nothing to amend"), which this method does not second-guess — a caller holding a + /// command line can refuse it before spending the round trip, and the CLI does. + /// + /// Read the resulting address off the returned `PageLocation`; never rebuild it from + /// `newSlug`. The server is the only thing that knows what actually happened — renaming a + /// page to the name it already has is a successful no-op — and it answers with the slug the + /// store settled on rather than the one that was asked for. A second place computing that + /// answer is a second place to get it wrong. + /// + /// A rename is a hard move. The old name is freed the instant it commits: no redirect, no + /// tombstone, and it goes straight back into the pool for anybody's next page. Every link + /// already in circulation therefore breaks — as an ordinary `404` at first, and then, if + /// someone else claims the name, as a link that quietly points at their page instead. That + /// is the real cost of this verb, and the reason a caller should know whether the old URL + /// has been handed to anyone before spending it. + /// + /// `?ttl=` is measured from *now*, not from the page's creation, so `.days(30)` grants + /// thirty fresh days rather than whatever is left of them. It cannot raise the dead: an + /// expired page is gone as far as every verb on this server is concerned, so `.never` aimed + /// at one is a `404` and not a resurrection. + /// + /// Attribution is deliberately left alone, which is the inversion of `update`: `client_id` + /// records who wrote a page's bytes, and an amendment writes none. Contents, content type + /// and creation time survive for the same reason. + public func amend( + slug: String, + newSlug: String? = nil, + ttl: PageTTL? = nil, + using credential: Credential + ) async throws -> PageLocation { + var query: [URLQueryItem] = [] + if let newSlug { + query.append(URLQueryItem(name: Query.slug, value: newSlug)) + } + if let ttl { + query.append(URLQueryItem(name: PageTTL.queryParameter, value: ttl.queryValue)) + } + return try await send( + method: "PATCH", + path: Path.page(slug: slug), + query: query, + credential: credential, + expectation: .amend, + decoding: PageLocation.self + ) + } + /// `GET /skill` — the server's own instructions for publishing to it, as Markdown. /// /// Unauthenticated, like every other read on this server, and returned as text rather than diff --git a/Sources/SteleKit/SteleError.swift b/Sources/SteleKit/SteleError.swift index 8d5746b..b456b94 100644 --- a/Sources/SteleKit/SteleError.swift +++ b/Sources/SteleKit/SteleError.swift @@ -22,17 +22,25 @@ public enum SteleError: Error, Equatable, CustomStringConvertible { case unauthorized /// `403` — a valid credential without the scope this operation needs. case forbidden(missing: Scope?, detail: String?) - /// `404` — a PUT to a slug that does not exist, or an unknown client name. The advice - /// comes from the caller, because those two have nothing useful in common to say. + /// `404` — a PUT to a slug that does not exist, a PATCH to one with no live page behind it + /// (an expired page counts as none), or an unknown client name. The advice comes from the + /// caller, because those three have nothing useful in common to say. case notFound(detail: String?, advice: String) /// `409` on a write — the requested slug is taken. - case slugTaken(String?) + /// + /// The advice comes from the caller for the same reason `notFound`'s does, and it was the + /// same mistake found twice: "omit `--slug` and let the server generate one" is the right + /// next move on a `publish` and a false one on an `amend`, where omitting `--slug` means + /// *do not rename* and no slug is ever allocated. An agent that took that branch would come + /// back with a client-side "nothing to amend" and never reach the server at all. + case slugTaken(detail: String?, advice: String) /// `409` on an admin route — a live credential already holds that name. /// /// Split from `slugTaken` rather than sharing it, because a `409` means two unrelated things - /// on this server and the remedies have nothing in common: one is "choose another `--slug`", - /// and the other is "revoke the credential holding the name, or pick a different one". A - /// single case answered `admin clients create` by naming a flag that command does not have. + /// on this server and the remedies have nothing in common: one is about the name a *page* + /// wanted, and the other is "revoke the credential holding the name, or pick a different + /// one". A single case answered `admin clients create` by naming a flag that command does + /// not have. case nameTaken(String?) /// `413` — the page is over the server's byte limit. case pageTooLarge(String?) @@ -79,11 +87,8 @@ public enum SteleError: Error, Equatable, CustomStringConvertible { """ case .notFound(let detail, let advice): return "the server has nothing there\(Self.suffix(detail)) \(advice)" - case .slugTaken(let detail): - return """ - that slug is already taken\(Self.suffix(detail)) Choose another `--slug`, or \ - omit it and let the server generate one. - """ + case .slugTaken(let detail, let advice): + return "that slug is already taken\(Self.suffix(detail)) \(advice)" case .nameTaken(let detail): return """ a live credential already has that name\(Self.suffix(detail)) Choose another \ @@ -179,7 +184,11 @@ extension SteleError { /// route knows about itself: `/pages` has slugs, `/admin/clients` has names, and a read /// has neither and should say so rather than pick one. enum Conflict: Sendable { - case slug + /// A route where a slug is what collided, carrying what *this* route's caller can + /// do about it. Two routes ask for a slug and they do not have the same escape: one + /// can drop `--slug` and take whatever the server allocates, and the other cannot, + /// because there dropping it means asking for no rename at all. + case slug(advice: String) case name /// A route with nothing unique on it. A `409` here is a genuine surprise, and /// `unexpectedStatus` says exactly that instead of inventing advice. @@ -187,7 +196,7 @@ extension SteleError { func error(_ detail: String?) -> SteleError { switch self { - case .slug: return .slugTaken(detail) + case .slug(let advice): return .slugTaken(detail: detail, advice: advice) case .name: return .nameTaken(detail) case .none: return .unexpectedStatus(code: 409, detail: detail) } @@ -206,7 +215,32 @@ extension SteleError { notFoundAdvice: """ `stele update` never creates a page — publish it with `stele publish` first. """, - conflict: .slug + conflict: .slug(advice: """ + Choose another `--slug`, or omit it and let the server generate one. + """) + ) + /// A write too, and still `publish`-scoped, but it can borrow neither half of `write`'s + /// advice. The `404`: that one names `stele update` and tells the reader to publish the + /// page first, which is true of a replacement and misleading here — the `404` an + /// amendment earns is usually a page that has *expired*, and the thing the reader has to + /// be told is that this verb neither creates nor revives, otherwise the obvious next move + /// is `--ttl never`, which will fail exactly the same way. The `409`: `publish`'s + /// "omit it and let the server generate one" is an escape this route does not have. + /// Omitting `--slug` on an amendment is not a request for any other name, it is a request + /// for no rename — and on its own it is refused before the request is even sent. + static let amend = Expectation( + scope: .publish, + notFoundAdvice: """ + `stele amend` only ever moves a page that is still live — it never creates one, \ + and it cannot bring back a page whose deadline has already passed, not even \ + with `--ttl never`. Check the slug, and publish the page again with \ + `stele publish --slug ` if it is gone. + """, + conflict: .slug(advice: """ + Another live page holds that name. Choose a different `--slug`: dropping it \ + asks for no rename rather than for another name, and an amendment never \ + allocates one of its own. + """) ) static let administration = Expectation( scope: .admin, diff --git a/Sources/SteleKit/Version.swift b/Sources/SteleKit/Version.swift index dbb196e..39f664b 100644 --- a/Sources/SteleKit/Version.swift +++ b/Sources/SteleKit/Version.swift @@ -8,7 +8,7 @@ public enum SteleVersion { /// Bumped by hand. Nothing derives it from git: a release built from a tarball or an /// unclean tree must still report a version the server can compare. - public static let current = "0.2.0" + public static let current = "0.3.0" /// Sent on every request. The `stele-cli/` product token is what the server matches on. public static var userAgent: String { "stele-cli/\(current)" } diff --git a/Sources/stele/Exit.swift b/Sources/stele/Exit.swift index 07c8608..e7a5d72 100644 --- a/Sources/stele/Exit.swift +++ b/Sources/stele/Exit.swift @@ -35,14 +35,17 @@ enum Exit { static let forbidden: Int32 = 4 /// `409` — the requested slug is taken. The one failure with an obvious programmatic - /// remedy: choose another `--slug`, or drop it and let the server allocate. + /// remedy: ask for a different `--slug`. On `publish` it can also be dropped, and the server + /// allocates one; on `amend` it cannot, because dropping it there means "do not rename" — + /// which is why the message carries the route's own advice and this table does not repeat it. static let slugTaken: Int32 = 5 /// `413` / `415` — the page itself is the problem. Retrying is pointless; the input changes /// or nothing does. static let pageRejected: Int32 = 6 - /// `404` — an update to a slug that does not exist, or an unknown client name. + /// `404` — an update to a slug that does not exist, an amendment to one with no live page + /// behind it (a page that has expired counts as none), or an unknown client name. static let notFound: Int32 = 7 /// `426` — this build is older than the server requires. Uniquely recoverable without @@ -65,7 +68,7 @@ enum Exit { (noCredential, "no usable credential here — ask the user to run `stele auth login`"), (credentialRejected, "the server rejected the credential — ask the user to log in again"), (forbidden, "valid credential, insufficient scope — an operator has to run this"), - (slugTaken, "that slug is taken — choose another --slug or omit it"), + (slugTaken, "that slug is taken — ask for a different --slug"), (pageRejected, "the page is too large or the wrong type"), (notFound, "no such page or client"), (upgradeRequired, "the CLI is too old — reinstall it and retry once"), diff --git a/Sources/stele/PageCommands.swift b/Sources/stele/PageCommands.swift index aa87fe5..13587bc 100644 --- a/Sources/stele/PageCommands.swift +++ b/Sources/stele/PageCommands.swift @@ -2,12 +2,18 @@ import ArgumentParser import Foundation import SteleKit -/// What `publish` and `update` share: reading a file off disk and printing where it landed. +/// What the three page-writing commands share: reading a file off disk, and printing where the +/// page landed. /// -/// Both commands are one API call around the same two chores, and both have exactly one line of -/// output that a caller might capture, so the rules about *what goes on stdout* are worth -/// stating once. The URL, alone, unstyled, is the whole of stdout on success — that is what -/// makes `url=$(stele publish page.html)` work and what an agent will do with it. +/// `publish` and `update` do both chores; `amend` does only the second, because it sends no +/// bytes at all. What all three have in common is exactly one line of output a caller might +/// capture, so the rules about *what goes on stdout* are worth stating once. The URL, alone, +/// unstyled, is the whole of stdout on success — that is what makes +/// `url=$(stele publish page.html)` work and what an agent will do with it. +/// +/// `report` printing the *response's* URL rather than one assembled from the arguments is what +/// makes it usable by `amend` at all: after a rename the address has changed, and the response +/// is the only thing that knows what it changed to. enum PageIO { /// Reads the page, with an error that names the file rather than surfacing a Foundation /// description that begins "The file couldn't be opened". @@ -148,9 +154,10 @@ struct UpdateCommand: SteleCommand { quietly publishing at a URL nobody has seen. Publish it with `stele publish --slug \ ` first if that is what you meant. - There is no --ttl here. A page's lifetime is fixed when it is published, so \ - replacing the body cannot buy the link more time; the deadline it already has is \ - printed under the URL. Republish it if you need a different one. + There is no --ttl here, deliberately: replacing a body cannot buy the link more \ + time, and a lifetime quietly reset by every edit would be a deadline nobody could \ + predict. The deadline the page already has is printed under the URL, unchanged. \ + `stele amend --ttl` is how you move it, without touching the contents. """ ) @@ -185,6 +192,104 @@ struct UpdateCommand: SteleCommand { } } +struct AmendCommand: SteleCommand { + static let configuration = CommandConfiguration( + commandName: "amend", + abstract: "Rename a page or change its deadline. Prints the URL.", + discussion: """ + Amends a page's name, its deadline, or both, and nothing else. The contents, the \ + content type and the record of who published them survive untouched — an amendment \ + writes no bytes, so it does not claim to have written them. + + Omitting --ttl leaves the existing deadline exactly where it is; it does not \ + reapply the default lifetime a new page would get. Passing one counts from now \ + rather than from publication, so --ttl 30 grants thirty fresh days rather than \ + whatever is left of the original thirty. + + A rename is a hard move. The old name is freed the moment it commits — it serves an \ + ordinary 404 and returns to the pool for anybody's next page to claim, with no \ + redirect and no tombstone left behind. Every link already in circulation breaks. If \ + the URL is already out in the world and what you want is for it to say something \ + else, that is `stele update`. + + Never creates and never revives: amending a slug with no live page behind it exits \ + 7, and a page that has already expired counts as none, so --ttl never cannot bring \ + one back. Exits 5 if the name you asked for is held by another page. + + The URL on stdout is the page's URL after the amendment, which may not be the one \ + you passed in. Read it from here rather than assembling it yourself. + """ + ) + + @OptionGroup var options: GlobalOptions + + @Argument(help: ArgumentHelp("The page to amend, as its slug appears in the URL.", valueName: "slug")) + var slug: String + + /// The new name, spelled `--slug` on the command line to match `publish --slug`, but held in + /// a differently named property because the positional above has already taken `slug`. The + /// flag is the part users and agents type, so it is the part that stays consistent. + @Option( + name: .customLong("slug"), + help: ArgumentHelp( + "Rename the page to this slug.", + discussion: """ + Lowercase letters, digits and hyphens; the server has the last word. Renaming a \ + page to the name it already has succeeds and changes nothing. + """, + valueName: "name" + ) + ) + var newSlug: String? + + @Option( + name: .long, + help: ArgumentHelp( + "Give the page a new lifetime: a number of days, or 'never'.", + discussion: """ + 30 and 30d both mean thirty days; 2w means fourteen. Counted from now rather \ + than from when the page was published, so 30 grants thirty fresh days. Omit it \ + and the deadline the page already has is left alone — unlike `publish`, this is \ + not a way to fall back to the default lifetime. The server has the last word on \ + the maximum. + """, + valueName: "days" + ), + completion: .list([PageTTL.neverKeyword, "7", "30", "90"]) + ) + var ttl: String? + + func execute() async throws { + // Refused here, before a credential is read or a byte is sent. The server answers this + // with a 400 of its own, but an amendment naming nothing to amend is a mistake in the + // invocation rather than a disagreement with the server, and this side can name the two + // flags that would fix it — the same bargain `PageIO.read` strikes over an empty file. + guard newSlug != nil || ttl != nil else { + throw Failure(""" + nothing to amend — pass --slug to rename the page, --ttl to change \ + its deadline, or both. + """) + } + // Parsed before the request rather than after, for `publish`'s reason: a lifetime this + // side can already tell is unusable should not cost a round trip. Note that it stays nil + // when --ttl was not given, and must — a defaulted value here would put a deadline on a + // page the caller only asked to rename. + let lifetime = try ttl.map { raw -> PageTTL in + do { return try PageTTL.parse(raw) } catch { throw Failure("\(error)") } + } + let credential = try options.credential() + let location = try await SteleClient(credential: credential).amend( + slug: slug, + newSlug: newSlug, + ttl: lifetime, + using: credential + ) + // The usual report, and it matters more here than anywhere else: after a rename the + // response is the only thing that knows where the page now lives. + try PageIO.report(location, options: options) + } +} + struct SkillCommand: SteleCommand { static let configuration = CommandConfiguration( commandName: "skill", diff --git a/Sources/stele/Stele.swift b/Sources/stele/Stele.swift index 183befa..9b59354 100644 --- a/Sources/stele/Stele.swift +++ b/Sources/stele/Stele.swift @@ -30,6 +30,7 @@ struct Stele: AsyncParsableCommand { AuthCommand.self, PublishCommand.self, UpdateCommand.self, + AmendCommand.self, SkillCommand.self, AdminCommand.self, ] diff --git a/Tests/SteleKitTests/SteleClientTests.swift b/Tests/SteleKitTests/SteleClientTests.swift index 04f56a4..37f9291 100644 --- a/Tests/SteleKitTests/SteleClientTests.swift +++ b/Tests/SteleKitTests/SteleClientTests.swift @@ -148,6 +148,180 @@ struct SteleClientRequestTests { #expect(request.url.absoluteString == "https://stele.example.com/pages/my-page") } + @Test("amend patches the slug's own path") + func amend() async throws { + let transport = FakeTransport( + status: 200, + body: """ + {"slug":"loud-cedar-otter","url":"https://stele.example.com/loud-cedar-otter", + "expires":null} + """ + ) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + let location = try await client.amend( + slug: "quiet-cedar-otter", newSlug: "loud-cedar-otter", using: credential + ) + + #expect(location.slug == "loud-cedar-otter") + let request = try #require(await transport.last) + #expect(request.method == "PATCH") + // Addressed at the page as it is *now*: the old name is what identifies the row, and the + // new one is what is being asked for, so they cannot swap places. + #expect( + request.url.absoluteString + == "https://stele.example.com/pages/quiet-cedar-otter?slug=loud-cedar-otter" + ) + } + + /// The load-bearing one. `ttl: nil` means opposite things on the two verbs — on `publish` it + /// asks for the server's default lifetime, and here it means *leave the deadline alone* — and + /// nothing in the type system carries that difference. It rests entirely on this request not + /// inventing a value. A `ttl=7` appended because seven looked like a sensible default would + /// put a week's deadline on a page its author published to keep forever, and the `200` that + /// came back would look exactly like the one a correct request earns; the page would simply + /// be gone the following week. So the assertion is absence rather than emptiness: `?ttl=` + /// with nothing after it is a value too, and a value is the thing that must not be sent. + @Test("a rename with no lifetime sends no ttl at all") + func renameSendsNoLifetime() async throws { + let transport = FakeTransport(status: 200, body: #"{"slug":"new-name","url":"u"}"#) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + _ = try await client.amend(slug: "old-name", newSlug: "new-name", using: credential) + + let request = try #require(await transport.last) + #expect(request.url.query == "slug=new-name") + // Not merely "no ttl parameter": no `ttl` anywhere in the request at all, which is the + // form the assertion has to take because an empty or defaulted one would still parse. + #expect(!request.url.absoluteString.contains("ttl")) + } + + /// The mirror, and the reason the two parameters are appended independently rather than + /// built as a pair: retiming a page must not rename it, and `?slug=` echoing the page's + /// current name would be a rename that happens to be a no-op today and a collision the day + /// the caller passes a stale slug. + @Test("a lifetime with no rename sends no slug at all") + func retimeSendsNoSlug() async throws { + let transport = FakeTransport(status: 200, body: #"{"slug":"old-name","url":"u"}"#) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + _ = try await client.amend(slug: "old-name", ttl: .days(30), using: credential) + + let request = try #require(await transport.last) + let query = try #require(request.url.query) + #expect(query == "ttl=30") + #expect(!query.contains("slug")) + } + + /// The server takes both in one `PATCH`, so a caller that asked for both gets one round trip + /// and one outcome. Split into two requests they could half-succeed — a page renamed and + /// still dying on Thursday — with no verb left to say so. + @Test("a rename and a lifetime travel together in one request") + func renameAndLifetime() async throws { + let transport = FakeTransport(status: 200, body: #"{"slug":"new-name","url":"u"}"#) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + _ = try await client.amend( + slug: "old-name", newSlug: "new-name", ttl: .never, using: credential + ) + + let request = try #require(await transport.last) + #expect(request.url.query == "slug=new-name&ttl=never") + #expect(await transport.requests.count == 1) + } + + /// Spelled the server's way, from the server's own constant. `ttl=forever` would be a `400` + /// and a computed date would be a deadline rather than the absence of one — and `never` is + /// the only word this route reads as "keep it". + @Test("an amended lifetime of never travels as the server's own keyword") + func amendNeverKeyword() async throws { + let transport = FakeTransport(status: 200, body: #"{"slug":"old-name","url":"u"}"#) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + _ = try await client.amend(slug: "old-name", ttl: .never, using: credential) + + #expect(try #require(await transport.last).url.query == "ttl=\(PageTTL.neverKeyword)") + } + + /// An amendment writes no bytes — that is the whole shape of the verb, and the reason the + /// server leaves `client_id` alone where `PUT` overwrites it. A `Content-Type` on it would + /// be a claim about content this request is not sending, and a body would be bytes the + /// server has already promised not to read. + @Test("amend sends no body and no content type") + func amendSendsNothing() async throws { + let transport = FakeTransport(status: 200, body: #"{"slug":"new-name","url":"u"}"#) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + _ = try await client.amend(slug: "old-name", newSlug: "new-name", using: credential) + + let request = try #require(await transport.last) + #expect(request.body == nil) + #expect(request.contentType == nil) + #expect(request.headerFields()["Content-Type"] == nil) + // Still authenticated, though: an amendment is a write and carries the publish scope. + #expect(request.headerFields()["Authorization"] == "Bearer stele_pat_test") + } + + /// The server has the last word on what the page is now called, and this side reads it off + /// the response rather than assuming the request got what it asked for. Renaming a page to + /// the name it already has is a successful no-op, so "what was asked for" and "what the store + /// settled on" are genuinely different questions — and a URL rebuilt from `newSlug` would be + /// right often enough to survive review and wrong the day it mattered. + @Test("the resulting slug is the server's answer, not the one that was asked for") + func resultingSlugComesFromTheServer() async throws { + let transport = FakeTransport( + status: 200, + body: """ + {"slug":"server-settled-on-this", + "url":"https://stele.example.com/server-settled-on-this","expires":null} + """ + ) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + let location = try await client.amend( + slug: "old-name", newSlug: "asked-for-this", using: credential + ) + + #expect(location.slug == "server-settled-on-this") + #expect(location.url == "https://stele.example.com/server-settled-on-this") + } + + /// The *other* surface a slug arrives on, and it is protected by something different from + /// the path segment's percent-encoding. A query value is not a path: dot-segment removal + /// never looks at it, so `../admin/clients` stays a string sitting in `?slug=`, unresolved, + /// and the request still acts on the page named in the path. That is the property worth + /// asserting — not an escaping that does not happen here. What the value *means* is the + /// server's `Slug` type's business, and a name like this comes back as the `400` that names + /// the rule it broke, which is exactly where that judgement belongs. + @Test("a new slug cannot climb out of /pages") + func newSlugStaysInTheQuery() async throws { + let transport = FakeTransport(status: 200, body: #"{"slug":"x","url":"u"}"#) + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + _ = try? await client.amend( + slug: "quiet-cedar-otter", newSlug: "../admin/clients", using: credential + ) + + let request = try #require(await transport.last) + #expect(request.url.path == "/pages/quiet-cedar-otter") + #expect(request.url.standardized.path == "/pages/quiet-cedar-otter") + // It travelled as a value, whole, for the server to reject on its own terms. + let components = try #require( + URLComponents(url: request.url, resolvingAgainstBaseURL: false) + ) + #expect( + components.queryItems == [URLQueryItem(name: "slug", value: "../admin/clients")] + ) + } + /// A slug is one path segment and must stay one, however it is spelled. Left raw it is not /// only a `/` away from another route: RFC 3986 removes dot segments before the request is /// ever sent, so `update ../admin/clients` reaches the server as `PUT /admin/clients` — an @@ -492,6 +666,20 @@ struct PageLocationTests { } } +/// The `409` advice a route carries, read back out of the case that holds it. +/// +/// A test asserting the status→case mapping by identity has to build the expected case by hand, +/// and the advice is part of it — but retyping the sentence here would be a copy that goes on +/// passing while the two drift, which is the failure the advice was moved onto `Expectation` to +/// prevent in the first place. What the identity test is for is the *case*; which route's words +/// it carries is asserted separately, by wording, in `amendConflictIsASlug`. +extension SteleError.Expectation { + var slugTakenAdvice: String { + guard case .slug(let advice) = conflict else { return "" } + return advice + } +} + @Suite("status mapping") struct SteleErrorMappingTests { private func failure(status: Int, body: String = "") async -> (any Error)? { @@ -507,6 +695,22 @@ struct SteleErrorMappingTests { } } + /// The same round trip through `amend`, so what is asserted is the advice this *route* + /// produces rather than the `Expectation` a test picked by hand — the mistake being guarded + /// against is the call site passing the wrong one. + private func amendFailure(status: Int, body: String = "") async -> (any Error)? { + do { + let credential = try testCredential() + let client = SteleClient( + credential: credential, transport: FakeTransport(status: status, body: body) + ) + _ = try await client.amend(slug: "old-name", newSlug: "new-name", using: credential) + return nil + } catch { + return error + } + } + /// One case per outcome the caller reacts to differently, and every description ends in an /// instruction — the reader is an agent deciding whether to retry, fix the input, or stop. @Test( @@ -540,7 +744,12 @@ struct SteleErrorMappingTests { (401, .unauthorized), (403, .forbidden(missing: .publish, detail: "m")), (404, .notFound(detail: "m", advice: SteleError.Expectation.write.notFoundAdvice)), - (409, .slugTaken("m")), + ( + 409, + .slugTaken( + detail: "m", advice: SteleError.Expectation.write.slugTakenAdvice + ) + ), (413, .pageTooLarge("m")), (415, .unsupportedContentType("m")), (426, .upgradeRequired("m")), @@ -593,14 +802,62 @@ struct SteleErrorMappingTests { SteleError.from(status: 409, detail: nil, expectation: .administration) ) - #expect(write == .slugTaken(nil)) + #expect( + write + == .slugTaken( + detail: nil, advice: SteleError.Expectation.write.slugTakenAdvice + ) + ) #expect(admin == .nameTaken(nil)) #expect(write.description.contains("--slug")) + // A publish *does* have the escape amend has not: no `--slug` at all, and the server + // allocates one. The advice says so, and this is the route it is true of. + #expect(write.description.contains("omit it and let the server generate one")) #expect(admin.description.contains("stele admin clients revoke")) // The specific mistake this split exists to make impossible. #expect(!admin.description.contains("--slug")) } + /// The whole reason `Expectation.amend` exists rather than borrowing `write`'s. A `404` here + /// is most often a page that has *expired*, and `write`'s advice — "`stele update` never + /// creates a page, publish it first" — names the wrong command and, worse, leaves the obvious + /// next move looking like `--ttl never`, which fails identically because this verb cannot + /// revive anything. + @Test("a 404 from amend advises about amend rather than about update") + func amendNotFoundAdvice() async throws { + let error = try #require( + await amendFailure(status: 404, body: #"{"error":{"message":"No page there"}}"#) + as? SteleError + ) + #expect(error.description.contains("stele amend")) + #expect(error.description.contains("never creates")) + #expect(!error.description.contains("stele update")) + } + + /// The new name being held by another live page. It has to reach `slugTaken` rather than + /// `nameTaken` or an `unexpectedStatus`, because that case is what carries the exit code an + /// agent reads as "pick another name and retry" — the one outcome here that is worth + /// retrying at all. + /// + /// It carries *this* route's advice with it, and the second half of that is what the last + /// assertion is for. `publish`'s sentence ends "or omit it and let the server generate one", + /// which on an amendment is a dead end: omitting `--slug` asks for no rename, the server + /// allocates nothing, and an agent that follows it lands on the client-side "nothing to + /// amend" and exit 1 without a request ever leaving the process. Same status, same case, two + /// remedies — exactly the split `nameTaken` already exists for. + @Test("a 409 from amend is a taken slug, with advice amend can actually act on") + func amendConflictIsASlug() async throws { + let error = try #require(await amendFailure(status: 409, body: "") as? SteleError) + #expect( + error + == .slugTaken( + detail: nil, advice: SteleError.Expectation.amend.slugTakenAdvice + ) + ) + #expect(error.description.contains("--slug")) + #expect(!error.description.contains("omit it and let the server generate one")) + } + /// A read has nothing unique on it for a `409` to be about, so this client says it has no /// advice rather than picking whichever of the two conflicts it was written next to. @Test("409 on a route with nothing unique is an unexpected status") @@ -621,6 +878,12 @@ struct SteleErrorMappingTests { for status in [400, 401, 403, 404, 409, 413, 415, 426, 503, 500] { seen.append(try #require(SteleError.from(status: status, detail: nil, expectation: .write))) } + // The two statuses that answer in the route's own words, taken from the other route that + // has any: advice is per-`Expectation`, so "every description names a next step" is a + // claim about each set of words and not about each case. + for status in [404, 409] { + seen.append(try #require(SteleError.from(status: status, detail: nil, expectation: .amend))) + } seen.append(.transportFailure(host: try SteleHost("https://stele.example.com"), reason: "refused")) seen.append(.malformedResponse("not JSON")) diff --git a/scripts/integration-smoke.sh b/scripts/integration-smoke.sh index 1f6c16e..f097e2b 100755 --- a/scripts/integration-smoke.sh +++ b/scripts/integration-smoke.sh @@ -334,6 +334,8 @@ expect_eq "--ttl never exits 0" "0" "$STATUS" printf '%s' "$OUT" > "$WORK/page-never.json" expect_eq "…and the page is kept: an explicit null, not a missing key" "null" \ "$(json_key_state "$WORK/page-never.json" expires)" +# Kept means kept: this row outlives the run, so its name is held for the footer to report. +NEVER_SLUG="$(json_field "$WORK/page-never.json" slug)" run_stele publish "$WORK/page.html" --ttl 1 expect_eq "--ttl 1 exits 0" "0" "$STATUS" @@ -394,6 +396,142 @@ run_stele update "$SLUG" "$WORK/page2.html" --ttl never expect_eq "update has no --ttl to give" "64" "$STATUS" expect_contains "…and says so rather than silently extending the page" "--ttl" "$ERR" +# --------------------------------------------------------------------------------- +section "5b. amend moves the name and the deadline, and nothing else" +# --------------------------------------------------------------------------------- + +MOVED="smoke-moved-$RUN_ID" +ARRIVED="smoke-arrived-$RUN_ID" + +# Published with no --ttl deliberately. An ephemeral page is the one a rename could quietly +# re-date, and the deadline recorded here is the value the rename checks below compare against. +run_stele publish "$WORK/page.html" --slug "$MOVED" --json +expect_eq "a page to amend is published" "0" "$STATUS" +printf '%s' "$OUT" > "$WORK/amend-before.json" +MOVED_URL="$(json_field "$WORK/amend-before.json" url)" +MOVED_EXPIRY="$(json_field "$WORK/amend-before.json" expires)" +expect_eq "…and it has a deadline to preserve" "set" \ + "$(json_key_state "$WORK/amend-before.json" expires)" + +run_stele amend "$MOVED" --slug "$ARRIVED" +expect_eq "amend --slug exits 0" "0" "$STATUS" +# What this pins is that the rename landed where it was asked to and that the URL on stdout is +# the page's new address rather than the one that was passed in. What it does *not* pin, and +# cannot: that the address was read off the response. A client that rebuilt it as host + its own +# --slug argument prints this same string, and only a server answering with a different slug +# would separate the two — which nothing here can arrange. `resultingSlugComesFromTheServer` in +# SteleClientTests is the check that holds that half, with a fake transport that does exactly +# that; if this line ever looks like the belt to its braces, it is not one. +expect_eq "…and prints the URL the page now has, not the one it was asked about" \ + "$HOST/$ARRIVED" "$OUT" +ARRIVED_URL="$OUT" + +curl -sS "$ARRIVED_URL" -o "$WORK/moved.html" +if cmp -s "$WORK/page.html" "$WORK/moved.html"; then + pass "the bytes are served at the new name, unchanged by the move" +else + fail "the bytes are served at the new name" "the file that was published" \ + "$(diff "$WORK/page.html" "$WORK/moved.html" | head -5)" +fi +# An amendment writes no bytes, so it must not have opinions about what they are either. +expect_eq "…still as text/html, a type the amendment never touched" "text/html; charset=utf-8" \ + "$(curl -sS -o /dev/null -w '%{content_type}' "$ARRIVED_URL")" + +# The hard move, and the assertion worth having. No redirect, no tombstone, no 410 saying the +# page went somewhere: the old name is *freed*, and whoever kept the link gets the same 404 as +# a name that was never published at all. Nothing else in this suite watches a slug go back +# into the pool, and a well-meant redirect would be an invisible change to what a link means. +expect_eq "…and the old name 404s at once, with nothing left behind" "404" \ + "$(http_status "$MOVED_URL")" + +# Back again, which asks two questions in one request. The freed name has to be claimable — +# by this page or by anybody's next one — and the deadline has to have survived both moves +# untouched. That second half is the trap the whole route turns on: --ttl was not passed, so +# the CLI must have sent no ttl parameter at all. A defaulted one would surface here as a +# deadline a week out instead of the one this page was published with. +run_stele amend "$ARRIVED" --slug "$MOVED" --json +expect_eq "the freed name is claimable again, exit 0" "0" "$STATUS" +printf '%s' "$OUT" > "$WORK/amend-back.json" +expect_eq "…and the response names the resulting slug" "$MOVED" \ + "$(json_field "$WORK/amend-back.json" slug)" +expect_eq "…while a rename alone leaves the deadline exactly where it was" \ + "$MOVED_EXPIRY" "$(json_field "$WORK/amend-back.json" expires)" + +# --ttl alone, the other half of the route, and `never` is the state worth landing on: the +# CLI's encoder says "kept" out loud as a null rather than dropping the key, which 4b pins for +# publish and which an amendment has to spell the same way or a reader learns nothing. +run_stele amend "$MOVED" --ttl never --json +expect_eq "amend --ttl exits 0" "0" "$STATUS" +printf '%s' "$OUT" > "$WORK/amend-never.json" +expect_eq "…and the page that had a deadline is kept: an explicit null" "null" \ + "$(json_key_state "$WORK/amend-never.json" expires)" +expect_eq "…while a retime alone leaves the name where it was" "$MOVED" \ + "$(json_field "$WORK/amend-never.json" slug)" + +KEPT="smoke-kept-$RUN_ID" +KEPT_MOVED="smoke-kept-moved-$RUN_ID" + +# The reason this section exists. A page published to be kept, renamed with --slug and nothing +# else. On POST an absent ttl means the default lifetime; on PATCH it means "do not touch the +# deadline", and a client that sends its own default to save the server the trouble hands a +# permanent page a week to live — silently, with a 200 and a URL that looks perfectly fine. +# This is the only check anywhere that would notice. The null is the whole assertion. +run_stele publish "$WORK/page.html" --slug "$KEPT" --ttl never --json +expect_eq "a kept page is published" "0" "$STATUS" +printf '%s' "$OUT" > "$WORK/kept-before.json" +expect_eq "…with no deadline at all" "null" "$(json_key_state "$WORK/kept-before.json" expires)" + +run_stele amend "$KEPT" --slug "$KEPT_MOVED" --json +expect_eq "renaming a kept page exits 0" "0" "$STATUS" +printf '%s' "$OUT" > "$WORK/kept-after.json" +expect_eq "…and it is still kept: a rename does not hand it the default lifetime" "null" \ + "$(json_key_state "$WORK/kept-after.json" expires)" + +# An amendment naming nothing to amend is refused by the command, not by the server's 400 — +# which is a claim about *when* it fails, so the credential file is parked to prove it. With +# nothing to authenticate with, a check that reached the transport would have died asking for +# `stele auth login`; this one still names the missing flags, so it never left the process. +mv "$CRED" "$WORK/credential.parked" +run_stele amend "$MOVED" +expect_eq "amend with neither --slug nor --ttl is refused, exit 1" "1" "$STATUS" +expect_contains "…and names the flags that would fix it" "--slug" "$ERR" +expect_eq "…printing no URL" "" "$OUT" +case "$ERR" in + *"auth login"*) fail "…refusing before it reads a credential, so nothing was sent" \ + "the nothing-to-amend message" "$ERR" ;; + *) pass "…refusing before it reads a credential, so nothing was sent" ;; +esac +mv "$WORK/credential.parked" "$CRED" + +# The same 409 vocabulary `publish --slug` earns, now on a route that can collide without +# uploading anything: $MOVED is live, so asking for its name is a conflict about slugs. +run_stele amend "$KEPT_MOVED" --slug "$MOVED" +expect_eq "409 amend onto a name another page holds -> exit 5" "5" "$STATUS" +expect_contains "…naming the flag to change" "--slug" "$ERR" +# The other half of the advice has to be this route's own too. `publish`'s sentence ends "or +# omit it and let the server generate one", and an agent that took that branch here would ask +# for no rename at all: the server allocates nothing on PATCH, so it would meet the local +# "nothing to amend" refusal and exit 1 without a request ever going out. +case "$ERR" in + *"generate one"*) fail "…and not by telling it to omit --slug, which renames nothing" \ + "advice about choosing a different name" "$ERR" ;; + *) pass "…and not by telling it to omit --slug, which renames nothing" ;; +esac + +# 404, with advice that has to be this route's own. The one `update` shares would send the +# reader to `stele update`, which is the wrong verb twice over — there is no page there to +# replace either — and the useful thing to say is that amend never creates and cannot revive, +# because the obvious next guess for a page that has expired is `--ttl never`, which fails +# exactly the same way. +run_stele amend "smoke-no-such-page-$RUN_ID" --ttl never +expect_eq "404 amend a slug with no live page -> exit 7" "7" "$STATUS" +expect_contains "…and the advice says amend never creates one" "never creates one" "$ERR" +case "$ERR" in + *"stele update"*) fail "…and does not send the reader to \`stele update\`" \ + "advice about publishing the page again" "$ERR" ;; + *) pass "…and does not send the reader to \`stele update\`" ;; +esac + # --------------------------------------------------------------------------------- section "6. --expires-in survives the round trip" # --------------------------------------------------------------------------------- @@ -568,7 +706,14 @@ expect_contains "…and returns the server's own skill document" "stele auth sta # --------------------------------------------------------------------------------- printf '\n\033[32msmoke: %d checks passed.\033[0m\n' "$CHECKS" -printf 'Left behind on %s: 2 pages (%s, %s) and %d client rows, revoked.\n' \ - "$HOST" "$SLUG" "$AGENT_SLUG" 3 +# Eight pages, and the count is kept honest by hand because nothing here tracks them: sections 4 +# and 8 publish one each, 4b publishes four to compare lifetimes, and 5b leaves two. Only the +# three below are permanent — the rest carry a deadline and the server reclaims them on its own +# schedule — so those three are the ones named, because they are the ones a human ends up +# deleting. Naming two of the ephemeral ones instead, as this line used to, sent whoever was +# tidying up home with six rows still on the deployment. +printf 'Left behind on %s: 8 pages and %d client rows, revoked.\n' "$HOST" 3 +printf 'Five of the pages expire on their own; these three never do: %s, %s, %s.\n' \ + "$NEVER_SLUG" "$MOVED" "$KEPT_MOVED" printf 'The server has no delete route, so pages and revoked credentials accumulate —\n' printf 'which is why this belongs on a throwaway deployment, not a real one.\n'