diff --git a/README.md b/README.md index c4cff8a..7e3cdbc 100644 --- a/README.md +++ b/README.md @@ -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 ] [--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 [--slug ] [--ttl ]` | agent | `POST /pages`. Prints the URL on stdout and the page's deadline on stderr. | | `stele attach [--slug ] [--ttl ] [--filename ]` | 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 `` — and the viewer's under the deadline on stderr. | @@ -74,7 +74,7 @@ beneath the deadline. `--json` names both and calls neither of them `url`. | `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. | +| `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 ` | operator | Stops a credential working, keeping its record. | Every command takes `--host` and `--json`. Styling is a presentation layer only; the core diff --git a/Sources/SteleKit/Models.swift b/Sources/SteleKit/Models.swift index db28f9b..2aa6316 100644 --- a/Sources/SteleKit/Models.swift +++ b/Sources/SteleKit/Models.swift @@ -83,6 +83,21 @@ 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], @@ -90,7 +105,8 @@ public struct ClientSummary: Codable, Sendable, Equatable { lastUsedAt: Date? = nil, expiresAt: Date? = nil, revokedAt: Date? = nil, - shared: Bool? = nil + shared: Bool? = nil, + githubLogin: String? = nil ) { self.name = name self.scopes = scopes @@ -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 } diff --git a/Sources/stele/AdminCommands.swift b/Sources/stele/AdminCommands.swift index 630ae45..4c54bd8 100644 --- a/Sources/stele/AdminCommands.swift +++ b/Sources/stele/AdminCommands.swift @@ -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))") } } } @@ -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) { @@ -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) } } diff --git a/Sources/stele/AuthCommands.swift b/Sources/stele/AuthCommands.swift index 74c8642..c6229b5 100644 --- a/Sources/stele/AuthCommands.swift +++ b/Sources/stele/AuthCommands.swift @@ -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)), @@ -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. @@ -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 diff --git a/Tests/SteleKitTests/SteleClientTests.swift b/Tests/SteleKitTests/SteleClientTests.swift index 4b6befd..3644f5e 100644 --- a/Tests/SteleKitTests/SteleClientTests.swift +++ b/Tests/SteleKitTests/SteleClientTests.swift @@ -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")