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
44 changes: 40 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ stored 0600 in ~/.config/stele/credentials.json

$ stele publish report.html
https://stele.example.com/quiet-cedar-otter
expires 2026-08-11 10:31

$ stele publish report.html --slug q3-report
$ stele publish report.html --slug q3-report --ttl never
https://stele.example.com/q3-report
kept until deleted

$ stele update q3-report report.html
https://stele.example.com/q3-report
kept until deleted

$ stele auth status
host https://stele.example.com
Expand All @@ -37,7 +40,9 @@ credential ~/.config/stele/credentials.json

Only the URL, the JSON and the skill document go to stdout. Prompts, warnings and errors go to
stderr, so `url=$(stele publish page.html)` captures a URL and `stele skill | less` pages a
document.
document. The deadline under the URL is on stderr for that reason, and it is printed at all
because the server's default is *ephemeral* — a page published with no `--ttl` is eventually
unpublished, and a caller handed only a URL would find that out when the link broke.

## Commands

Expand All @@ -46,8 +51,8 @@ document.
| `stele auth login [--host <url>]` | human, once | Prompts for the token on a TTY, verifies it against the server, writes the credential `0600`. |
| `stele auth status` | agent or human | Host, client name, scopes, expiry — never the token. |
| `stele auth logout` | human | Forgets the local credential. Does not revoke it. |
| `stele publish <file> [--slug <name>]` | agent | `POST /pages`. Prints the URL and nothing else. |
| `stele update <slug> <file>` | agent | `PUT /pages/:slug`. Never creates. |
| `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 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 All @@ -58,6 +63,37 @@ operations in `SteleKit` return plain data and print nothing, which is what make
rendering choice rather than a second code path — and JSON output is never styled, because it is
a machine contract.

## Page lifetimes

Pages are ephemeral by default. `--ttl` says otherwise:

| `--ttl` | Means |
| --- | --- |
| omitted | the server's default lifetime, which is a matter of days |
| `30`, `30d` | thirty days |
| `2w` | fourteen days |
| `never` | kept until you delete it |

Two asymmetries shape the rules here. The first is why the default is ephemeral at all: a page
that outlives its purpose fails quietly and forever, while one that expires too early fails
loudly to somebody who can republish it. The second is why nothing is ever rounded — a lifetime
finer than a day, like `12h`, is refused rather than quietly turned into `1d`, because the
failure mode of guessing is a link that dies on a schedule nobody typed.

The maximum is deliberately *not* enforced here. That bound belongs to the server's
`PageLifetime`, the same way the slug rules belong to its `Slug`; a copy in this repository
would be a second source of truth that drifts silently the day the server moves it, and the
`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.

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.

## Exit codes

The primary reader of this tool is an agent, and an agent branches on `$?` before it reads
Expand Down
26 changes: 23 additions & 3 deletions Sources/SteleKit/Credentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,15 @@ public struct SteleHost: Sendable, Hashable, Comparable, CustomStringConvertible
self.value = canonical
}

