@gjermundgaraba/clankercreds 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ import { a as NeedsSignIn, d as ProviderUnavailable, i as Name, l as scopes, n as Conflict, o as NotFound, r as Host, s as UpstreamUnavailable, u as CurrentPrincipal } from "./shared-DBK2cCkw.mjs";
2
+ import { Config, Console, Effect, Option, Redacted, Schema } from "effect";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
6
+ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
7
+ import { execFile } from "node:child_process";
8
+ import { text } from "node:stream/consumers";
9
+ import { CliError, Command, Flag } from "effect/cli";
10
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
11
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
12
+ //#region ../../packages/api/src/admin.ts
13
+ /** Written by the service, always ISO. What an operator enters is decoded as a date instead. */
14
+ const Timestamp = Schema.String;
15
+ /** The kinds an operator stores a value for. A ChatGPT login only ever comes from a sign-in. */
16
+ const SecretKind = Schema.Literals(["gh", "claude"]);
17
+ /** A stored secret's state. The value itself is never read back out. */
18
+ const SecretStatus = Schema.Struct({
19
+ name: Name,
20
+ kind: SecretKind,
21
+ /** The host a `gh` token is for; null for every other kind. */
22
+ host: Schema.NullOr(Schema.String),
23
+ expiresAt: Schema.NullOr(Timestamp),
24
+ createdAt: Timestamp,
25
+ updatedAt: Timestamp
26
+ });
27
+ /** A ChatGPT login's state. It exists from the first completed sign-in on. */
28
+ const ChatgptStatus = Schema.Struct({
29
+ name: Name,
30
+ kind: Schema.Literal("chatgpt"),
31
+ needsSignIn: Schema.Boolean,
32
+ email: Schema.NullOr(Schema.String),
33
+ accountId: Schema.String,
34
+ accessExpiresAt: Timestamp,
35
+ lastRefreshAt: Timestamp,
36
+ lastError: Schema.NullOr(Schema.String),
37
+ createdAt: Timestamp,
38
+ updatedAt: Timestamp
39
+ });
40
+ const CredentialStatus = Schema.Union([SecretStatus, ChatgptStatus]);
41
+ /** The clankerauth id of a machine key. */
42
+ const KeyId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
43
+ /**
44
+ * A machine key and the credentials it may read. A machine names which of them it syncs; a
45
+ * replacement key is granted the same ones and overlaps the old key until that is removed.
46
+ * A key may be granted several credentials of one kind: several accounts of a provider.
47
+ */
48
+ const KeyInfo = Schema.Struct({
49
+ id: KeyId,
50
+ credentials: Schema.Array(Name)
51
+ });
52
+ /**
53
+ * A pending Claude sign-in: approve at `authorizeUrl`, then hand the code the callback page
54
+ * shows to `completeClaudeSignIn`. The code is useless without the verifier the service holds.
55
+ */
56
+ const ClaudeSignIn = Schema.Struct({
57
+ signInId: Schema.String,
58
+ credential: Name,
59
+ authorizeUrl: Schema.String,
60
+ expiresAt: Timestamp
61
+ });
62
+ /**
63
+ * A Claude sign-in's code was not taken: it is not this sign-in's, or Anthropic refused it.
64
+ * The sign-in stays open until it expires, for the code again or a new one from its
65
+ * `authorizeUrl`. An approval that left out a scope the token needs ends it: start again.
66
+ */
67
+ var SignInRefused = class extends Schema.TaggedError()("SignInRefused", { message: Schema.String }, { httpApiStatus: 422 }) {};
68
+ const SignInState = Schema.Struct({
69
+ signInId: Schema.String,
70
+ credential: Name,
71
+ status: Schema.Literals([
72
+ "pending",
73
+ "complete",
74
+ "failed",
75
+ "expired"
76
+ ]),
77
+ userCode: Schema.String,
78
+ verificationUrl: Schema.String,
79
+ expiresAt: Timestamp,
80
+ message: Schema.optionalKey(Schema.String)
81
+ });
82
+ /** Credentials by name: rows outlive what they name, and stay findable. */
83
+ const AuditEntry = Schema.Struct({
84
+ id: Schema.Int,
85
+ at: Timestamp,
86
+ actorKind: Schema.Literals([
87
+ "machine",
88
+ "admin",
89
+ "system"
90
+ ]),
91
+ keyId: Schema.NullOr(Schema.String),
92
+ clientId: Schema.NullOr(Schema.String),
93
+ action: Schema.String,
94
+ credential: Schema.NullOr(Schema.String),
95
+ remoteAddr: Schema.NullOr(Schema.String),
96
+ detail: Schema.Record(Schema.String, Schema.Json)
97
+ });
98
+ /** Every filter is optional; the page size defaults on the service. */
99
+ const AuditQuery = Schema.Struct({
100
+ limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({
101
+ minimum: 1,
102
+ maximum: 500
103
+ }))),
104
+ before: Schema.optionalKey(Schema.Int),
105
+ action: Schema.optionalKey(Schema.String),
106
+ credential: Schema.optionalKey(Name),
107
+ keyId: Schema.optionalKey(Schema.String),
108
+ clientId: Schema.optionalKey(Schema.String)
109
+ });
110
+ /**
111
+ * A read whose user code signs a ChatGPT account in: exported so the server's authorization
112
+ * rule can name it.
113
+ */
114
+ const GetChatgptSignIn = Action.make("getChatgptSignIn", {
115
+ readOnly: true,
116
+ caller: CurrentPrincipal,
117
+ description: "Read a sign-in. The service holds one in memory only, until the next sign-in starts after it expired; one it no longer holds is not found.",
118
+ input: Schema.Struct({ signInId: Schema.String }),
119
+ success: SignInState,
120
+ error: NotFound
121
+ });
122
+ /** Everything an operator does; which scope each needs is the server's authorization rule. */
123
+ const Admin = [
124
+ Action.make("listCredentials", {
125
+ readOnly: true,
126
+ caller: CurrentPrincipal,
127
+ description: "Every credential with its state: expiry, last refresh, last error and whether a ChatGPT login needs a new sign-in. Values are never returned.",
128
+ success: Schema.Struct({ credentials: Schema.Array(CredentialStatus) })
129
+ }),
130
+ Action.make("putCredential", {
131
+ readOnly: false,
132
+ caller: CurrentPrincipal,
133
+ description: "Store a secret under a name, or replace its value and expiry. A `gh` token is for `host`, github.com unless given. A credential's kind and host never change; remove it and put it again instead. Machines receive it on their next sync.",
134
+ input: Schema.Struct({
135
+ name: Name,
136
+ kind: SecretKind,
137
+ value: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(16384)),
138
+ host: Schema.optionalKey(Host),
139
+ /** Any date string JavaScript parses; a typo is refused, so alerting never loses it. Left out, it does not expire. */
140
+ expiresAt: Schema.optionalKey(Schema.DateFromString)
141
+ }).check(Schema.makeFilter(({ kind, host }) => kind === "gh" || host === void 0 ? void 0 : {
142
+ path: ["host"],
143
+ issue: "Only a gh credential has a host."
144
+ })),
145
+ success: SecretStatus,
146
+ error: Conflict
147
+ }),
148
+ Action.make("removeCredential", {
149
+ readOnly: false,
150
+ caller: CurrentPrincipal,
151
+ description: "Delete a credential. Refused while a key is still granted it.",
152
+ input: Schema.Struct({ name: Name }),
153
+ success: Schema.Void,
154
+ error: [NotFound, Conflict]
155
+ }),
156
+ Action.make("startChatgptSignIn", {
157
+ readOnly: false,
158
+ caller: CurrentPrincipal,
159
+ description: "Start a device-code sign-in for a ChatGPT credential; the credential is created, or its login replaced, when the sign-in completes. Approve the code at the returned URL, then poll getChatgptSignIn.",
160
+ input: Schema.Struct({ name: Name }),
161
+ success: SignInState,
162
+ error: [Conflict, UpstreamUnavailable]
163
+ }),
164
+ GetChatgptSignIn,
165
+ Action.make("refreshChatgptCredential", {
166
+ readOnly: false,
167
+ caller: CurrentPrincipal,
168
+ description: "Refresh one ChatGPT login now instead of waiting for its schedule.",
169
+ input: Schema.Struct({ name: Name }),
170
+ success: ChatgptStatus,
171
+ error: [
172
+ NotFound,
173
+ Conflict,
174
+ NeedsSignIn,
175
+ UpstreamUnavailable
176
+ ]
177
+ }),
178
+ Action.make("startClaudeSignIn", {
179
+ readOnly: false,
180
+ caller: CurrentPrincipal,
181
+ description: "Start a sign-in for a Claude credential: a token for both Claude Code and the usage endpoint, valid for a year. Approve at the returned authorizeUrl, then pass the code the callback page shows to completeClaudeSignIn within 15 minutes.",
182
+ input: Schema.Struct({ name: Name }),
183
+ success: ClaudeSignIn,
184
+ error: Conflict
185
+ }),
186
+ Action.make("completeClaudeSignIn", {
187
+ readOnly: false,
188
+ caller: CurrentPrincipal,
189
+ description: "Exchange the code from an approved Claude sign-in for its token, stored under the sign-in's name: created, or its value and expiry replaced. Machines receive it on their next sync.",
190
+ input: Schema.Struct({
191
+ signInId: Schema.String,
192
+ /** As the callback page shows it: `<code>#<state>`. */
193
+ code: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4096))
194
+ }),
195
+ success: SecretStatus,
196
+ error: [
197
+ NotFound,
198
+ Conflict,
199
+ SignInRefused,
200
+ UpstreamUnavailable
201
+ ]
202
+ }),
203
+ Action.make("listKeys", {
204
+ readOnly: true,
205
+ caller: CurrentPrincipal,
206
+ description: "Every machine key with the credentials it is granted.",
207
+ success: Schema.Struct({ keys: Schema.Array(KeyInfo) })
208
+ }),
209
+ Action.make("putKey", {
210
+ readOnly: false,
211
+ caller: CurrentPrincipal,
212
+ description: "Grant a machine key, by its clankerauth id, exactly these credentials, replacing what it had; none takes every grant. A machine syncs the ones its configuration names; several of one kind may be granted. Revoke the key itself in clankerauth.",
213
+ input: Schema.Struct({
214
+ id: KeyId,
215
+ credentials: Schema.Array(Name)
216
+ }),
217
+ success: KeyInfo,
218
+ error: NotFound
219
+ }),
220
+ Action.make("listAudit", {
221
+ readOnly: true,
222
+ caller: CurrentPrincipal,
223
+ description: "The audit log, newest first. Filter by action, credential, key or client id; page with `before`.",
224
+ input: AuditQuery,
225
+ success: Schema.Struct({ entries: Schema.Array(AuditEntry) })
226
+ })
227
+ ];
228
+ /**
229
+ * An operator's bearer token on the private port: an access token or API key of the admin
230
+ * resource, verified by its own provider.
231
+ */
232
+ const AdminLogin = Authentication.make("clankercreds.admin", CurrentPrincipal, { error: ProviderUnavailable });
233
+ const AdminHttp = ActionHttp.make(Admin, {
234
+ prefix: "/v1",
235
+ authentication: AdminLogin
236
+ });
237
+ //#endregion
238
+ //#region package.json
239
+ var version = "0.3.0";
240
+ //#endregion
241
+ //#region src/admin.ts
242
+ /** A refusal of this command's own, printed as its message on stderr, exiting 1. */
243
+ const refused = (message) => new CliError.UserError({
244
+ cause: message,
245
+ userMessage: message
246
+ });
247
+ /** What an operator signs in to clankerauth for: every admin action. */
248
+ const operatorScopes = `${scopes.read} ${scopes.admin}`;
249
+ /**
250
+ * The admin port, and the resource its tokens are for: the port's origin root, unless the
251
+ * service names its resource otherwise.
252
+ */
253
+ const admin = Command.make("admin").pipe(Command.withSharedFlags({
254
+ url: Flag.String("url").pipe(Flag.withDescription("The admin port's origin; CLANKERCREDS_ADMIN_URL when left out"), Flag.withFallbackConfig(Config.String("CLANKERCREDS_ADMIN_URL"))),
255
+ resource: Flag.String("resource").pipe(Flag.withDescription("The service's clankerauth resource, as CLANKERCREDS_RESOURCE names it on the server; the admin origin's root when left out"), Flag.withFallbackConfig(Config.String("CLANKERCREDS_RESOURCE")), Flag.optional)
256
+ }), Command.withDescription(`Administer clankercreds over its admin port. A command signs in with \`clankerauth token\`, so sign in once with \`clankerauth login <issuer> --resource <resource> --scope "${operatorScopes}"\`; CLANKERCREDS_ADMIN_TOKEN, an API key or access token, is used instead when set. Results are JSON on stdout.`));
257
+ /** Run `clankerauth token` for `resource`: the CLI that holds the operator's sign-in. */
258
+ const clankerauthToken = (resource) => Effect.callback((resume) => {
259
+ execFile("clankerauth", [
260
+ "token",
261
+ "--resource",
262
+ resource
263
+ ], (error, stdout, stderr) => {
264
+ if (error === null) {
265
+ resume(Effect.succeed(stdout.trim()));
266
+ return;
267
+ }
268
+ resume(Effect.fail(refused("code" in error && error.code === "ENOENT" ? `clankerauth is not installed. Install it with \`npm install --global @gjermundgaraba/clankerauth\` and sign in with \`clankerauth login <issuer> --resource ${resource} --scope "${operatorScopes}"\`, or set CLANKERCREDS_ADMIN_TOKEN.` : `clankerauth token: ${stderr.trim() || error.message}`)));
269
+ });
270
+ });
271
+ /**
272
+ * The client every admin command calls through: the admin origin and a bearer token,
273
+ * CLANKERCREDS_ADMIN_TOKEN or a current one from `clankerauth token`.
274
+ */
275
+ const operator = Effect.gen(function* () {
276
+ const { url, resource } = yield* admin;
277
+ const origin = yield* Effect.try({
278
+ try: () => new URL(url).origin,
279
+ catch: () => refused(`The admin URL must be a URL, such as https://clankercreds.example: ${url}`)
280
+ });
281
+ const given = yield* Config.option(Config.Redacted("CLANKERCREDS_ADMIN_TOKEN")).pipe(Effect.mapError(() => refused("CLANKERCREDS_ADMIN_TOKEN cannot be read")));
282
+ const token = yield* Option.match(given, {
283
+ onSome: (secret) => Effect.succeed(Redacted.value(secret)),
284
+ onNone: () => clankerauthToken(Option.getOrElse(resource, () => `${origin}/`))
285
+ });
286
+ return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(origin), HttpClientRequest.bearerToken(token)));
287
+ });
288
+ /**
289
+ * The secret's value, from stdin: a flag would leave it in the process list and the shell's
290
+ * history. Line endings a pipe adds, such as `op read`'s, are not part of it.
291
+ */
292
+ const valueFromStdin = Effect.gen(function* () {
293
+ if (process.stdin.isTTY) return yield* refused("Pipe the value in, such as `op read 'op://…' | clankercreds admin put-credential …`; it is never taken from a flag or the terminal.");
294
+ const value = (yield* Effect.promise(() => text(process.stdin))).replace(/[\r\n]+$/, "");
295
+ if (value === "") return yield* refused("The value on stdin is empty.");
296
+ return value;
297
+ });
298
+ const json = (value) => Console.log(JSON.stringify(value, null, 2));
299
+ /** `putCredential`, its value read from stdin and every other field a flag. */
300
+ const putCredential = Command.make("put-credential", {
301
+ name: Flag.String("name").pipe(Flag.withDescription("The credential's name")),
302
+ kind: Flag.Literals("kind", SecretKind.literals).pipe(Flag.withDescription("The tool the secret is for")),
303
+ host: Flag.String("host").pipe(Flag.withDescription("A gh token's host; github.com when left out"), Flag.optional),
304
+ expiresAt: Flag.String("expires-at").pipe(Flag.withDescription("When the secret expires, any date string; it never does when left out"), Flag.optional)
305
+ }, ({ name, kind, host, expiresAt }) => Effect.gen(function* () {
306
+ const value = yield* valueFromStdin;
307
+ const expiry = yield* Option.match(expiresAt, {
308
+ onNone: () => Effect.succeedNone,
309
+ onSome: (given) => {
310
+ const date = new Date(given);
311
+ return Number.isNaN(date.getTime()) ? Effect.fail(refused(`--expires-at is not a date: ${given}`)) : Effect.succeedSome(date);
312
+ }
313
+ });
314
+ const status = yield* (yield* ActionHttp.client(AdminHttp)).putCredential({
315
+ name,
316
+ kind,
317
+ value,
318
+ ...Option.match(host, {
319
+ onNone: () => ({}),
320
+ onSome: (named) => ({ host: named })
321
+ }),
322
+ ...Option.match(expiry, {
323
+ onNone: () => ({}),
324
+ onSome: (date) => ({ expiresAt: date })
325
+ })
326
+ }).pipe(Effect.mapError((error) => refused(`${error._tag}: ${error.message}`)));
327
+ yield* json(status);
328
+ })).pipe(Command.withDescription("Store a secret under a name, or replace its value and expiry. The value is read from stdin."), Command.provideEffect(HttpClient.HttpClient, operator));
329
+ /** Every other admin action, as a command derived from its contract. */
330
+ const derived = Admin.filter((action) => action.name !== "putCredential").map((action) => ActionCli.remoteCommand(AdminHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, operator)));
331
+ const cli = Command.make("clankercreds").pipe(Command.withSubcommands([admin.pipe(Command.withSubcommands([putCredential, ...derived]))]));
332
+ /** Run `clankercreds admin …`; `args` are the process's, after the script. */
333
+ const run = (args) => Command.runWith(cli, { version })(args).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeServices.layer), ActionCli.logToStderr, NodeRuntime.runMain);
334
+ //#endregion
335
+ export { run };
package/dist/main.mjs CHANGED
@@ -1,159 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ import { a as NeedsSignIn, c as claimsOf, d as ProviderUnavailable, i as Name, o as NotFound, s as UpstreamUnavailable, t as ClientId, u as CurrentPrincipal } from "./shared-DBK2cCkw.mjs";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
- import { randomBytes } from "node:crypto";
6
- import { Context, Data, Effect, Option, Predicate, Schema } from "effect";
7
- import { HttpClient, HttpClientRequest } from "effect/http";
5
+ import { Effect, Option, Predicate, Schema } from "effect";
8
6
  import * as Action from "@gjermundgaraba/effect-actions/Action";
