@gjermundgaraba/clankercreds 0.1.1 → 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.
- package/dist/admin-CXDLXpaH.mjs +335 -0
- package/dist/main.mjs +277 -292
- package/dist/shared-DBK2cCkw.mjs +108 -0
- package/package.json +9 -8
|
@@ -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,15 +1,175 @@
|
|
|
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 {
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { Option, Predicate, Schema } from "effect";
|
|
7
|
-
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
8
|
-
import * as ActionHttpClient from "@gjermundgaraba/effect-actions/ActionHttpClient";
|
|
5
|
+
import { Effect, Option, Predicate, Schema } from "effect";
|
|
9
6
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
10
|
-
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
11
7
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
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";
|
|
12
12
|
import { YAMLMap, isMap, parseDocument } from "yaml";
|
|
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 };
|
|
16
|
+
const GhCredential = Schema.Struct({
|
|
17
|
+
type: Schema.Literal("gh"),
|
|
18
|
+
...fromCredential,
|
|
19
|
+
host: Schema.String,
|
|
20
|
+
token: Schema.String
|
|
21
|
+
});
|
|
22
|
+
const ClaudeCredential = Schema.Struct({
|
|
23
|
+
type: Schema.Literal("claude"),
|
|
24
|
+
...fromCredential,
|
|
25
|
+
token: Schema.String
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* What Codex keeps in its `auth.json`, always written together: the three belong to one
|
|
29
|
+
* login. No refresh token: the client writes `<clientId>:<credential>:<key>` in its place.
|
|
30
|
+
*/
|
|
31
|
+
const CodexLogin = Schema.Struct({
|
|
32
|
+
accessToken: Schema.String,
|
|
33
|
+
idToken: Schema.String,
|
|
34
|
+
accountId: Schema.String
|
|
35
|
+
});
|
|
36
|
+
const CodexCredential = Schema.Struct({
|
|
37
|
+
type: Schema.Literal("codex"),
|
|
38
|
+
...fromCredential,
|
|
39
|
+
...CodexLogin.fields
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* The access token `clankercreds token` answers pi with until it is near expiry: pi asks the
|
|
43
|
+
* command on every model call, and the command asks the service only after that.
|
|
44
|
+
*/
|
|
45
|
+
const PiCredential = Schema.Struct({
|
|
46
|
+
type: Schema.Literal("pi"),
|
|
47
|
+
...fromCredential,
|
|
48
|
+
accessToken: Schema.String
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* The service sends typed credentials, never file contents or paths, so it cannot write
|
|
52
|
+
* anything on a machine but what the client knows how to place. The cost is that a new
|
|
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.
|
|
56
|
+
*
|
|
57
|
+
* Each machine keeps the client its setup installed, so old clients read what the service sends. A change
|
|
58
|
+
* an old client would get wrong is a new type name, which it skips: the decoder drops fields
|
|
59
|
+
* it does not know, so one ignoring `host` would place a GHE token as github.com's. A field
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
const TypedCredential = Schema.Union([
|
|
64
|
+
GhCredential,
|
|
65
|
+
ClaudeCredential,
|
|
66
|
+
CodexCredential,
|
|
67
|
+
PiCredential
|
|
68
|
+
]);
|
|
69
|
+
/** The types this build knows, derived from the union so there is one list. */
|
|
70
|
+
const knownTypes = TypedCredential.members.map((member) => member.fields.type.literal);
|
|
71
|
+
const isKnownType = (type) => knownTypes.some((known) => known === type);
|
|
72
|
+
/**
|
|
73
|
+
* What the service may send that this client does not know yet. It decodes instead of
|
|
74
|
+
* failing, so the client can skip it with a warning. A known type that fails its own
|
|
75
|
+
* schema must not land here, or a broken credential would be placed as an empty one.
|
|
76
|
+
*/
|
|
77
|
+
const UnknownCredential = Schema.Struct({ type: Schema.String.check(Schema.makeFilter((type) => !isKnownType(type), { message: "a known credential type must match its schema" })) });
|
|
78
|
+
/** Everything the machine asked for, sent in full on every sync. */
|
|
79
|
+
const Delivery = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region ../../packages/api/src/machine.ts
|
|
82
|
+
/**
|
|
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.
|
|
85
|
+
*/
|
|
86
|
+
const Machine = [Action.make("sync", {
|
|
87
|
+
readOnly: false,
|
|
88
|
+
caller: CurrentPrincipal,
|
|
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,
|
|
95
|
+
error: NotFound
|
|
96
|
+
}), Action.make("codexLogin", {
|
|
97
|
+
readOnly: false,
|
|
98
|
+
caller: CurrentPrincipal,
|
|
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
|
+
}),
|
|
104
|
+
success: CodexLogin,
|
|
105
|
+
error: [
|
|
106
|
+
NotFound,
|
|
107
|
+
NeedsSignIn,
|
|
108
|
+
UpstreamUnavailable
|
|
109
|
+
]
|
|
110
|
+
})];
|
|
111
|
+
/**
|
|
112
|
+
* Codex treats a 401 as permanent and stops retrying; every other failure is transient.
|
|
113
|
+
* The codes are the ones Codex classifies: `refresh_token_invalidated` reads as revoked.
|
|
114
|
+
*/
|
|
115
|
+
var CodexRefreshRejected = class extends Schema.TaggedError()("CodexRefreshRejected", { error: Schema.Struct({
|
|
116
|
+
code: Schema.String,
|
|
117
|
+
message: Schema.String
|
|
118
|
+
}) }, { httpApiStatus: 401 }) {};
|
|
119
|
+
var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUnavailable", { error: Schema.Struct({
|
|
120
|
+
code: Schema.String,
|
|
121
|
+
message: Schema.String
|
|
122
|
+
}) }, { httpApiStatus: 503 }) {};
|
|
123
|
+
/**
|
|
124
|
+
* Codex's own refresh request, the one request whose shape is not ours. The refresh-token
|
|
125
|
+
* field carries `<clientId>:<credential>:<key>`, so the contract is public and its handler authenticates
|
|
126
|
+
* the key against the machine resource. The answer leaves `refresh_token` out, so Codex keeps the
|
|
127
|
+
* composite it has. Its input alone accepts fields it does not name: Codex may add one,
|
|
128
|
+
* and every other action refuses them.
|
|
129
|
+
*/
|
|
130
|
+
const CodexRefresh = Action.make("codexRefresh", {
|
|
131
|
+
readOnly: false,
|
|
132
|
+
caller: Action.Anyone,
|
|
133
|
+
description: "Answer Codex's token refresh with the current tokens of the ChatGPT credential its composite names.",
|
|
134
|
+
input: Schema.StructWithRest(Schema.Struct({
|
|
135
|
+
grant_type: Schema.Literal("refresh_token"),
|
|
136
|
+
refresh_token: Schema.String,
|
|
137
|
+
client_id: Schema.optionalKey(Schema.String),
|
|
138
|
+
scope: Schema.optionalKey(Schema.String)
|
|
139
|
+
}), [Schema.Record(Schema.String, Schema.Json)]),
|
|
140
|
+
success: Schema.Struct({
|
|
141
|
+
access_token: Schema.String,
|
|
142
|
+
id_token: Schema.String
|
|
143
|
+
}),
|
|
144
|
+
error: [CodexRefreshRejected, CodexRefreshUnavailable]
|
|
145
|
+
});
|
|
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}`;
|
|
151
|
+
const parseCodexRefreshToken = (value) => {
|
|
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;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* A machine key's bearer token on the public port, verified by the machine resource's own
|
|
164
|
+
* provider, which requires `clankercreds:machine`: a key without that scope is refused here.
|
|
165
|
+
*/
|
|
166
|
+
const MachineLogin = Authentication.make("clankercreds.machine", CurrentPrincipal, { error: ProviderUnavailable });
|
|
167
|
+
/** Everything the public listener serves: the machine actions, and Codex's refresh beside them. */
|
|
168
|
+
const MachineHttp = ActionHttp.make([...Machine, CodexRefresh], {
|
|
169
|
+
prefix: "/v1",
|
|
170
|
+
authentication: MachineLogin
|
|
171
|
+
});
|
|
172
|
+
//#endregion
|
|
13
173
|
//#region src/files.ts
|
|
14
174
|
/** Secrets are readable by their owner only, in directories only the owner can enter. */
|
|
15
175
|
const fileMode = 384;
|
|
@@ -82,287 +242,62 @@ const configHome = (home) => {
|
|
|
82
242
|
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
83
243
|
return xdg === void 0 || xdg === "" ? join(home, ".config") : xdg;
|
|
84
244
|
};
|
|
85
|
-
|
|
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. */
|
|
86
250
|
const loadConfig = () => {
|
|
87
251
|
const directory = configDirectory();
|
|
88
252
|
const file = join(directory, "config.json");
|
|
89
|
-
const { service } = readJsonObject(file);
|
|
90
|
-
if (typeof service !== "string") throw new Error(`${file} must hold {"service": "https://…"}`);
|
|
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`));
|
|
91
254
|
const key = readText(join(directory, "key"))?.trim();
|
|
92
255
|
if (key === void 0 || key === "") throw new Error(`${join(directory, "key")} must hold the machine key`);
|
|
93
256
|
const home = homedir();
|
|
94
257
|
return {
|
|
95
258
|
service: service.replace(/\/+$/, ""),
|
|
259
|
+
credentials,
|
|
96
260
|
key,
|
|
97
261
|
home,
|
|
98
262
|
configHome: configHome(home)
|
|
99
263
|
};
|
|
100
264
|
};
|
|
101
265
|
//#endregion
|
|
102
|
-
//#region ../../node_modules/.pnpm/@gjermundgaraba+clankerauth-sdk@0.9.0_@gjermundgaraba+effect-actions@0.7.0_effect@4.0.0-rc.117_/node_modules/@gjermundgaraba/clankerauth-sdk/dist/errors.js
|
|
103
|
-
/**
|
|
104
|
-
* Every refusal this package produces, as schemas. This entry point is browser-safe:
|
|
105
|
-
* a shared contract declares these on its surface and a browser client decodes them,
|
|
106
|
-
* without pulling token verification or its cryptography into the bundle.
|
|
107
|
-
*
|
|
108
|
-
* Schemas define public responses. Diagnostic causes are internal, non-enumerable fields.
|
|
109
|
-
*/
|
|
110
|
-
var Unauthorized = class extends Schema.TaggedError()("Unauthorized", { message: Schema.String }, { httpApiStatus: 401 }) {
|
|
111
|
-
constructor(options) {
|
|
112
|
-
super({ message: options.message });
|
|
113
|
-
Object.defineProperty(this, "cause", { value: options.cause });
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
var RateLimited = class extends Schema.TaggedError()("RateLimited", { message: Schema.String }, { httpApiStatus: 429 }) {};
|
|
117
|
-
var ProviderUnavailable = class extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
|
|
118
|
-
constructor(options) {
|
|
119
|
-
super({ operation: options.operation });
|
|
120
|
-
Object.defineProperty(this, "cause", { value: options.cause });
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
/**
|
|
124
|
-
* A verified credential lacks one scope the requested access needs. It names only
|
|
125
|
-
* the missing scope, so a refusal never enumerates the resource's permissions.
|
|
126
|
-
*/
|
|
127
|
-
var InsufficientScope = class extends Schema.TaggedError()("InsufficientScope", { scope: Schema.String }, { httpApiStatus: 403 }) {};
|
|
128
|
-
Schema.TaggedError()("ConfigurationError", { message: Schema.String });
|
|
129
|
-
/**
|
|
130
|
-
* What verification, admission and the authorization hook refuse with — one list, so a
|
|
131
|
-
* surface declares `errors: authenticationErrors` and answers every refusal the same way.
|
|
132
|
-
*/
|
|
133
|
-
const authenticationErrors = [
|
|
134
|
-
Unauthorized,
|
|
135
|
-
InsufficientScope,
|
|
136
|
-
RateLimited,
|
|
137
|
-
ProviderUnavailable
|
|
138
|
-
];
|
|
139
|
-
//#endregion
|
|
140
|
-
//#region ../../packages/api/src/bundle.ts
|
|
141
|
-
const GhCredential = Schema.Struct({
|
|
142
|
-
type: Schema.Literal("gh"),
|
|
143
|
-
host: Schema.String,
|
|
144
|
-
token: Schema.String
|
|
145
|
-
});
|
|
146
|
-
const ClaudeCredential = Schema.Struct({
|
|
147
|
-
type: Schema.Literal("claude"),
|
|
148
|
-
token: Schema.String
|
|
149
|
-
});
|
|
150
|
-
/**
|
|
151
|
-
* What Codex keeps in its `auth.json`, always written together: the three belong to one
|
|
152
|
-
* login. No refresh token: the client writes `<clientId>:<key>` in its place.
|
|
153
|
-
*/
|
|
154
|
-
const CodexLogin = Schema.Struct({
|
|
155
|
-
accessToken: Schema.String,
|
|
156
|
-
idToken: Schema.String,
|
|
157
|
-
accountId: Schema.String
|
|
158
|
-
});
|
|
159
|
-
const CodexCredential = Schema.Struct({
|
|
160
|
-
type: Schema.Literal("codex"),
|
|
161
|
-
...CodexLogin.fields
|
|
162
|
-
});
|
|
163
|
-
/**
|
|
164
|
-
* The access token `clankercreds token` answers pi with until it is near expiry: pi asks the
|
|
165
|
-
* command on every model call, and the command asks the service only after that.
|
|
166
|
-
*/
|
|
167
|
-
const PiCredential = Schema.Struct({
|
|
168
|
-
type: Schema.Literal("pi"),
|
|
169
|
-
accessToken: Schema.String
|
|
170
|
-
});
|
|
171
|
-
/**
|
|
172
|
-
* The client is baked into images, so old clients read what the service sends. A change
|
|
173
|
-
* an old client would get wrong is a new type name, which it skips: the decoder drops fields
|
|
174
|
-
* it does not know, so one ignoring `host` would place a GHE token as github.com's. A field
|
|
175
|
-
* an old client can safely ignore may be added, as pi's `accessToken` was; the service ships
|
|
176
|
-
* first, since a new client refuses a bundle that lacks it.
|
|
177
|
-
*/
|
|
178
|
-
const TypedCredential = Schema.Union([
|
|
179
|
-
GhCredential,
|
|
180
|
-
ClaudeCredential,
|
|
181
|
-
CodexCredential,
|
|
182
|
-
PiCredential
|
|
183
|
-
]);
|
|
184
|
-
/** The types this build knows, derived from the union so there is one list. */
|
|
185
|
-
const knownTypes = TypedCredential.members.map((member) => member.fields.type.literal);
|
|
186
|
-
const isKnownType = (type) => knownTypes.some((known) => known === type);
|
|
187
|
-
/**
|
|
188
|
-
* What the service may send that this client does not know yet. It decodes instead of
|
|
189
|
-
* failing, so the client can skip it with a warning. A known type that fails its own
|
|
190
|
-
* schema must not land here, or a broken credential would be placed as an empty one.
|
|
191
|
-
*/
|
|
192
|
-
const UnknownCredential = Schema.Struct({ type: Schema.String.check(Schema.makeFilter((type) => !isKnownType(type), { message: "a known credential type must match its schema" })) });
|
|
193
|
-
/** Everything the machine's bundle holds, sent in full on every sync. */
|
|
194
|
-
const Bundle = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
|
|
195
|
-
//#endregion
|
|
196
|
-
//#region ../../packages/api/src/shared.ts
|
|
197
|
-
/** The caller asked for something the service cannot do with the input given. */
|
|
198
|
-
var BadRequest = class extends Schema.TaggedError()("BadRequest", { message: Schema.String }, { httpApiStatus: 400 }) {};
|
|
199
|
-
/**
|
|
200
|
-
* What was asked for does not exist: a bundle, credential or sign-in by that name, a
|
|
201
|
-
* bundle for the calling key, or a ChatGPT login in the bundle that has none.
|
|
202
|
-
*/
|
|
203
|
-
var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {};
|
|
204
|
-
Schema.TaggedError()("Conflict", { message: Schema.String }, { httpApiStatus: 409 });
|
|
205
|
-
/** The ChatGPT login is dead, or was never completed. Only a new device sign-in revives it. */
|
|
206
|
-
var NeedsSignIn = class extends Schema.TaggedError()("NeedsSignIn", { message: Schema.String }, { httpApiStatus: 409 }) {};
|
|
207
|
-
/** OpenAI could not be reached and no valid token is held. Waiting helps; retrying now does not. */
|
|
208
|
-
var UpstreamUnavailable = class extends Schema.TaggedError()("UpstreamUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {};
|
|
209
|
-
/** The request could not be represented on the wire; never a caller-fixable failure. */
|
|
210
|
-
var InternalError = class extends Schema.TaggedError()("InternalError", { message: Schema.String }, { httpApiStatus: 500 }) {};
|
|
211
|
-
/** Transport failures answer with the same public errors handlers use. */
|
|
212
|
-
const schemaError = {
|
|
213
|
-
invalid: {
|
|
214
|
-
schema: BadRequest,
|
|
215
|
-
make: () => new BadRequest({ message: "The request does not match the action's input." })
|
|
216
|
-
},
|
|
217
|
-
internal: {
|
|
218
|
-
schema: InternalError,
|
|
219
|
-
make: () => new InternalError({ message: "The response could not be encoded." })
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/));
|
|
223
|
-
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));
|
|
224
|
-
/**
|
|
225
|
-
* The machine's self-generated label. It identifies nothing; it only groups audit rows.
|
|
226
|
-
* It excludes `:` because the Codex refresh token is `<clientId>:<key>`.
|
|
227
|
-
*/
|
|
228
|
-
const ClientId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/));
|
|
229
|
-
const Claims = Schema.Struct({
|
|
230
|
-
exp: Schema.optionalKey(Schema.Number),
|
|
231
|
-
email: Schema.optionalKey(Schema.String),
|
|
232
|
-
"https://api.openai.com/auth": Schema.optionalKey(Schema.Struct({ chatgpt_account_id: Schema.optionalKey(Schema.String) }))
|
|
233
|
-
});
|
|
234
|
-
/**
|
|
235
|
-
* The claims of an OpenAI JWT, unverified: they are read for their timing and their
|
|
236
|
-
* account, never trusted for anything. Anything unreadable is simply no claims.
|
|
237
|
-
*/
|
|
238
|
-
const claimsOf = (jwt) => {
|
|
239
|
-
const payload = Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8");
|
|
240
|
-
const claims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims))(payload);
|
|
241
|
-
return Option.match(claims, {
|
|
242
|
-
onNone: () => ({
|
|
243
|
-
exp: void 0,
|
|
244
|
-
email: void 0,
|
|
245
|
-
accountId: void 0
|
|
246
|
-
}),
|
|
247
|
-
onSome: (value) => ({
|
|
248
|
-
exp: value.exp,
|
|
249
|
-
email: value.email,
|
|
250
|
-
accountId: value["https://api.openai.com/auth"]?.chatgpt_account_id
|
|
251
|
-
})
|
|
252
|
-
});
|
|
253
|
-
};
|
|
254
|
-
//#endregion
|
|
255
|
-
//#region ../../packages/api/src/machine.ts
|
|
256
|
-
/**
|
|
257
|
-
* What a machine key can do: read the one bundle bound to it, and nothing else. The
|
|
258
|
-
* bundle is never named: the key is bound to it on the server.
|
|
259
|
-
*/
|
|
260
|
-
const Machine = ActionGroup.make({
|
|
261
|
-
name: "machine",
|
|
262
|
-
schemaError
|
|
263
|
-
}, Action.make("bundle", {
|
|
264
|
-
access: "read",
|
|
265
|
-
description: "Fetch the typed credentials of the bundle bound to the calling key.",
|
|
266
|
-
input: Schema.Struct({ clientId: ClientId }),
|
|
267
|
-
success: Bundle,
|
|
268
|
-
errors: [NotFound],
|
|
269
|
-
mcp: false
|
|
270
|
-
}), Action.make("codexLogin", {
|
|
271
|
-
access: "read",
|
|
272
|
-
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.",
|
|
273
|
-
input: Schema.Struct({ clientId: ClientId }),
|
|
274
|
-
success: CodexLogin,
|
|
275
|
-
errors: [
|
|
276
|
-
NotFound,
|
|
277
|
-
NeedsSignIn,
|
|
278
|
-
UpstreamUnavailable
|
|
279
|
-
],
|
|
280
|
-
mcp: false
|
|
281
|
-
}));
|
|
282
|
-
/**
|
|
283
|
-
* Codex treats a 401 as permanent and stops retrying; every other failure is transient.
|
|
284
|
-
* The codes are the ones Codex classifies: `refresh_token_invalidated` reads as revoked.
|
|
285
|
-
*/
|
|
286
|
-
var CodexRefreshRejected = class extends Schema.TaggedError()("CodexRefreshRejected", { error: Schema.Struct({
|
|
287
|
-
code: Schema.String,
|
|
288
|
-
message: Schema.String
|
|
289
|
-
}) }, { httpApiStatus: 401 }) {};
|
|
290
|
-
var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUnavailable", { error: Schema.Struct({
|
|
291
|
-
code: Schema.String,
|
|
292
|
-
message: Schema.String
|
|
293
|
-
}) }, { httpApiStatus: 503 }) {};
|
|
294
|
-
/**
|
|
295
|
-
* Codex's own refresh request, the one request whose shape is not ours. The refresh-token
|
|
296
|
-
* field carries `<clientId>:<key>`, so this group authenticates in its handler instead of
|
|
297
|
-
* through bearer middleware. The answer leaves `refresh_token` out, so Codex keeps the
|
|
298
|
-
* composite it has.
|
|
299
|
-
*/
|
|
300
|
-
const Codex = ActionGroup.make({ name: "codex" }, Action.make("refresh", {
|
|
301
|
-
access: "read",
|
|
302
|
-
description: "Answer Codex's token refresh with the bundle's current ChatGPT tokens.",
|
|
303
|
-
input: Schema.Struct({
|
|
304
|
-
grant_type: Schema.Literal("refresh_token"),
|
|
305
|
-
refresh_token: Schema.String,
|
|
306
|
-
client_id: Schema.optionalKey(Schema.String),
|
|
307
|
-
scope: Schema.optionalKey(Schema.String)
|
|
308
|
-
}),
|
|
309
|
-
success: Schema.Struct({
|
|
310
|
-
access_token: Schema.String,
|
|
311
|
-
id_token: Schema.String
|
|
312
|
-
}),
|
|
313
|
-
errors: [CodexRefreshRejected, CodexRefreshUnavailable],
|
|
314
|
-
mcp: false
|
|
315
|
-
}));
|
|
316
|
-
/** `<clientId>:<key>`. Client ids exclude `:`, so the key is the rest. */
|
|
317
|
-
const formatCodexRefreshToken = (clientId, key) => `${clientId}:${key}`;
|
|
318
|
-
const parseCodexRefreshToken = (value) => {
|
|
319
|
-
const colon = value.indexOf(":");
|
|
320
|
-
return colon < 1 || colon === value.length - 1 ? void 0 : {
|
|
321
|
-
clientId: value.slice(0, colon),
|
|
322
|
-
key: value.slice(colon + 1)
|
|
323
|
-
};
|
|
324
|
-
};
|
|
325
|
-
const MachineHttp = ActionHttp.make({
|
|
326
|
-
apiPath: "/v1",
|
|
327
|
-
errors: authenticationErrors
|
|
328
|
-
}, Machine);
|
|
329
|
-
ActionHttp.make({ apiPath: "/v1" }, Codex);
|
|
330
|
-
Object.keys(ActionGroup.contracts(Machine, Codex)).map((key) => `/v1/${key.replace(".", "/")}`);
|
|
331
|
-
//#endregion
|
|
332
266
|
//#region src/service.ts
|
|
333
267
|
/** The service answered and said no. Retrying the same request will not help. */
|
|
334
268
|
var Refused = class extends Error {};
|
|
335
269
|
/** No answer worth acting on. Whatever the machine already holds stays in place. */
|
|
336
270
|
var Unreachable = class extends Error {};
|
|
337
|
-
|
|
338
|
-
* The deadline covers the whole request, the body included. It matters most to `token`:
|
|
339
|
-
* pi gives the command about ten seconds, and a hang would cost it a token it already holds.
|
|
340
|
-
*/
|
|
341
|
-
const connect = (config, deadlineMs) => ActionHttpClient.promise(MachineHttp, {
|
|
271
|
+
const connect = (config) => ActionHttp.fetchClient(MachineHttp, {
|
|
342
272
|
baseUrl: config.service,
|
|
343
|
-
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(config.key))
|
|
344
|
-
fetch: (input, init) => {
|
|
345
|
-
const deadline = AbortSignal.timeout(deadlineMs);
|
|
346
|
-
const signal = init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline;
|
|
347
|
-
return fetch(input, {
|
|
348
|
-
...init,
|
|
349
|
-
signal
|
|
350
|
-
});
|
|
351
|
-
}
|
|
273
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(config.key))
|
|
352
274
|
});
|
|
353
275
|
/**
|
|
354
|
-
* The key's own refusals
|
|
355
|
-
* outage: transport, upstream, a broken answer, or a
|
|
276
|
+
* The key's own refusals, input the service will not take, and the declared answers are
|
|
277
|
+
* refusals. Whatever is left is an outage: transport, upstream, a broken answer, or a
|
|
278
|
+
* service that never answers within its deadline.
|
|
356
279
|
*/
|
|
357
280
|
const classify = (error) => {
|
|
358
|
-
if (error instanceof
|
|
359
|
-
if (error instanceof
|
|
360
|
-
if (error instanceof
|
|
281
|
+
if (error instanceof Action.Unauthenticated) return new Refused("The machine key was refused. It may have been revoked or expired, or not be granted here.");
|
|
282
|
+
if (error instanceof Action.Forbidden) return new Refused("The machine key does not carry the machine scope. A new grant takes up to a minute to apply.");
|
|
283
|
+
if (error instanceof Action.InvalidInput || error instanceof NotFound || error instanceof NeedsSignIn) return new Refused(error.message);
|
|
361
284
|
return new Unreachable(error instanceof Error ? error.message : String(error));
|
|
362
285
|
};
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
286
|
+
/**
|
|
287
|
+
* One call, within its deadline. The deadline covers the whole request, the body included:
|
|
288
|
+
* the call is interrupted, which aborts its `fetch`. It matters most to `token`: pi gives
|
|
289
|
+
* the command about ten seconds, and a hang would cost it a token it already holds.
|
|
290
|
+
*/
|
|
291
|
+
const within = (call, deadlineMs) => Effect.runPromise(Effect.timeout(call, deadlineMs)).catch((error) => Promise.reject(classify(error)));
|
|
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);
|
|
366
301
|
//#endregion
|
|
367
302
|
//#region src/place/claude.ts
|
|
368
303
|
const settingsFile = (home) => join(home, ".claude/settings.json");
|
|
@@ -404,8 +339,8 @@ const removeClaude = (home) => {
|
|
|
404
339
|
const authFile$1 = (home) => join(home, ".codex/auth.json");
|
|
405
340
|
/**
|
|
406
341
|
* The file's tokens, when its refresh-token field is a composite: then clankercreds wrote
|
|
407
|
-
* it, for whichever identity the composite names. A login anyone else wrote
|
|
408
|
-
* 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.
|
|
409
344
|
*/
|
|
410
345
|
const ownTokens = (auth) => {
|
|
411
346
|
const tokens = auth["tokens"];
|
|
@@ -421,19 +356,20 @@ const ownTokens = (auth) => {
|
|
|
421
356
|
/**
|
|
422
357
|
* The one writer of the file, and it always writes the whole login: the three tokens
|
|
423
358
|
* belong together. The refresh-token field never holds OpenAI's token: it names this
|
|
424
|
-
* machine to the service, which Codex calls when
|
|
425
|
-
* 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.
|
|
426
362
|
*/
|
|
427
363
|
const placeCodex = (home, login, identity) => {
|
|
428
364
|
const existing = readJsonObject(authFile$1(home));
|
|
429
|
-
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`);
|
|
430
366
|
writeJson(authFile$1(home), {
|
|
431
367
|
auth_mode: "chatgpt",
|
|
432
368
|
OPENAI_API_KEY: null,
|
|
433
369
|
tokens: {
|
|
434
370
|
id_token: login.idToken,
|
|
435
371
|
access_token: login.accessToken,
|
|
436
|
-
refresh_token: formatCodexRefreshToken(identity.clientId, identity.key),
|
|
372
|
+
refresh_token: formatCodexRefreshToken(identity.clientId, login.credential, identity.key),
|
|
437
373
|
account_id: login.accountId
|
|
438
374
|
},
|
|
439
375
|
last_refresh: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -544,8 +480,21 @@ const clientId = (home, machineIdFile = clankerboxMachineId) => {
|
|
|
544
480
|
writeAtomic(file, `${generated}\n`);
|
|
545
481
|
return generated;
|
|
546
482
|
};
|
|
547
|
-
/**
|
|
548
|
-
|
|
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));
|
|
549
498
|
//#endregion
|
|
550
499
|
//#region src/place/pi.ts
|
|
551
500
|
const modelsFile = (home) => join(home, ".pi/agent/models.json");
|
|
@@ -563,10 +512,11 @@ const refuseStoredLogin = (home) => {
|
|
|
563
512
|
};
|
|
564
513
|
/**
|
|
565
514
|
* Only the key is ours; every other provider setting in the file is kept. The token goes
|
|
566
|
-
* first, so whatever stops the key, `token` never answers for a
|
|
515
|
+
* first, with its credential, so whatever stops the key, `token` never answers for a
|
|
516
|
+
* credential the config no longer names.
|
|
567
517
|
*/
|
|
568
|
-
const placePi = (home,
|
|
569
|
-
|
|
518
|
+
const placePi = (home, held) => {
|
|
519
|
+
writeHeldToken(home, held);
|
|
570
520
|
refuseStoredLogin(home);
|
|
571
521
|
const models = readJsonObject(modelsFile(home));
|
|
572
522
|
const providers = objectAt(models, "providers", modelsFile(home));
|
|
@@ -605,7 +555,7 @@ const removeEntry = (home) => {
|
|
|
605
555
|
* is left alone when it is someone else's.
|
|
606
556
|
*/
|
|
607
557
|
const removePi = (home) => {
|
|
608
|
-
|
|
558
|
+
removeHeldToken(home);
|
|
609
559
|
return removeEntry(home);
|
|
610
560
|
};
|
|
611
561
|
//#endregion
|
|
@@ -628,11 +578,11 @@ const placements = {
|
|
|
628
578
|
remove: (config) => removeCodex(config.home)
|
|
629
579
|
},
|
|
630
580
|
pi: {
|
|
631
|
-
place: (config, _identity, [credential]) => placePi(config.home, credential
|
|
581
|
+
place: (config, _identity, [credential]) => placePi(config.home, credential),
|
|
632
582
|
remove: (config) => removePi(config.home)
|
|
633
583
|
}
|
|
634
584
|
};
|
|
635
|
-
/** What the
|
|
585
|
+
/** What the delivery carries of one type is placed; a type it does not carry is undone. */
|
|
636
586
|
const settle = (config, identity, known, type) => {
|
|
637
587
|
const credentials = known.filter((credential) => credential.type === type);
|
|
638
588
|
if (credentials.length === 0) return placements[type].remove(config) ? "removed" : void 0;
|
|
@@ -640,22 +590,48 @@ const settle = (config, identity, known, type) => {
|
|
|
640
590
|
return "placed";
|
|
641
591
|
};
|
|
642
592
|
/**
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
|
|
646
|
-
|
|
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.
|
|
647
622
|
*/
|
|
648
623
|
const sync = async (config) => {
|
|
649
624
|
const identity = {
|
|
650
625
|
clientId: clientId(config.home),
|
|
651
626
|
key: config.key
|
|
652
627
|
};
|
|
653
|
-
const
|
|
654
|
-
const known =
|
|
628
|
+
const delivery = await fetchDelivery(config, identity.clientId);
|
|
629
|
+
const known = delivery.credentials.filter((credential) => isKnownType(credential.type));
|
|
630
|
+
refuseShared(known);
|
|
655
631
|
const report = {
|
|
656
632
|
placed: new Array(),
|
|
657
633
|
removed: new Array(),
|
|
658
|
-
skipped:
|
|
634
|
+
skipped: delivery.credentials.flatMap(({ type }) => isKnownType(type) ? [] : [type]),
|
|
659
635
|
failed: new Array()
|
|
660
636
|
};
|
|
661
637
|
for (const type of knownTypes) try {
|
|
@@ -677,20 +653,24 @@ const margin = 3e5;
|
|
|
677
653
|
const remaining = (jwt) => (claimsOf(jwt).exp ?? 0) * 1e3 - Date.now();
|
|
678
654
|
/**
|
|
679
655
|
* pi runs this on every model call, so the common path is one small file read. The token is
|
|
680
|
-
* the client's own copy, placed by sync, so no other tool's file
|
|
681
|
-
*
|
|
682
|
-
* 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.
|
|
683
659
|
*/
|
|
684
660
|
const accessToken = async (config) => {
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
if (
|
|
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;
|
|
688
664
|
try {
|
|
689
|
-
const { accessToken: fresh } = await fetchCodexLogin(config, clientId(config.home));
|
|
690
|
-
|
|
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
|
+
});
|
|
691
671
|
return fresh;
|
|
692
672
|
} catch (error) {
|
|
693
|
-
if (error instanceof Unreachable &&
|
|
673
|
+
if (error instanceof Unreachable && remaining(held.accessToken) > 0) return held.accessToken;
|
|
694
674
|
throw error;
|
|
695
675
|
}
|
|
696
676
|
};
|
|
@@ -698,8 +678,9 @@ const accessToken = async (config) => {
|
|
|
698
678
|
//#region src/main.ts
|
|
699
679
|
const usage = `Usage: clankercreds <command>
|
|
700
680
|
|
|
701
|
-
sync Fetch
|
|
681
|
+
sync Fetch the credentials the config names and place each where its tool expects it.
|
|
702
682
|
token Print the current ChatGPT access token, for pi.
|
|
683
|
+
admin Administer the service over its admin port: \`clankercreds admin --help\`.
|
|
703
684
|
`;
|
|
704
685
|
const runSync = async () => {
|
|
705
686
|
const report = await sync(loadConfig());
|
|
@@ -722,6 +703,10 @@ const commandFor = (name) => {
|
|
|
722
703
|
}
|
|
723
704
|
};
|
|
724
705
|
const main = async () => {
|
|
706
|
+
if (process.argv[2] === "admin") {
|
|
707
|
+
(await import("./admin-CXDLXpaH.mjs")).run(process.argv.slice(2));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
725
710
|
const command = commandFor(process.argv[2]);
|
|
726
711
|
if (command === void 0) {
|
|
727
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.
|
|
4
|
-
"description": "The machine client
|
|
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
|
-
"@
|
|
23
|
-
"effect": "
|
|
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.
|
|
29
|
-
"@types/node": "26.6.
|
|
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.
|
|
32
|
-
"vite-plus": "1.
|
|
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"
|