From be132d483c422c5b920fe1e45cc9007c8b57b770 Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:12:22 -0400 Subject: [PATCH] feat: report the GitHub login a credential was signed in for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server records it now — a sign-in mints a credential and remembers whose it was — and this client dropped the field on the floor, so the one question the record exists to answer could not be asked from here. It is not derivable from the name, which is why it is a field. A credential is *addressed* by a lowercased name, because a URL path segment has no uppercase in its alphabet, and *attributed* to the spelling GitHub reports. The fold is the half that is already in `name`; this is the half that is not. Optional for `shared`'s reason and read the same way: a deployment predating sign-in omits the key, and nil means "this server does not say" rather than "nobody". The decode test covers the absent key as well as an explicit null, because the absent one is the case that would break `auth status` against every older server if it were ever made required — which is the state the deployment is actually in today. Shown only when there is one. A column of dashes on every deployment that has not adopted sign-in teaches an operator to stop reading the column, and `client` already carries the name. The JSON keeps the existing convention for an optional the server may not answer: absent, the way `expiresAt` is. Co-Authored-By: Claude Opus 5 --- README.md | 4 +- Sources/SteleKit/Models.swift | 19 +++++++++- Sources/stele/AdminCommands.swift | 40 +++++++++++++------- Sources/stele/AuthCommands.swift | 17 ++++++++- Tests/SteleKitTests/SteleClientTests.swift | 44 ++++++++++++++++++++++ 5 files changed, 107 insertions(+), 17 deletions(-) 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")