9
7
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
10
8
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
9
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { randomBytes } from "node:crypto";
11
+ import { HttpClient, HttpClientRequest } from "effect/http";
11
12
  import { YAMLMap, isMap, parseDocument } from "yaml";
12
- //#region src/files.ts
13
- /** Secrets are readable by their owner only, in directories only the owner can enter. */
14
- const fileMode = 384;
15
- const directoryMode = 448;
16
- const isMissing = (error) => Predicate.hasProperty(error, "code") && error.code === "ENOENT";
17
- /** A missing file is `undefined`. A file that exists but cannot be read is not ours to replace. */
18
- const readText = (path) => {
19
- try {
20
- return readFileSync(path, "utf8");
21
- } catch (error) {
22
- if (isMissing(error)) return void 0;
23
- throw error;
24
- }
25
- };
26
- /**
27
- * Write-then-rename, so a tool reading the file sees the old content or the new and never
28
- * half of one. The directory is created private; an existing one keeps its mode.
29
- */
30
- const writeAtomic = (path, content) => {
31
- mkdirSync(dirname(path), {
32
- recursive: true,
33
- mode: directoryMode
34
- });
35
- const partial = `${path}.${randomBytes(6).toString("hex")}.tmp`;
36
- try {
37
- writeFileSync(partial, content, { mode: fileMode });
38
- chmodSync(partial, fileMode);
39
- renameSync(partial, path);
40
- } catch (error) {
41
- rmSync(partial, { force: true });
42
- throw error;
43
- }
44
- };
45
- /** True when there was a file to remove. */
46
- const remove = (path) => {
47
- try {
48
- unlinkSync(path);
49
- return true;
50
- } catch (error) {
51
- if (isMissing(error)) return false;
52
- throw error;
53
- }
54
- };
55
- const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
56
- /** The file as an object; a missing file is empty. Anything else is not ours to overwrite. */
57
- const readJsonObject = (path) => {
58
- const text = readText(path);
59
- if (text === void 0 || text.trim() === "") return {};
60
- const parsed = JSON.parse(text);
61
- if (!isJsonObject(parsed)) throw new Error(`${path} does not hold a JSON object`);
62
- return parsed;
63
- };
64
- /**
65
- * A nested object a placement merges into; a missing one is empty. Anything else under the
66
- * key is someone else's, however malformed, and not ours to overwrite.
67
- */
68
- const objectAt = (parent, key, path) => {
69
- const value = parent[key];
70
- if (value === void 0) return {};
71
- if (!isJsonObject(value)) throw new Error(`${key} in ${path} is not a JSON object; refusing to replace it`);
72
- return value;
73
- };
74
- const writeJson = (path, value) => writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
75
- //#endregion
76
- //#region src/config.ts
77
- /** Where the recipe puts `config.json` and `key`. The variable exists for tests and odd images. */
78
- const configDirectory = () => process.env["CLANKERCREDS_CONFIG_DIR"] ?? "/etc/clankercreds";
79
- /** The environment is read here and nowhere else, so a placement goes only where it is told. */
80
- const configHome = (home) => {
81
- const xdg = process.env["XDG_CONFIG_HOME"];
82
- return xdg === void 0 || xdg === "" ? join(home, ".config") : xdg;
83
- };
84
- /** The bundle is not named here: the key is bound to it on the service. */
85
- const loadConfig = () => {
86
- const directory = configDirectory();
87
- const file = join(directory, "config.json");
88
- const { service } = readJsonObject(file);
89
- if (typeof service !== "string") throw new Error(`${file} must hold {"service": "https://…"}`);
90
- const key = readText(join(directory, "key"))?.trim();
91
- if (key === void 0 || key === "") throw new Error(`${join(directory, "key")} must hold the machine key`);
92
- const home = homedir();
93
- return {
94
- service: service.replace(/\/+$/, ""),
95
- key,
96
- home,
97
- configHome: configHome(home)
98
- };
99
- };
100
- Data.TaggedError("Unauthorized");
101
- /**
102
- * The issuer could not be reached, so the credential could not be decided: a 503, not a
103
- * refusal. A resource's descriptor declares it,
104
- * `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
105
- * Its public response is the schema. Diagnostic causes are internal, non-enumerable fields.
106
- */
107
- var ProviderUnavailable = class extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
108
- constructor(options) {
109
- super({ operation: options.operation });
110
- Object.defineProperty(this, "cause", { value: options.cause });
111
- }
112
- };
113
- Data.TaggedError("InsufficientScope");
114
- Schema.TaggedError()("ConfigurationError", { message: Schema.String });
115
- //#endregion
116
- //#region ../../node_modules/.pnpm/@gjermundgaraba+clankerauth-sdk@0.13.0_@gjermundgaraba+effect-actions@0.10.0_effect@4.0.0__effect@4.0.0/node_modules/@gjermundgaraba/clankerauth-sdk/dist/session.js
117
- /**
118
- * The identity a browser page needs, without any verification code. This entry point is
119
- * browser-safe: it declares the identity a protected contract states, the `whoami` contract
120
- * and the issuer's sign-out URL, so contracts, bindings and pages import it directly, and a
121
- * server answers `Whoami` with its resource's `session`.
122
- */
123
- /**
124
- * The caller: what a protected contract states, `caller: CurrentPrincipal`, and what an
125
- * authentication descriptor proves,
126
- * `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
127
- * Remotely, the resource's provider gives it per request, from the verified credential; on
128
- * a trusted local surface, the host gives it the process, `Notes.local(subject)`.
129
- */
130
- var CurrentPrincipal = class extends Context.Service()("@clankerauth/CurrentPrincipal") {};
131
- /** The verified credential behind a request, as the resource server read it. */
132
- const Principal = Schema.Struct({
133
- subject: Schema.NonEmptyString,
134
- issuer: Schema.NonEmptyString,
135
- scopes: Schema.Array(Schema.String)
136
- });
137
- Action.make("whoami", {
138
- description: "The verified subject, issuer and scopes behind this request's credential.",
139
- readOnly: true,
140
- caller: CurrentPrincipal,
141
- success: Principal
142
- });
143
- //#endregion
144
- //#region ../../packages/api/src/bundle.ts
13
+ //#region ../../packages/api/src/credentials.ts
14
+ /** The credential an entry was delivered from, as the machine asked for it by name. */
15
+ const fromCredential = { credential: Name };
145
16
  const GhCredential = Schema.Struct({
146
17
  type: Schema.Literal("gh"),
18
+ ...fromCredential,
147
19
  host: Schema.String,
148
20
  token: Schema.String
149
21
  });