/// Builds a URL under this host. `path` is expected to start with `/`.
/// Builds a URL under this host. `path` is expected to start with `/` and to be already
/// percent-encoded — `SteleClient.Path` is the only thing that builds one, and it encodes
/// the parts that come from a slug or a credential name itself. Assigning to `path` here
/// instead would be the bug it is guarding against: that setter treats `/` and `.` as the
/// path characters they are, so an encoded segment would have its `%` escaped back into
/// `%25` and a slug of `../admin/clients` would sail through as three real segments.
public func url(path: String, query: [URLQueryItem] = []) -> URL? {
guard var components = URLComponents(string: value) else { return nil }
components.path = path
components.percentEncodedPath = path
components.queryItems = query.isEmpty ? nil : query
return components.url
}
Expand Down Expand Up @@ -243,10 +248,25 @@ public struct Credentials: Sendable, Equatable {
/// hand-edited file — which is a state worth handling, since the file is documented and
/// people edit it.
public mutating func set(_ credential: Credential, makeDefault: Bool = true) {
// Which host was already answering bare commands, read *before* the insert. It is not
// simply `defaultHost`: one stored host resolves with no marker at all, and `encode`
// leaves the marker out of a one-host file for that reason — so `defaultHost` is
// routinely nil while a host is plainly the incumbent. Treating that nil as "no default
// yet" is how declining the default used to hand it to the new host, sending the next
// bare `stele publish` to the deployment that asked not to be preferred.
let incumbent = defaultHost ?? (entries.count == 1 ? entries.keys.first : nil)
entries[credential.host] = Entry(
clientName: credential.clientName, token: credential.token
)
if makeDefault || defaultHost == nil { defaultHost = credential.host }
if makeDefault {
defaultHost = credential.host
} else {
// The incumbent keeps it. With nothing to keep it — a fresh file, or a hand-edited
// one holding several hosts and no marker — the new host takes it only when it is
// the only host stored. Otherwise the file stays ambiguous and `resolve` says so,
// which is the honest answer: nothing here knows which of the others was meant.
defaultHost = incumbent ?? (entries.count == 1 ? credential.host : nil)
}
}

/// Forgets a host's credential. Returns false if there was nothing stored for it, so
Expand Down
132 changes: 132 additions & 0 deletions Sources/SteleKit/Inputs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,135 @@ public enum ExpiryDuration {
return TimeInterval(total)
}
}

