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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ beneath the deadline. `--json` names both and calls neither of them `url`.
| Command | Who runs it | Does |
| --- | --- | --- |
| `stele auth login [--host <url>] [--admin]` | human, once | Prompts for the token on a TTY, verifies it, writes the credential `0600`. An operator token is *spent*, not stored — see below. |
| `stele auth status` | agent or human | Host, client name, scopes, expiry — never the token. |
| `stele auth status` | agent or human | Host, client name, scopes, expiry, and the GitHub login behind the credential when there is one — never the token. |
| `stele auth logout` | human | Forgets the local credential. Does not revoke it. |
| `stele publish <file> [--slug <name>] [--ttl <days>]` | agent | `POST /pages`. Prints the URL on stdout and the page's deadline on stderr. |
| `stele attach <file> [--slug <name>] [--ttl <days>] [--filename <name>]` | agent | `POST /pages` with a binary type. Publishes an image, video or PDF. Prints the URL of the *bytes* on stdout — the one that goes in an `<img src>` — and the viewer's under the deadline on stderr. |
Expand All @@ -74,7 +74,7 @@ beneath the deadline. `--json` names both and calls neither of them `url`.
| `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. |
| `stele admin clients list` | operator | Names, scopes, last use, revocation state, and the GitHub login a credential was signed in for when there is one. |
| `stele admin clients revoke <name>` | operator | Stops a credential working, keeping its record. |

Every command takes `--host` and `--json`. Styling is a presentation layer only; the core
Expand Down
19 changes: 18 additions & 1 deletion Sources/SteleKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,30 @@ public struct ClientSummary: Codable, Sendable, Equatable {
/// falls back to the name only when it lands, and prefers this answer whenever there is one.
public let shared: Bool?

/// The GitHub login this credential was minted for, when it was minted by signing in rather
/// than by an operator running `admin clients create`.
///
/// Optional for `shared`'s reason and read the same way: a deployment predating GitHub
/// sign-in omits the key, and nil means "this server does not say" rather than "nobody".
/// The two nils are worth keeping apart in the renderer, but not here — this type reports
/// what the server said, and the absence is what it said.
///
/// Recorded in the canonical spelling GitHub reports, which need not be `name`: the
/// credential is *addressed* by a lowercased name, because a URL path segment has no
/// uppercase in its alphabet, and attributed to the spelling its owner would recognise.
/// That difference is the only part of the identity the name does not already carry, and it
/// is why this is a field rather than something derived from `name`.
public let githubLogin: String?

public init(
name: String,
scopes: [String],
createdAt: Date? = nil,
lastUsedAt: Date? = nil,
expiresAt: Date? = nil,
revokedAt: Date? = nil,
shared: Bool? = nil
shared: Bool? = nil,
githubLogin: String? = nil
) {
self.name = name
self.scopes = scopes
Expand All @@ -99,6 +115,7 @@ public struct ClientSummary: Codable, Sendable, Equatable {
self.expiresAt = expiresAt
self.revokedAt = revokedAt
self.shared = shared
self.githubLogin = githubLogin
}

public var isRevoked: Bool { revokedAt != nil }
Expand Down
40 changes: 27 additions & 13 deletions Sources/stele/AdminCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,22 +202,30 @@ struct ListClientsCommand: SteleCommand {
let scopeWidth = max(
6, min(24, clients.map { $0.scopes.joined(separator: ",").count }.max() ?? 6)
)
Terminal.out(
style.dim(
[
Format.pad("NAME", nameWidth), Format.pad("SCOPES", scopeWidth),
Format.pad("CREATED", 16), Format.pad("LAST USED", 16), "STATE",
].joined(separator: " ")
)
)
for client in clients {
let row = [
Format.pad(client.name, nameWidth),
// Shown only when somebody signed in, which is not the same as always. A deployment
// that has not adopted GitHub sign-in — or one predating it, which answers no such key
// at all — would otherwise get a column of dashes in every row, and a column that is
// empty on every deployment it appears on teaches an operator to stop reading it.
let logins = clients.map { $0.githubLogin ?? "—" }
let loginWidth = clients.contains { $0.githubLogin != nil }
? max(6, min(24, logins.map(\.count).max() ?? 6))
: nil
var header = [Format.pad("NAME", nameWidth)]
if let loginWidth { header.append(Format.pad("GITHUB", loginWidth)) }
header += [
Format.pad("SCOPES", scopeWidth),
Format.pad("CREATED", 16), Format.pad("LAST USED", 16), "STATE",
]
Terminal.out(style.dim(header.joined(separator: " ")))
for (client, login) in zip(clients, logins) {
var cells = [Format.pad(client.name, nameWidth)]
if let loginWidth { cells.append(Format.pad(login, loginWidth)) }
cells += [
Format.pad(client.scopes.joined(separator: ","), scopeWidth),
Format.pad(Format.moment(client.createdAt), 16),
Format.pad(Format.moment(client.lastUsedAt), 16),
].joined(separator: " ")
Terminal.out("\(row) \(style.state(client))")
]
Terminal.out("\(cells.joined(separator: " ")) \(style.state(client))")
}
}
}
Expand All @@ -231,6 +239,11 @@ struct ClientJSON: Encodable {
let lastUsedAt: Date?
let expiresAt: Date?
let revokedAt: Date?
/// Who signed in for it. Absent when nobody did, and absent in the same way `expiresAt` is
/// — the synthesised encoder drops a nil optional — which is why the table's rule does not
/// have to be repeated here: an operator reading a column of dashes learns nothing, but a
/// reader of this asks for the key and gets nothing back, which is the same answer.
let githubLogin: String?
let state: String

init(_ summary: ClientSummary) {
Expand All @@ -240,6 +253,7 @@ struct ClientJSON: Encodable {
self.lastUsedAt = summary.lastUsedAt
self.expiresAt = summary.expiresAt
self.revokedAt = summary.revokedAt
self.githubLogin = summary.githubLogin
self.state = Format.state(summary)
}
}
Expand Down
17 changes: 16 additions & 1 deletion Sources/stele/AuthCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -421,9 +421,19 @@ struct StatusCommand: SteleCommand {
return
}
let style = options.style
let rows: [(String, String)] = [
var rows: [(String, String)] = [
("host", style.accent(host.value)),
("client", style.bold(summary.name)),
]
// Only when there is one. A deployment that has not adopted GitHub sign-in — or one
// predating it, which does not answer the key at all — would otherwise print a row that
// says nothing on every machine it is run on, and `client` already carries the name.
// Worth showing when it exists because it need not match: the credential is addressed
// by a lowercased name and attributed to the spelling GitHub reports.
if let login = summary.githubLogin {
rows.append(("github", login))
}
rows += [
("scopes", summary.scopes.isEmpty ? "—" : summary.scopes.joined(separator: ", ")),
("expires", summary.expiresAt.map(Format.moment) ?? "never"),
("last used", Format.moment(summary.lastUsedAt)),
Expand Down Expand Up @@ -474,6 +484,10 @@ struct AuthStatus: Encodable {
let lastUsedAt: Date?
let expiresAt: Date?
let revokedAt: Date?
/// The GitHub login this credential was minted for, when one was. Absent otherwise, the way
/// `expiresAt` is — this is a field the server either reports or does not, and unlike
/// `minted` below there is no answer this side could invent for it.
let githubLogin: String?
let state: String
let credentialFile: String
/// False when the server could not be reached and these are the file's cached values.
Expand All @@ -500,6 +514,7 @@ struct AuthStatus: Encodable {
self.lastUsedAt = summary.lastUsedAt
self.expiresAt = summary.expiresAt
self.revokedAt = summary.revokedAt
self.githubLogin = summary.githubLogin
self.state = verified ? Format.state(summary) : "unverified"
self.credentialFile = path
self.verified = verified
Expand Down
44 changes: 44 additions & 0 deletions Tests/SteleKitTests/SteleClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,50 @@ struct SteleClientRequestTests {
#expect(request.headerFields()["Authorization"] != nil)
}

/// The spelling has to be the server's, and it is not derivable from `name`: a credential is
/// addressed by a lowercased name — a URL path segment has no uppercase in its alphabet —
/// and attributed to the spelling GitHub reports. A key this client misspells decodes to nil
/// and reads as "nobody signed in for this", which is a wrong answer rather than a missing
/// one.
@Test("whoami reports the GitHub login a credential was minted for")
func whoamiCarriesTheGitHubLogin() async throws {
let transport = FakeTransport(
status: 200,
body: #"{"name":"projedi1234","scopes":["publish"],"githubLogin":"ProJedi1234"}"#
)
let credential = try testCredential()
let client = SteleClient(credential: credential, transport: transport)

let summary = try await client.verifyCredential(credential)

#expect(summary.name == "projedi1234")
#expect(summary.githubLogin == "ProJedi1234")
}

/// Nil for the two deployments that answer nothing: one predating GitHub sign-in, which
/// omits the key entirely, and one that has it but where this credential was minted by an
/// operator instead. Both mean "no login to show", which is why they are not told apart —
/// and the absent key must not be a decoding failure, or `auth status` breaks against every
/// older server.
@Test("a credential with no GitHub login behind it decodes either way")
func githubLoginIsOptional() async throws {
let transport = FakeTransport(
status: 200,
body: """
{"clients":[{"name":"argos","scopes":["publish"]},
{"name":"claude-code","scopes":["publish"],"githubLogin":null}]}
"""
)
let credential = try testCredential()
let client = SteleClient(credential: credential, transport: transport)

let clients = try await client.listClients(using: credential)

#expect(clients.count == 2)
#expect(clients[0].githubLogin == nil)
#expect(clients[1].githubLogin == nil)
}

/// The content type is a parameter with a default, not a constant: `stele publish notes.md`
/// has to be able to say what it is publishing.
@Test("an explicit content type is what gets sent")
Expand Down