From 407df13205eacb84c43e6173d0285f67f63ac1ac Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:22:41 -0400 Subject: [PATCH] feat: take a published page down with `stele delete` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server has answered DELETE /pages/:slug since the beginning; this gives the CLI a verb for it. `SteleClient.delete` bypasses the JSON-decoding `send` helper — success is a 204 with no body, and routed through `send` every successful delete would surface as a malformedResponse. Its 404 advice is written for the reader a delete actually has: "already gone" is the work being done, not a page to publish first. On success stdout stays empty, deliberately — every other page command's stdout is a URL, and a deleted page has none to print. The confirmation goes to stderr; --json echoes the slug back. A delete aimed at a slug with no live page behind it exits 7 rather than claiming work the server never did. Co-Authored-By: Claude Fable 5 --- README.md | 1 + Sources/SteleKit/SteleClient.swift | 36 ++++++ Sources/SteleKit/SteleError.swift | 20 ++++ Sources/stele/Exit.swift | 5 +- Sources/stele/PageCommands.swift | 53 ++++++++- Sources/stele/Stele.swift | 1 + Tests/SteleKitTests/SteleClientTests.swift | 124 +++++++++++++++++++++ 7 files changed, 237 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5eb8a7a..835f0ea 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ unpublished, and a caller handed only a URL would find that out when the link br | `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 delete ` | agent | `DELETE /pages/:slug`. Permanent and immediate — the slug is freed and returns to the pool, so every link to it breaks. Prints nothing on stdout; the confirmation is on stderr. | | `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. | diff --git a/Sources/SteleKit/SteleClient.swift b/Sources/SteleKit/SteleClient.swift index 4ae5e30..c63fa09 100644 --- a/Sources/SteleKit/SteleClient.swift +++ b/Sources/SteleKit/SteleClient.swift @@ -238,6 +238,42 @@ public struct SteleClient: Sendable { ) } + /// `DELETE /pages/:slug`. Takes a page down, and answers with nothing at all. + /// + /// The one write here that does not go through `send`, and it cannot: success is a `204` with + /// no body — the server strips even `Content-Length` — while `send` hands whatever came back + /// to a `JSONDecoder` unconditionally. Routed through it, every *successful* delete would + /// surface as a `malformedResponse` complaining about zero bytes, with the page genuinely + /// gone by then and the caller told the server had answered strangely. So this calls + /// `perform` directly, the way `fetchSkill` does, and discards the response. There is nothing + /// in it to read. + /// + /// Deletion is permanent and immediate: no tombstone, no redirect, and the slug goes straight + /// back into the pool for anybody's next page. That is the same hard move a rename makes, and + /// it costs the same — links already in circulation break as a plain `404` at first, and then, + /// once someone else is allocated the name, as links that quietly point at their page instead. + /// + /// A `404` means the page was already gone, and an expired-but-unreclaimed page counts as gone + /// here exactly as it does for `update` and `amend`. It is reported rather than swallowed: the + /// server declines to claim a deletion it did not perform, and this method does not soften + /// that into success on its behalf. + public func delete(slug: String, using credential: Credential) async throws { + // `credential.host`, deliberately — see the type's documentation. Every other + // authenticated call inherits that rule from `send`; this one, bypassing it, has to keep + // the rule itself. + let target = credential.host + _ = try await perform( + SteleRequest( + method: "DELETE", + url: try url(path: Path.page(slug: slug), on: target), + credential: credential, + timeout: timeout + ), + host: target, + expectation: .delete + ) + } + /// `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 b456b94..a85c05c 100644 --- a/Sources/SteleKit/SteleError.swift +++ b/Sources/SteleKit/SteleError.swift @@ -242,6 +242,26 @@ extension SteleError { allocates one of its own. """) ) + /// The third `publish`-scoped write, and the third route that cannot take `write`'s `404` + /// as it stands. That one names `stele update` and tells the reader to publish the page + /// first, which here is not merely the wrong command but the opposite instruction: an + /// agent following it would recreate the very page it was asked to remove, and the run + /// would end with the page live and nothing reading as a failure. What a `404` on a delete + /// actually reports is that the work is already done — the page expired, or an earlier + /// delete took it — so the advice names no follow-up command, because there is none to + /// name. Its whole job is to stop the reader treating "already gone" as a problem of + /// theirs to fix. The `409` is `.none` for the plainer reason: this route asks for no + /// name, so there is nothing on it for a conflict to be about. + static let delete = Expectation( + scope: .publish, + notFoundAdvice: """ + Nothing is published at that slug — it may have expired, or it may already have \ + been deleted. Either way the page is gone and there is nothing left to delete: \ + the server refuses to claim work it never did rather than answering yes to a \ + page it does not have. If you expected one there, the slug is the thing to check. + """, + conflict: .none + ) static let administration = Expectation( scope: .admin, notFoundAdvice: "Check the name against `stele admin clients list`.", diff --git a/Sources/stele/Exit.swift b/Sources/stele/Exit.swift index e7a5d72..542602f 100644 --- a/Sources/stele/Exit.swift +++ b/Sources/stele/Exit.swift @@ -44,8 +44,9 @@ enum Exit { /// or nothing does. static let pageRejected: Int32 = 6 - /// `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. + /// `404` — an update to a slug that does not exist, an amendment or a deletion aimed at 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 diff --git a/Sources/stele/PageCommands.swift b/Sources/stele/PageCommands.swift index 13587bc..4c5c8d2 100644 --- a/Sources/stele/PageCommands.swift +++ b/Sources/stele/PageCommands.swift @@ -6,7 +6,8 @@ import SteleKit /// page landed. /// /// `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 +/// bytes at all. `delete` does neither and is not routed through here at all — it ends with no +/// page to point at. 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. @@ -290,6 +291,56 @@ struct AmendCommand: SteleCommand { } } +struct DeleteCommand: SteleCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a published page. Prints nothing.", + discussion: """ + Permanent and immediate. No tombstone, no redirect: the slug is freed the moment it \ + commits and returns to the pool for anybody's next page to claim. Every link already \ + in circulation breaks as an ordinary 404 — and may one day answer with somebody \ + else's page, which is the part worth saying out loud before you type this. \ + Republishing the same file afterwards is a new page, not the old one back. + + Never a silent success: deleting a slug with no live page behind it exits 7, and a \ + page that has already expired counts as none. The server refuses to claim work it \ + never did, so a typo'd slug is an error you can branch on rather than a 0 that means \ + nothing happened. + + Stdout stays empty on success, deliberately. The other page commands print a URL \ + because there is a page to point at; here there is not. The confirmation goes to \ + stderr, and --json prints the slug on stdout for a caller that needs to tie the \ + outcome back to the request. + """ + ) + + @OptionGroup var options: GlobalOptions + + @Argument(help: ArgumentHelp("The page to delete, as its slug appears in the URL.", valueName: "slug")) + var slug: String + + func execute() async throws { + let credential = try options.credential() + // The route needs only `publish`, so this is the agent's own credential and not an + // operator's — deleting a page is part of publishing one, not an administrative act. + try await SteleClient(credential: credential).delete(slug: slug, using: credential) + if options.json { + // The slug echoed back rather than an empty object: the page is gone, so there is + // nothing left for a caller batching deletes to match this result against except + // the name it asked for. + Terminal.out(try Format.json(["slug": slug])) + return + } + // Nothing goes to stdout here, and the emptiness is the contract. Every other page + // command's stdout is a URL, which is why `url=$(stele publish page.html)` works — + // a confirmation line printed on this one would be captured by the same idiom and + // handed onward as if it were an address. So the only thing said on success is said on + // stderr, where `PageIO` already puts the deadline: legible to a human watching, absent + // from anything reading. + Terminal.error(options.style.dim("deleted \(slug)")) + } +} + struct SkillCommand: SteleCommand { static let configuration = CommandConfiguration( commandName: "skill", diff --git a/Sources/stele/Stele.swift b/Sources/stele/Stele.swift index 9b59354..4c33be4 100644 --- a/Sources/stele/Stele.swift +++ b/Sources/stele/Stele.swift @@ -31,6 +31,7 @@ struct Stele: AsyncParsableCommand { PublishCommand.self, UpdateCommand.self, AmendCommand.self, + DeleteCommand.self, SkillCommand.self, AdminCommand.self, ] diff --git a/Tests/SteleKitTests/SteleClientTests.swift b/Tests/SteleKitTests/SteleClientTests.swift index 37f9291..5a46732 100644 --- a/Tests/SteleKitTests/SteleClientTests.swift +++ b/Tests/SteleKitTests/SteleClientTests.swift @@ -293,6 +293,46 @@ struct SteleClientRequestTests { #expect(location.url == "https://stele.example.com/server-settled-on-this") } + /// A delete's request is mostly absences, and each one is a decision. No query: this route has + /// no parameters at all, and one the server does not know is silence rather than a `400`. No + /// body and no content type: the server reads neither, so a `Content-Type` here would be a + /// claim about bytes that are not being sent. + @Test("delete DELETEs the slug's own path and sends nothing with it") + func delete() async throws { + let transport = FakeTransport(status: 204, body: "") + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + try await client.delete(slug: "quiet-cedar-otter", using: credential) + + let request = try #require(await transport.last) + #expect(request.method == "DELETE") + #expect(request.url.absoluteString == "https://stele.example.com/pages/quiet-cedar-otter") + #expect(request.url.query == nil) + #expect(request.body == nil) + #expect(request.contentType == nil) + #expect(request.headerFields()["Content-Type"] == nil) + // Behind the bearer token like every other write on /pages — a delete is not a read. + #expect(request.headerFields()["Authorization"] == "Bearer stele_pat_test") + } + + /// The reason `delete` does not go through the generic `send` helper, pinned. Success on this + /// route is a `204` with no body — the server strips `Content-Length` too — and `send` decodes + /// the body unconditionally, so a delete routed through it would fail on every success with a + /// `malformedResponse` about zero bytes. Nothing downstream could tell that apart from a real + /// server problem, and by then the page is actually gone: the caller would be told the delete + /// went wrong about a delete that worked. + @Test("a 204 with an empty body is a success, not a decoding failure") + func deleteAcceptsAnEmptyBody() async throws { + let transport = FakeTransport(status: 204, body: "") + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + try await client.delete(slug: "quiet-cedar-otter", using: credential) + + #expect(await transport.requests.count == 1) + } + /// 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, @@ -351,6 +391,36 @@ struct SteleClientRequestTests { #expect(request.url.standardized.path.hasPrefix("/pages/")) } + /// The same rule on the verb that is already destructive, which is what makes a stray path + /// segment worth catching here twice over: dot segments are removed before the request leaves, + /// so `delete ../admin/clients/claude-code` raw is `DELETE /admin/clients/claude-code` — a + /// page command revoking a credential. Encoded, the whole thing stays one segment and comes + /// back as the server's `400` naming the rule the slug broke. + @Test( + "a deleted slug cannot climb out of /pages", + arguments: [ + ( + "../admin/clients/claude-code", + "https://stele.example.com/pages/..%2Fadmin%2Fclients%2Fclaude-code" + ), + ("..", "https://stele.example.com/pages/%2E%2E"), + (".", "https://stele.example.com/pages/%2E"), + ("a/b", "https://stele.example.com/pages/a%2Fb"), + ("quiet-cedar-otter", "https://stele.example.com/pages/quiet-cedar-otter"), + ] + ) + func deletedSlugStaysOneSegment(_ slug: String, _ expected: String) async throws { + let transport = FakeTransport(status: 204, body: "") + let credential = try testCredential() + let client = SteleClient(credential: credential, transport: transport) + + try await client.delete(slug: slug, using: credential) + + let request = try #require(await transport.last) + #expect(request.url.absoluteString == expected) + #expect(request.url.standardized.path.hasPrefix("/pages/")) + } + /// The same rule on the admin side, where the reachable routes are the destructive ones. @Test("a client name cannot climb out of /admin/clients") func clientNameStaysOneSegment() async throws { @@ -711,6 +781,23 @@ struct SteleErrorMappingTests { } } + /// The same round trip through `delete`, and it exists for `amendFailure`'s reason: the thing + /// worth asserting is the advice this *route* produces, since the mistake being guarded + /// against is the call site handing `perform` the wrong `Expectation` — which no amount of + /// testing `Expectation.delete` on its own would catch. + private func deleteFailure(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.delete(slug: "quiet-cedar-otter", 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( @@ -858,6 +945,43 @@ struct SteleErrorMappingTests { #expect(!error.description.contains("omit it and let the server generate one")) } + /// Why `Expectation.delete` exists rather than reusing `write`'s. That advice names + /// `stele update` and tells the reader to publish the page first — on a delete that is not the + /// wrong command so much as the reverse instruction, and an agent obeying it would recreate + /// the page it had just been asked to remove, ending the run with the page live and nothing + /// looking like a failure. The truth here is duller and needs no next command: the page is + /// already gone, expired or deleted earlier, and the server would rather say so than claim a + /// deletion it never performed. + @Test("a 404 from delete says the page is already gone rather than to publish it") + func deleteNotFoundAdvice() async throws { + let error = try #require( + await deleteFailure(status: 404, body: #"{"error":{"message":"No page there"}}"#) + as? SteleError + ) + #expect( + error + == .notFound( + detail: "No page there", advice: SteleError.Expectation.delete.notFoundAdvice + ) + ) + #expect(error.description.contains("nothing left to delete")) + #expect(!error.description.contains("stele update")) + #expect(!error.description.contains("stele publish")) + } + + /// The scope named in a `403` comes off the route's `Expectation`, and a delete is a write on + /// `/pages` like the rest — `publish`, not `admin`. Naming the wrong one sends an agent whose + /// credential is perfectly good hunting for an operator token it will never be given. + @Test("a 403 from delete names the publish scope") + func deleteForbiddenScope() async throws { + let error = try #require( + await deleteFailure(status: 403, body: #"{"error":{"message":"Missing scope"}}"#) + as? SteleError + ) + #expect(error == .forbidden(missing: .publish, detail: "Missing scope")) + #expect(error.description.contains("`publish` scope")) + } + /// 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")