150
22
  const ClaudeCredential = Schema.Struct({
151
23
  type: Schema.Literal("claude"),
24
+ ...fromCredential,
152
25
  token: Schema.String
153
26
  });
154
27
  /**
155
28
  * What Codex keeps in its `auth.json`, always written together: the three belong to one
156
- * login. No refresh token: the client writes `<clientId>:<key>` in its place.
29
+ * login. No refresh token: the client writes `<clientId>:<credential>:<key>` in its place.
157
30
  */
158
31
  const CodexLogin = Schema.Struct({
159
32
  accessToken: Schema.String,
@@ -162,6 +35,7 @@ const CodexLogin = Schema.Struct({
162
35
  });
163
36
  const CodexCredential = Schema.Struct({
164
37
  type: Schema.Literal("codex"),
38
+ ...fromCredential,
165
39
  ...CodexLogin.fields
166
40
  });
167
41
  /**
@@ -170,18 +44,21 @@ const CodexCredential = Schema.Struct({
170
44
  */
171
45
  const PiCredential = Schema.Struct({
172
46
  type: Schema.Literal("pi"),
47
+ ...fromCredential,
173
48
  accessToken: Schema.String
174
49
  });
175
50
  /**
176
51
  * The service sends typed credentials, never file contents or paths, so it cannot write
177
52
  * anything on a machine but what the client knows how to place. The cost is that a new
178
- * tool, or a placement fix, needs a client release, which new machines install.
53
+ * tool, or a placement fix, needs a client release, which new machines install. Each entry
54
+ * names the credential it came from: a ChatGPT login arrives as a `codex` and a `pi` entry
55
+ * under the same name.
179
56
  *
180
57
  * Each machine keeps the client its setup installed, so old clients read what the service sends. A change
181
58
  * an old client would get wrong is a new type name, which it skips: the decoder drops fields
182
59
  * it does not know, so one ignoring `host` would place a GHE token as github.com's. A field
183
- * an old client can safely ignore may be added, as pi's `accessToken` was; the service ships
184
- * first, since a new client refuses a bundle that lacks it.
60
+ * an old client can safely ignore may be added; the service ships first, since a new client
61
+ * refuses an entry that lacks it.
185
62
  */
186
63
  const TypedCredential = Schema.Union([
187
64
  GhCredential,
@@ -198,70 +75,32 @@ const isKnownType = (type) => knownTypes.some((known) => known === type);
198
75
  * schema must not land here, or a broken credential would be placed as an empty one.
199
76
  */
200
77
  const UnknownCredential = Schema.Struct({ type: Schema.String.check(Schema.makeFilter((type) => !isKnownType(type), { message: "a known credential type must match its schema" })) });
201
- /** Everything the machine's bundle holds, sent in full on every sync. */
202
- const Bundle = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
203
- //#endregion
204
- //#region ../../packages/api/src/shared.ts
205
- /**
206
- * What was asked for does not exist: a bundle, credential or sign-in by that name, a
207
- * bundle for the calling key, or a ChatGPT login in the bundle that has none.
208
- */
209
- var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {};
210
- Schema.TaggedError()("Conflict", { message: Schema.String }, { httpApiStatus: 409 });
211
- /** The ChatGPT login is dead, or was never completed. Only a new device sign-in revives it. */
212
- var NeedsSignIn = class extends Schema.TaggedError()("NeedsSignIn", { message: Schema.String }, { httpApiStatus: 409 }) {};
213
- /** OpenAI could not be reached and no valid token is held. Waiting helps; retrying now does not. */
214
- var UpstreamUnavailable = class extends Schema.TaggedError()("UpstreamUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {};
215
- Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/));
216
- Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/), Schema.isMaxLength(253));
217
- /**
218
- * The machine's self-generated label. It identifies nothing; it only groups audit rows.
219
- * It excludes `:` because the Codex refresh token is `<clientId>:<key>`.
220
- */
221
- const ClientId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/));
222
- const Claims = Schema.Struct({
223
- exp: Schema.optionalKey(Schema.Number),
224
- email: Schema.optionalKey(Schema.String),
225
- "https://api.openai.com/auth": Schema.optionalKey(Schema.Struct({ chatgpt_account_id: Schema.optionalKey(Schema.String) }))
226
- });
227
- /**
228
- * The claims of an OpenAI JWT, unverified: they are read for their timing and their
229
- * account, never trusted for anything. Anything unreadable is simply no claims.
230
- */
231
- const claimsOf = (jwt) => {
232
- const payload = Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8");
233
- const claims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims))(payload);
234
- return Option.match(claims, {
235
- onNone: () => ({
236
- exp: void 0,
237
- email: void 0,
238
- accountId: void 0
239
- }),
240
- onSome: (value) => ({
241
- exp: value.exp,
242
- email: value.email,
243
- accountId: value["https://api.openai.com/auth"]?.chatgpt_account_id
244
- })
245
- });
246
- };
78
+ /** Everything the machine asked for, sent in full on every sync. */
79
+ const Delivery = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
247
80
  //#endregion