/// How long a published page lives, as the `?ttl=` value `POST /pages` wants.
///
/// 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.
///
/// 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
/// source of truth that drifts silently the first time the server moves it. An over-long
/// lifetime comes back as a `400` naming the real limit. What is checked here is only what the
/// server cannot check for us: that the caller wrote something that means a number of days at
/// all, and that turning it into one does not overflow on the way.
public enum PageTTL: Sendable, Equatable {
case days(Int)
case never

/// The query parameter, and the spelling that opts out of expiry. Both are the server's
/// words, and both are exact-match contracts with another repository — the same class of
/// constant as `SteleClient.Path` and `expiresIn`, and the same failure if they drift: a
/// `ttl=forever` would be a `400`, but a parameter named anything but `ttl` would be
/// *ignored*, and a page the caller asked to keep forever would quietly die in a week.
public static let queryParameter = "ttl"
public static let neverKeyword = "never"

/// Days and weeks, which are the units that survive the trip.
///
/// Nothing shorter is offered, and that is the whole reason this is not `ExpiryDuration`.
/// The server stores a page's deadline to the day, so `12h` could only be honoured by
/// rounding it to something the caller did not type — and a lifetime silently rounded is
/// the failure both ends of this contract are built to refuse.
static let units: [(suffix: String, days: Int, name: String)] = [
("d", 1, "days"),
("w", 7, "weeks"),
]

public enum ParseError: Error, Equatable, CustomStringConvertible {
case malformed(String)
case notPositive(String)
/// A unit this server cannot store a page to: hours, minutes, seconds. Its own case
/// because the caller wrote a perfectly good duration and the answer is not "write a
/// duration" but "days are the resolution you get".
case tooFine(String, unit: String)
/// A number of days too large to be turned into one at all. Not a policy limit — the
/// server's is enforced by the server — but the point past which the arithmetic here
/// stops being arithmetic.
case unreachable(String)

public var description: String {
switch self {
case .malformed(let raw):
return """
'\(raw)' is not a page lifetime. Write a whole number of days — 30, or 30d — \
or '\(PageTTL.neverKeyword)' for a page that is kept until you delete it.
"""
case .notPositive(let raw):
return """
'\(raw)' is not a lifetime. A page that expires now or in the past would be \
published dead; use '\(PageTTL.neverKeyword)' for one that is kept.
"""
case .tooFine(let raw, let unit):
return """
'\(raw)' is \(unit), and a page's lifetime is measured in whole days — \
honouring it would mean rounding to a lifetime you did not ask for. Write \
the number of days instead, or '\(PageTTL.neverKeyword)' to keep the page.
"""
case .unreachable(let raw):
return """
'\(raw)' is too many days to be a lifetime. Use \
'\(PageTTL.neverKeyword)' for a page that should never expire.
"""
}
}
}

/// `"30"` and `"30d"` → `.days(30)`; `"2w"` → `.days(14)`; `"never"` → `.never`.
///
/// A bare number is accepted here where `--expires-in` refuses one, and the difference is
/// not an inconsistency. That flag's underlying unit is *seconds* and its range runs from
/// seconds to weeks, so `90` genuinely has two readings and guessing between them mints a
/// credential that dies in a minute and a half. A page's lifetime has one unit, the server's
/// own document teaches `?ttl=30`, and refusing the spelling an agent just read there would
/// be a second grammar to no purpose.
public static func parse(_ raw: String) throws(ParseError) -> PageTTL {
let text = raw.trimmingCharacters(in: .whitespaces).lowercased()
guard !text.isEmpty else { throw .malformed(raw) }
if text == neverKeyword { return .never }

// A trailing unit is optional; its absence means days. Anything that is not a digit run
// has to be named as a unit before it can be refused as one, so hours and friends are
// matched here rather than falling through to "that is not a lifetime".
let (digits, multiplier): (Substring, Int)
if let last = text.last, last.isASCII, !last.isNumber {
if let unit = units.first(where: { $0.suffix == String(last) }) {
(digits, multiplier) = (text.dropLast(), unit.days)
} else if let finer = ExpiryDuration.units.first(where: { $0.suffix == String(last) }) {
throw .tooFine(raw, unit: finer.name)
} else {
throw .malformed(raw)
}
} else {
(digits, multiplier) = (text[...], 1)
}

// ASCII digits specifically, matching `ExpiryDuration`: `isNumber` admits other scripts'
// digits, which `Int` then declines to parse, and the check below reads a failed parse
// as a number too large rather than a number in the wrong script.
guard !digits.isEmpty, digits.allSatisfy({ $0.isASCII && $0.isNumber }) else {
// A leading `-` lands here. It is not a malformed lifetime, it is a negative one,
// and saying so sends the caller to the right correction.
if digits.first == "-", digits.dropFirst().allSatisfy({ $0.isASCII && $0.isNumber }) {
throw .notPositive(raw)
}
throw .malformed(raw)
}
guard let count = Int(digits) else { throw .unreachable(raw) }
guard count > 0 else { throw .notPositive(raw) }
let (days, overflowed) = count.multipliedReportingOverflow(by: multiplier)
guard !overflowed else { throw .unreachable(raw) }
return .days(days)
}

/// What travels as `?ttl=`.
public var queryValue: String {
switch self {
case .days(let count): return String(count)
case .never: return PageTTL.neverKeyword
}
}
}
38 changes: 37 additions & 1 deletion Sources/SteleKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,45 @@ public struct PageLocation: Codable, Sendable, Equatable {
public let slug: String
public let url: String

public init(slug: String, url: String) {
/// 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.
///
/// 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
/// that server nothing expires either. Both are honestly "no deadline".
public let expiresAt: Date?

private enum CodingKeys: String, CodingKey {
case slug, url
/// The server's spelling. One key on the wire in both directions, so the `--json` this
/// tool prints is the shape it reads.
case expiresAt = "expires"
}

public init(slug: String, url: String, expiresAt: Date? = nil) {
self.slug = slug
self.url = url
self.expiresAt = expiresAt
}

/// Hand-written for the one line in the middle, and for the reason the server hand-writes
/// its own encoder: the synthesised version calls `encodeIfPresent`, which *drops the key*
/// when there is no deadline — so `stele publish --json` on a permanent page would emit a
/// blob with no `expires` in it, and whatever read that blob could not tell "this page is
/// kept" from "this tool has nothing to say about lifetimes". An explicit null says which.
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(slug, forKey: .slug)
try container.encode(url, forKey: .url)
if let expiresAt {
try container.encode(expiresAt, forKey: .expiresAt)
} else {
try container.encodeNil(forKey: .expiresAt)
}
}
}

Expand Down
Loading