Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ unpublished, and a caller handed only a URL would find that out when the link br
| `stele publish <file> [--slug <name>] [--ttl <days>]` | agent | `POST /pages`. Prints the URL on stdout and the page's deadline on stderr. |
| `stele update <slug> <file>` | agent | `PUT /pages/:slug`. Never creates, and never changes the deadline. |
| `stele amend <slug> [--slug <name>] [--ttl <days>]` | 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 <slug>` | 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 <name>` | operator | Mints a credential and prints the token **once**. `--scopes`, `--expires-in 90d`. |
| `stele admin clients list` | operator | Names, scopes, last use, revocation state. |
Expand Down
36 changes: 36 additions & 0 deletions Sources/SteleKit/SteleClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions Sources/SteleKit/SteleError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.",
Expand Down
5 changes: 3 additions & 2 deletions Sources/stele/Exit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 52 additions & 1 deletion Sources/stele/PageCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions Sources/stele/Stele.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct Stele: AsyncParsableCommand {
PublishCommand.self,
UpdateCommand.self,
AmendCommand.self,
DeleteCommand.self,
SkillCommand.self,
AdminCommand.self,
]
Expand Down
124 changes: 124 additions & 0 deletions Tests/SteleKitTests/SteleClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down