248
81
  //#region ../../packages/api/src/machine.ts
249
82
  /**
250
- * What a machine key can do: read the one bundle bound to it, and nothing else. The
251
- * bundle is never named: the key is bound to it on the server.
83
+ * What a machine key can do: read the credentials it is granted, by name, and nothing else.
84
+ * The machine names what it wants; the grants on the server decide whether it may.
252
85
  */
253
- const Machine = [Action.make("bundle", {
86
+ const Machine = [Action.make("sync", {
254
87
  readOnly: false,
255
88
  caller: CurrentPrincipal,
256
- description: "Fetch the typed credentials of the bundle bound to the calling key.",
257
- input: Schema.Struct({ clientId: ClientId }),
258
- success: Bundle,
89
+ description: "Fetch the named credentials as typed entries. Refused whole when the calling key is not granted any one of them.",
90
+ input: Schema.Struct({
91
+ clientId: ClientId,
92
+ credentials: Schema.Array(Name)
93
+ }),
94
+ success: Delivery,
259
95
  error: NotFound
260
96
  }), Action.make("codexLogin", {
261
97
  readOnly: false,
262
98
  caller: CurrentPrincipal,
263
- description: "The current ChatGPT login of the bundle bound to the calling key, as the codex type carries it, refreshed first when it has expired.",
264
- input: Schema.Struct({ clientId: ClientId }),
99
+ description: "The current login of a ChatGPT credential the calling key is granted, as the codex type carries it, refreshed first when it has expired.",
100
+ input: Schema.Struct({
101
+ clientId: ClientId,
102
+ credential: Name
103
+ }),
265
104
  success: CodexLogin,
266
105
  error: [
267
106
  NotFound,
@@ -283,7 +122,7 @@ var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUn
283
122
  }) }, { httpApiStatus: 503 }) {};
284
123
  /**
285
124
  * Codex's own refresh request, the one request whose shape is not ours. The refresh-token
286
- * field carries `<clientId>:<key>`, so the contract is public and its handler authenticates
125
+ * field carries `<clientId>:<credential>:<key>`, so the contract is public and its handler authenticates
287
126
  * the key against the machine resource. The answer leaves `refresh_token` out, so Codex keeps the
288
127
  * composite it has. Its input alone accepts fields it does not name: Codex may add one,
289
128
  * and every other action refuses them.
@@ -291,7 +130,7 @@ var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUn
291
130
  const CodexRefresh = Action.make("codexRefresh", {
292
131
  readOnly: false,
293
132
  caller: Action.Anyone,
294
- description: "Answer Codex's token refresh with the bundle's current ChatGPT tokens.",
133
+ description: "Answer Codex's token refresh with the current tokens of the ChatGPT credential its composite names.",
295
134
  input: Schema.StructWithRest(Schema.Struct({
296
135
  grant_type: Schema.Literal("refresh_token"),
297
136
  refresh_token: Schema.String,
@@ -304,14 +143,21 @@ const CodexRefresh = Action.make("codexRefresh", {
304
143
  }),
305
144
  error: [CodexRefreshRejected, CodexRefreshUnavailable]
306
145
  });
307
- /** `<clientId>:<key>`. Client ids exclude `:`, so the key is the rest. */
308
- const formatCodexRefreshToken = (clientId, key) => `${clientId}:${key}`;
146
+ /**
147
+ * `<clientId>:<credential>:<key>`: a key may be granted several ChatGPT logins, so the
148
+ * composite names the one Codex holds. Client ids and names exclude `:`, so the key is the rest.
149
+ */
150
+ const formatCodexRefreshToken = (clientId, credential, key) => `${clientId}:${credential}:${key}`;
309
151
  const parseCodexRefreshToken = (value) => {
310
- const colon = value.indexOf(":");
311
- return colon < 1 || colon === value.length - 1 ? void 0 : {
312
- clientId: value.slice(0, colon),
313
- key: value.slice(colon + 1)
314
- };
152
+ const first = value.indexOf(":");
153
+ const second = value.indexOf(":", first + 1);
154
+ if (first < 1 || second === -1 || second === value.length - 1) return void 0;
155
+ const credential = value.slice(first + 1, second);
156
+ return Schema.is(Name)(credential) ? {
157
+ clientId: value.slice(0, first),
158
+ credential,
159
+ key: value.slice(second + 1)
160
+ } : void 0;
315
161
  };
316
162
  /**
317
163
  * A machine key's bearer token on the public port, verified by the machine resource's own
@@ -324,6 +170,99 @@ const MachineHttp = ActionHttp.make([...Machine, CodexRefresh], {
324
170
  authentication: MachineLogin
325
171
  });
326
172
  //#endregion
173
+ //#region src/files.ts
174
+ /** Secrets are readable by their owner only, in directories only the owner can enter. */
175
+ const fileMode = 384;
176
+ const directoryMode = 448;
177
+ const isMissing = (error) => Predicate.hasProperty(error, "code") && error.code === "ENOENT";
178
+ /** A missing file is `undefined`. A file that exists but cannot be read is not ours to replace. */
179
+ const readText = (path) => {
180
+ try {
181
+ return readFileSync(path, "utf8");
182
+ } catch (error) {
183
+ if (isMissing(error)) return void 0;
184
+ throw error;
185
+ }
186
+ };
187
+ /**
188
+ * Write-then-rename, so a tool reading the file sees the old content or the new and never
189
+ * half of one. The directory is created private; an existing one keeps its mode.
190
+ */
191
+ const writeAtomic = (path, content) => {
192
+ mkdirSync(dirname(path), {
193
+ recursive: true,
194
+ mode: directoryMode
195
+ });
196
+ const partial = `${path}.${randomBytes(6).toString("hex")}.tmp`;
197
+ try {
198
+ writeFileSync(partial, content, { mode: fileMode });
199
+ chmodSync(partial, fileMode);
200
+ renameSync(partial, path);
201
+ } catch (error) {
202
+ rmSync(partial, { force: true });
203
+ throw error;
204
+ }
205
+ };
206
+ /** True when there was a file to remove. */
207
+ const remove = (path) => {
208
+ try {
209
+ unlinkSync(path);
210
+ return true;
211
+ } catch (error) {
212
+ if (isMissing(error)) return false;
213
+ throw error;
214
+ }
215
+ };
216
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
217
+ /** The file as an object; a missing file is empty. Anything else is not ours to overwrite. */
218
+ const readJsonObject = (path) => {
219
+ const text = readText(path);
220
+ if (text === void 0 || text.trim() === "") return {};
221
+ const parsed = JSON.parse(text);
222
+ if (!isJsonObject(parsed)) throw new Error(`${path} does not hold a JSON object`);
223
+ return parsed;
224
+ };
225
+ /**
226
+ * A nested object a placement merges into; a missing one is empty. Anything else under the
227
+ * key is someone else's, however malformed, and not ours to overwrite.
228
+ */
229
+ const objectAt = (parent, key, path) => {
230
+ const value = parent[key];
231
+ if (value === void 0) return {};
232
+ if (!isJsonObject(value)) throw new Error(`${key} in ${path} is not a JSON object; refusing to replace it`);
233
+ return value;
234
+ };
235
+ const writeJson = (path, value) => writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
236
+ //#endregion
237
+ //#region src/config.ts
238
+ /** Where the recipe puts `config.json` and `key`. The variable exists for tests and odd images. */
239
+ const configDirectory = () => process.env["CLANKERCREDS_CONFIG_DIR"] ?? "/etc/clankercreds";
240
+ /** The environment is read here and nowhere else, so a placement goes only where it is told. */
241
+ const configHome = (home) => {
242
+ const xdg = process.env["XDG_CONFIG_HOME"];
243
+ return xdg === void 0 || xdg === "" ? join(home, ".config") : xdg;
244
+ };
245
+ const ConfigFile = Schema.Struct({
246
+ service: Schema.String,
247
+ credentials: Schema.Array(Name)
248
+ });
249
+ /** The machine names what it syncs; the service decides whether its key may read it. */
250
+ const loadConfig = () => {
251
+ const directory = configDirectory();
252
+ const file = join(directory, "config.json");
253
+ const { service, credentials } = Option.getOrThrowWith(Schema.decodeUnknownOption(ConfigFile)(readJsonObject(file)), () => /* @__PURE__ */ new Error(`${file} must hold {"service": "https://…", "credentials": ["github-main", …]}, each name in lowercase letters, digits and dashes`));
254
+ const key = readText(join(directory, "key"))?.trim();
255
+ if (key === void 0 || key === "") throw new Error(`${join(directory, "key")} must hold the machine key`);
256
+ const home = homedir();
257
+ return {
258
+ service: service.replace(/\/+$/, ""),
259
+ credentials,
260
+ key,
261
+ home,
262
+ configHome: configHome(home)
263
+ };
264
+ };
265
+ //#endregion
327
266
  //#region src/service.ts
328
267
  /** The service answered and said no. Retrying the same request will not help. */
329
268
  var Refused = class extends Error {};
@@ -350,8 +289,15 @@ const classify = (error) => {
350
289
  * the command about ten seconds, and a hang would cost it a token it already holds.
351
290
  */
352
291
  const within = (call, deadlineMs) => Effect.runPromise(Effect.timeout(call, deadlineMs)).catch((error) => Promise.reject(classify(error)));
353
- const fetchBundle = (config, clientId, deadlineMs = 3e4) => within(connect(config).bundle({ clientId }), deadlineMs);
354
- const fetchCodexLogin = (config, clientId, deadlineMs = 5e3) => within(connect(config).codexLogin({ clientId }), deadlineMs);
292
+ /** The credentials the config names, refused whole when the key is not granted any one. */
293
+ const fetchDelivery = (config, clientId, deadlineMs = 3e4) => within(connect(config).sync({
294
+ clientId,
295
+ credentials: config.credentials
296
+ }), deadlineMs);
297
+ const fetchCodexLogin = (config, clientId, credential, deadlineMs = 5e3) => within(connect(config).codexLogin({
298
+ clientId,
299
+ credential
300
+ }), deadlineMs);
355
301
  //#endregion
356
302
  //#region src/place/claude.ts
357
303
  const settingsFile = (home) => join(home, ".claude/settings.json");
@@ -393,8 +339,8 @@ const removeClaude = (home) => {
393
339
  const authFile$1 = (home) => join(home, ".codex/auth.json");
394
340
  /**
395
341
  * The file's tokens, when its refresh-token field is a composite: then clankercreds wrote
396
- * it, for whichever identity the composite names. A login anyone else wrote has a real
397
- * refresh token there.
342
+ * it, for whichever identity and credential the composite names. A login anyone else wrote
343
+ * has a real refresh token there.
398
344
  */
399
345
  const ownTokens = (auth) => {
400
346
  const tokens = auth["tokens"];
@@ -410,19 +356,20 @@ const ownTokens = (auth) => {
410
356
  /**
411
357
  * The one writer of the file, and it always writes the whole login: the three tokens
412
358
  * belong together. The refresh-token field never holds OpenAI's token: it names this
413
- * machine to the service, which Codex calls when `CODEX_REFRESH_TOKEN_URL_OVERRIDE` is
414
- * set. A login someone else made is never replaced.
359
+ * machine and the credential to the service, which Codex calls when
360
+ * `CODEX_REFRESH_TOKEN_URL_OVERRIDE` is set. A login someone else made is never replaced,
361
+ * nor one a release before named credentials wrote: its two-part composite is not ours.
415
362
  */
416
363
  const placeCodex = (home, login, identity) => {
417
364
  const existing = readJsonObject(authFile$1(home));
418
- if (Object.keys(existing).length > 0 && ownTokens(existing) === void 0) throw new Error(`${authFile$1(home)} was not written by clankercreds; refusing to replace it`);
365
+ if (Object.keys(existing).length > 0 && ownTokens(existing) === void 0) throw new Error(`${authFile$1(home)} was not written by clankercreds, or by a release before credentials were named; refusing to replace it. If clankercreds wrote it, remove it and sync again`);
419
366
  writeJson(authFile$1(home), {
420
367
  auth_mode: "chatgpt",
421
368
  OPENAI_API_KEY: null,
422
369
  tokens: {
423
370
  id_token: login.idToken,
424
371
  access_token: login.accessToken,
425
- refresh_token: formatCodexRefreshToken(identity.clientId, identity.key),
372
+ refresh_token: formatCodexRefreshToken(identity.clientId, login.credential, identity.key),
426
373
  account_id: login.accountId
427
374
  },
428
375
  last_refresh: (/* @__PURE__ */ new Date()).toISOString()
@@ -533,8 +480,21 @@ const clientId = (home, machineIdFile = clankerboxMachineId) => {
533
480
  writeAtomic(file, `${generated}\n`);
534
481
  return generated;
535
482
  };
536
- /** The access token `token` last fetched for pi. The client's own copy; no tool reads it. */
537
- const accessTokenFile = (home) => join(stateDirectory(home), "access-token");
483
+ /**
484
+ * pi's access token, and the ChatGPT credential it is of, which `token` renews it from. The
485
+ * client's own copy; no tool reads it.
486
+ */
487
+ const HeldToken = Schema.Struct({
488
+ credential: Schema.String,
489
+ accessToken: Schema.String
490
+ });
491
+ /** Encoding writes the schema's fields only, so pi's whole entry may be passed. */
492
+ const HeldTokenJson = Schema.fromJsonString(HeldToken);
493
+ const heldTokenFile = (home) => join(stateDirectory(home), "access-token");
494
+ /** The copy as `token` reads it. One that does not name its credential is no copy. */
495
+ const readHeldToken = (home) => Option.getOrUndefined(Schema.decodeUnknownOption(HeldTokenJson)(readText(heldTokenFile(home))));
496
+ const writeHeldToken = (home, held) => writeAtomic(heldTokenFile(home), `${Schema.encodeSync(HeldTokenJson)(held)}\n`);
497
+ const removeHeldToken = (home) => remove(heldTokenFile(home));
538
498
  //#endregion
539
499
  //#region src/place/pi.ts
540
500
  const modelsFile = (home) => join(home, ".pi/agent/models.json");
@@ -552,10 +512,11 @@ const refuseStoredLogin = (home) => {
552
512
  };
553
513
  /**
554
514
  * Only the key is ours; every other provider setting in the file is kept. The token goes
555
- * first, so whatever stops the key, `token` never answers for a login the bundle dropped.
515
+ * first, with its credential, so whatever stops the key, `token` never answers for a
516
+ * credential the config no longer names.
556
517
  */
557
- const placePi = (home, accessToken) => {
558
- writeAtomic(accessTokenFile(home), `${accessToken}\n`);
518
+ const placePi = (home, held) => {
519
+ writeHeldToken(home, held);
559
520
  refuseStoredLogin(home);
560
521
  const models = readJsonObject(modelsFile(home));
561
522
  const providers = objectAt(models, "providers", modelsFile(home));
@@ -594,7 +555,7 @@ const removeEntry = (home) => {
594
555
  * is left alone when it is someone else's.
595
556
  */
596
557
  const removePi = (home) => {
597
- remove(accessTokenFile(home));
558
+ removeHeldToken(home);
598
559
  return removeEntry(home);
599
560
  };
600
561
  //#endregion
@@ -617,11 +578,11 @@ const placements = {
617
578
  remove: (config) => removeCodex(config.home)
618
579
  },
619
580
  pi: {
620
- place: (config, _identity, [credential]) => placePi(config.home, credential.accessToken),
581
+ place: (config, _identity, [credential]) => placePi(config.home, credential),
621
582
  remove: (config) => removePi(config.home)
622
583
  }
623
584
  };
624
- /** What the bundle carries of one type is placed; a type it does not carry is undone. */
585
+ /** What the delivery carries of one type is placed; a type it does not carry is undone. */
625
586
  const settle = (config, identity, known, type) => {
626
587
  const credentials = known.filter((credential) => credential.type === type);
627
588
  if (credentials.length === 0) return placements[type].remove(config) ? "removed" : void 0;
@@ -629,22 +590,48 @@ const settle = (config, identity, known, type) => {
629
590
  return "placed";
630
591
  };
631
592
  /**
632
- * Idempotent: the whole bundle is fetched and placed every time, and every type it does
633
- * not carry is undone, whether or not a previous sync placed it. Nothing on disk changes
634
- * until the service has answered, so an unreachable service leaves every file as it was.
635
- * Each type stands alone: a file that is not ours to touch stops its own type only.
593
+ * What a machine holds one of, named as the operator stored it: a GitHub token per host, one
594
+ * Claude token, and one ChatGPT login, which arrives as a codex and a pi entry under one name.
595
+ */
596
+ const slot = (credential) => {
597
+ switch (credential.type) {
598
+ case "gh": return `GitHub credentials for ${credential.host}`;
599
+ case "claude": return "Claude credentials";
600
+ case "codex":
601
+ case "pi": return "ChatGPT credentials";
602
+ }
603
+ };
604
+ /**
605
+ * Two credentials for one slot would leave the machine holding whichever came last, so the
606
+ * whole sync is refused before anything is written.
607
+ */
608
+ const refuseShared = (known) => {
609
+ const claimed = /* @__PURE__ */ new Map();
610
+ for (const credential of known) {
611
+ const other = claimed.get(slot(credential));
612
+ if (other !== void 0 && other !== credential.credential) throw new Error(`${other} and ${credential.credential} are both ${slot(credential)}; a machine holds one, so config.json must name only one of them`);
613
+ claimed.set(slot(credential), credential.credential);
614
+ }
615
+ };
616
+ /**
617
+ * Idempotent: every credential the config names is fetched and placed every time, and every
618
+ * type the delivery does not carry is undone, whether or not a previous sync placed it.
619
+ * Nothing on disk changes until the service has answered, so an unreachable service leaves
620
+ * every file as it was. Each type stands alone: a file that is not ours to touch stops its
621
+ * own type only.
636
622
  */
637
623
  const sync = async (config) => {
638
624
  const identity = {
639
625
  clientId: clientId(config.home),
640
626
  key: config.key
641
627
  };
642
- const bundle = await fetchBundle(config, identity.clientId);
643
- const known = bundle.credentials.filter((credential) => isKnownType(credential.type));
628
+ const delivery = await fetchDelivery(config, identity.clientId);
629
+ const known = delivery.credentials.filter((credential) => isKnownType(credential.type));
630
+ refuseShared(known);
644
631
  const report = {
645
632
  placed: new Array(),
646
633
  removed: new Array(),
647
- skipped: bundle.credentials.flatMap(({ type }) => isKnownType(type) ? [] : [type]),
634
+ skipped: delivery.credentials.flatMap(({ type }) => isKnownType(type) ? [] : [type]),
648
635
  failed: new Array()
649
636
  };
650
637
  for (const type of knownTypes) try {
@@ -666,20 +653,24 @@ const margin = 3e5;
666
653
  const remaining = (jwt) => (claimsOf(jwt).exp ?? 0) * 1e3 - Date.now();
667
654
  /**
668
655
  * pi runs this on every model call, so the common path is one small file read. The token is
669
- * the client's own copy, placed by sync, so no other tool's file is read or written. Near
670
- * expiry, or with no copy, the service is asked again; an outage does not take a
671
- * still-valid token away.
656
+ * the client's own copy, placed by sync with the credential it is of, so no other tool's file
657
+ * is read or written. Near expiry the service is asked again for that credential; an outage
658
+ * does not take a still-valid token away. With no copy there is nothing to renew.
672
659
  */
673
660
  const accessToken = async (config) => {
674
- const file = accessTokenFile(config.home);
675
- const held = readText(file)?.trim();
676
- if (held && remaining(held) > margin) return held;
661
+ const held = readHeldToken(config.home);
662
+ if (held === void 0) throw new Error("no ChatGPT credential is placed for pi; run `clankercreds sync`");
663
+ if (remaining(held.accessToken) > margin) return held.accessToken;
677
664
  try {
678
- const { accessToken: fresh } = await fetchCodexLogin(config, clientId(config.home));
679
- if (held && readText(file)?.trim() === held) writeAtomic(file, `${fresh}\n`);
665
+ const { accessToken: fresh } = await fetchCodexLogin(config, clientId(config.home), held.credential);
666
+ const current = readHeldToken(config.home);
667
+ if (current?.credential === held.credential && current.accessToken === held.accessToken) writeHeldToken(config.home, {
668
+ credential: held.credential,
669
+ accessToken: fresh
670
+ });
680
671
  return fresh;
681
672
  } catch (error) {
682
- if (error instanceof Unreachable && held && remaining(held) > 0) return held;
673
+ if (error instanceof Unreachable && remaining(held.accessToken) > 0) return held.accessToken;
683
674
  throw error;
684
675
  }
685
676
  };
@@ -687,8 +678,9 @@ const accessToken = async (config) => {
687
678
  //#region src/main.ts
688
679
  const usage = `Usage: clankercreds <command>
689
680
 
690
- sync Fetch this machine's bundle and place each credential where its tool expects it.
681
+ sync Fetch the credentials the config names and place each where its tool expects it.
691
682
  token Print the current ChatGPT access token, for pi.
683
+ admin Administer the service over its admin port: \`clankercreds admin --help\`.
692
684
  `;
693
685
  const runSync = async () => {
694
686
  const report = await sync(loadConfig());
@@ -711,6 +703,10 @@ const commandFor = (name) => {
711
703
  }
712
704
  };
713
705
  const main = async () => {
706
+ if (process.argv[2] === "admin") {
707
+ (await import("./admin-CXDLXpaH.mjs")).run(process.argv.slice(2));
708
+ return;
709
+ }
714
710
  const command = commandFor(process.argv[2]);
715
711
  if (command === void 0) {
716
712
  console.error(usage);
@@ -0,0 +1,108 @@
1
+ import { Context, Data, Option, Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ Data.TaggedError("Unauthorized");
4
+ /**
5
+ * The issuer could not be reached, so the credential could not be decided: a 503, not a
6
+ * refusal. A resource's descriptor declares it,
7
+ * `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
8
+ * Its public response is the schema. Diagnostic causes are internal, non-enumerable fields.
9
+ */
10
+ var ProviderUnavailable = class extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
11
+ constructor(options) {
12
+ super({ operation: options.operation });
13
+ Object.defineProperty(this, "cause", { value: options.cause });
14
+ }
15
+ };
16
+ Data.TaggedError("InsufficientScope");
17
+ Schema.TaggedError()("ConfigurationError", { message: Schema.String });
18
+ //#endregion
19
+ //#region ../../node_modules/.pnpm/@gjermundgaraba+clankerauth-sdk@0.15.0_@gjermundgaraba+effect-actions@0.11.0_effect@4.0.2__effect@4.0.2/node_modules/@gjermundgaraba/clankerauth-sdk/dist/session.js
20
+ /**
21
+ * The identity a browser page needs, without any verification code. This entry point is
22
+ * browser-safe: it declares the identity a protected contract states, the `whoami` contract
23
+ * and the issuer's sign-out URL, so contracts, bindings and pages import it directly, and a
24
+ * server answers `Whoami` with its resource's `session`.
25
+ */
26
+ /**
27
+ * The caller: what a protected contract states, `caller: CurrentPrincipal`, and what an
28
+ * authentication descriptor proves,
29
+ * `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
30
+ * Remotely, the resource's provider gives it per request, from the verified credential; on
31
+ * a trusted local surface, the host gives it the process, `Notes.local(subject)`.
32
+ */
33
+ var CurrentPrincipal = class extends Context.Service()("@clankerauth/CurrentPrincipal") {};
34
+ /** The verified credential behind a request, as the resource server read it. */
35
+ const Principal = Schema.Struct({
36
+ subject: Schema.NonEmptyString,
37
+ issuer: Schema.NonEmptyString,
38
+ scopes: Schema.Array(Schema.String)
39
+ });
40
+ Action.make("whoami", {
41
+ description: "The verified subject, issuer and scopes behind this request's credential.",
42
+ readOnly: true,
43
+ caller: CurrentPrincipal,
44
+ success: Principal
45
+ });
46
+ //#endregion
47
+ //#region ../../packages/api/src/shared.ts
48
+ /**
49
+ * What a credential of the clankercreds resource may carry. `machine` admits the
50
+ * public actions and nothing else; `read` and `admin` admit the admin actions.
51
+ */
52
+ const scopes = {
53
+ machine: "clankercreds:machine",
54
+ read: "clankercreds:read",
55
+ admin: "clankercreds:admin"
56
+ };
57
+ /**
58
+ * What was asked for does not exist: a key, credential or sign-in by that name, or a
59
+ * credential the calling machine key is not granted, which it is not told apart from one
60
+ * that does not exist.
61
+ */
62
+ var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {};
63
+ /** The change collides with what is stored: a taken name, or a credential still in use. */
64
+ var Conflict = class extends Schema.TaggedError()("Conflict", { message: Schema.String }, { httpApiStatus: 409 }) {};
65
+ /** The ChatGPT login is dead, or was never completed. Only a new device sign-in revives it. */
66
+ var NeedsSignIn = class extends Schema.TaggedError()("NeedsSignIn", { message: Schema.String }, { httpApiStatus: 409 }) {};
67
+ /** OpenAI could not be reached and no valid token is held. Waiting helps; retrying now does not. */
68
+ var UpstreamUnavailable = class extends Schema.TaggedError()("UpstreamUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {};
69
+ /**
70
+ * Credential names: lowercase labels, because they are typed by hand and are
71
+ * identities. SQLite compares them case-sensitively, so mixed case would let `GitHub-Main`
72
+ * and `github-main` be two credentials.
73
+ */
74
+ const Name = Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/));
75
+ /** A GitHub host, lowercase: it is written into `gh`'s and git's configuration as is. */
76
+ const Host = Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/), Schema.isMaxLength(253));
77
+ /**
78
+ * The machine's self-generated label. It identifies nothing; it only groups audit rows.
79
+ * It excludes `:` because the Codex refresh token is `<clientId>:<credential>:<key>`.
80
+ */
81
+ const ClientId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/));
82
+ const Claims = Schema.Struct({
83
+ exp: Schema.optionalKey(Schema.Number),
84
+ email: Schema.optionalKey(Schema.String),
85
+ "https://api.openai.com/auth": Schema.optionalKey(Schema.Struct({ chatgpt_account_id: Schema.optionalKey(Schema.String) }))
86
+ });
87
+ /**
88
+ * The claims of an OpenAI JWT, unverified: they are read for their timing and their
89
+ * account, never trusted for anything. Anything unreadable is simply no claims.
90
+ */
91
+ const claimsOf = (jwt) => {
92
+ const payload = Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8");
93
+ const claims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims))(payload);
94
+ return Option.match(claims, {
95
+ onNone: () => ({
96
+ exp: void 0,
97
+ email: void 0,
98
+ accountId: void 0
99
+ }),
100
+ onSome: (value) => ({
101
+ exp: value.exp,
102
+ email: value.email,
103
+ accountId: value["https://api.openai.com/auth"]?.chatgpt_account_id
104
+ })
105
+ });
106
+ };
107
+ //#endregion
108
+ export { NeedsSignIn as a, claimsOf as c, ProviderUnavailable as d, Name as i, scopes as l, Conflict as n, NotFound as o, Host as r, UpstreamUnavailable as s, ClientId as t, CurrentPrincipal as u };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankercreds",
3
- "version": "0.2.0",
4
- "description": "The machine client of clankercreds: fetches a bundle of credentials and places each where its tool expects it.",
3
+ "version": "0.3.0",
4
+ "description": "The clankercreds command: a machine client that fetches the credentials its config names and places each where its tool expects it, and the operator's admin commands.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -19,17 +19,18 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@gjermundgaraba/effect-actions": "0.10.0",
23
- "effect": "4.0.0",
22
+ "@effect/platform-node": "4.0.2",
23
+ "@gjermundgaraba/effect-actions": "0.11.0",
24
+ "effect": "4.0.2",
24
25
  "yaml": "2.9.1"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@clankercreds/api": "0.0.0",
28
- "@gjermundgaraba/clankerauth-sdk": "0.13.0",
29
- "@types/node": "26.6.2",
29
+ "@gjermundgaraba/clankerauth-sdk": "0.15.0",
30
+ "@types/node": "26.6.4",
30
31
  "typescript": "7.0.2",
31
- "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
32
- "vite-plus": "1.0.0"
32
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
33
+ "vite-plus": "1.1.0"
33
34
  },
34
35
  "engines": {
35
36
  "node": ">=26"