@gjermundgaraba/clankercreds 0.2.0 → 0.4.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-DC0cTmmT.mjs +342 -0
- package/dist/main.mjs +282 -249
- package/dist/shared-C56uw1xx.mjs +127 -0
- package/package.json +11 -9
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { a as LoginKind, c as NotFound, f as scopes, i as Host, l as SecretKind, m as ProviderUnavailable, n as Conflict, o as Name, p as CurrentPrincipal, s as NeedsSignIn, u as UpstreamUnavailable } from "./shared-C56uw1xx.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
|
+
/** A stored secret's state. The value itself is never read back out. */
|
|
16
|
+
const SecretStatus = Schema.Struct({
|
|
17
|
+
name: Name,
|
|
18
|
+
kind: SecretKind,
|
|
19
|
+
/** The host a `gh` token is for; null for every other kind. */
|
|
20
|
+
host: Schema.NullOr(Schema.String),
|
|
21
|
+
expiresAt: Schema.NullOr(Timestamp),
|
|
22
|
+
createdAt: Timestamp,
|
|
23
|
+
updatedAt: Timestamp
|
|
24
|
+
});
|
|
25
|
+
/** A login's state. It exists from the first completed sign-in on. */
|
|
26
|
+
const LoginStatus = Schema.Struct({
|
|
27
|
+
name: Name,
|
|
28
|
+
kind: LoginKind,
|
|
29
|
+
needsSignIn: Schema.Boolean,
|
|
30
|
+
email: Schema.NullOr(Schema.String),
|
|
31
|
+
/** The ChatGPT account a ChatGPT login is for; null for every other kind. */
|
|
32
|
+
accountId: Schema.NullOr(Schema.String),
|
|
33
|
+
accessExpiresAt: Timestamp,
|
|
34
|
+
lastRefreshAt: Timestamp,
|
|
35
|
+
lastError: Schema.NullOr(Schema.String),
|
|
36
|
+
createdAt: Timestamp,
|
|
37
|
+
updatedAt: Timestamp
|
|
38
|
+
});
|
|
39
|
+
const CredentialStatus = Schema.Union([SecretStatus, LoginStatus]);
|
|
40
|
+
/** The clankerauth id of a machine key. */
|
|
41
|
+
const KeyId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
|
|
42
|
+
/**
|
|
43
|
+
* A machine key and the credentials it may read. A machine names which of them it syncs; a
|
|
44
|
+
* replacement key is granted the same ones and overlaps the old key until that is removed.
|
|
45
|
+
* A key may be granted several credentials of one kind: several accounts of a provider.
|
|
46
|
+
*/
|
|
47
|
+
const KeyInfo = Schema.Struct({
|
|
48
|
+
id: KeyId,
|
|
49
|
+
credentials: Schema.Array(Name)
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* A pending Claude sign-in: approve at `authorizeUrl`, then hand the code the callback page
|
|
53
|
+
* shows to `completeClaudeSignIn`. The code is useless without the verifier the service holds.
|
|
54
|
+
*/
|
|
55
|
+
const ClaudeSignIn = Schema.Struct({
|
|
56
|
+
signInId: Schema.String,
|
|
57
|
+
credential: Name,
|
|
58
|
+
authorizeUrl: Schema.String,
|
|
59
|
+
expiresAt: Timestamp
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* A Claude sign-in's code was not taken: it is not this sign-in's, or Anthropic refused it.
|
|
63
|
+
* The sign-in stays open until it expires, for the code again or a new one from its
|
|
64
|
+
* `authorizeUrl`. An approval that left out a scope the token needs ends it: start again.
|
|
65
|
+
*/
|
|
66
|
+
var SignInRefused = class extends Schema.TaggedError()("SignInRefused", { message: Schema.String }, { httpApiStatus: 422 }) {};
|
|
67
|
+
/**
|
|
68
|
+
* A login's sign-in: open `verificationUrl` and approve, entering `userCode` where the
|
|
69
|
+
* provider asks for one. Cursor's link carries everything and has no code.
|
|
70
|
+
*/
|
|
71
|
+
const SignInState = Schema.Struct({
|
|
72
|
+
signInId: Schema.String,
|
|
73
|
+
credential: Name,
|
|
74
|
+
kind: LoginKind,
|
|
75
|
+
status: Schema.Literals([
|
|
76
|
+
"pending",
|
|
77
|
+
"complete",
|
|
78
|
+
"failed",
|
|
79
|
+
"expired"
|
|
80
|
+
]),
|
|
81
|
+
userCode: Schema.NullOr(Schema.String),
|
|
82
|
+
verificationUrl: Schema.String,
|
|
83
|
+
expiresAt: Timestamp,
|
|
84
|
+
message: Schema.optionalKey(Schema.String)
|
|
85
|
+
});
|
|
86
|
+
/** Credentials by name: rows outlive what they name, and stay findable. */
|
|
87
|
+
const AuditEntry = Schema.Struct({
|
|
88
|
+
id: Schema.Int,
|
|
89
|
+
at: Timestamp,
|
|
90
|
+
actorKind: Schema.Literals([
|
|
91
|
+
"machine",
|
|
92
|
+
"admin",
|
|
93
|
+
"system"
|
|
94
|
+
]),
|
|
95
|
+
keyId: Schema.NullOr(Schema.String),
|
|
96
|
+
clientId: Schema.NullOr(Schema.String),
|
|
97
|
+
action: Schema.String,
|
|
98
|
+
credential: Schema.NullOr(Schema.String),
|
|
99
|
+
remoteAddr: Schema.NullOr(Schema.String),
|
|
100
|
+
detail: Schema.Record(Schema.String, Schema.Json)
|
|
101
|
+
});
|
|
102
|
+
/** Every filter is optional; the page size defaults on the service. */
|
|
103
|
+
const AuditQuery = Schema.Struct({
|
|
104
|
+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({
|
|
105
|
+
minimum: 1,
|
|
106
|
+
maximum: 500
|
|
107
|
+
}))),
|
|
108
|
+
before: Schema.optionalKey(Schema.Int),
|
|
109
|
+
action: Schema.optionalKey(Schema.String),
|
|
110
|
+
credential: Schema.optionalKey(Name),
|
|
111
|
+
keyId: Schema.optionalKey(Schema.String),
|
|
112
|
+
clientId: Schema.optionalKey(Schema.String)
|
|
113
|
+
});
|
|
114
|
+
/**
|
|
115
|
+
* A read whose code or link signs an account in: exported so the server's authorization
|
|
116
|
+
* rule can name it.
|
|
117
|
+
*/
|
|
118
|
+
const GetSignIn = Action.make("getSignIn", {
|
|
119
|
+
readOnly: true,
|
|
120
|
+
caller: CurrentPrincipal,
|
|
121
|
+
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.",
|
|
122
|
+
input: Schema.Struct({ signInId: Schema.String }),
|
|
123
|
+
success: SignInState,
|
|
124
|
+
error: NotFound
|
|
125
|
+
});
|
|
126
|
+
/** Everything an operator does; which scope each needs is the server's authorization rule. */
|
|
127
|
+
const Admin = [
|
|
128
|
+
Action.make("listCredentials", {
|
|
129
|
+
readOnly: true,
|
|
130
|
+
caller: CurrentPrincipal,
|
|
131
|
+
description: "Every credential with its state: expiry, last refresh, last error and whether a login needs a new sign-in. Values are never returned.",
|
|
132
|
+
success: Schema.Struct({ credentials: Schema.Array(CredentialStatus) })
|
|
133
|
+
}),
|
|
134
|
+
Action.make("putCredential", {
|
|
135
|
+
readOnly: false,
|
|
136
|
+
caller: CurrentPrincipal,
|
|
137
|
+
description: "Store a secret under a name, or replace its value and expiry: a `gh` or `claude` token a machine places, or a `token`, any other service's API key, which a service such as clankerusage reads. 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. Keys granted it receive it on their next sync.",
|
|
138
|
+
input: Schema.Struct({
|
|
139
|
+
name: Name,
|
|
140
|
+
kind: SecretKind,
|
|
141
|
+
value: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(16384)),
|
|
142
|
+
host: Schema.optionalKey(Host),
|
|
143
|
+
/** Any date string JavaScript parses; a typo is refused, so alerting never loses it. Left out, it does not expire. */
|
|
144
|
+
expiresAt: Schema.optionalKey(Schema.DateFromString)
|
|
145
|
+
}).check(Schema.makeFilter(({ kind, host }) => kind === "gh" || host === void 0 ? void 0 : {
|
|
146
|
+
path: ["host"],
|
|
147
|
+
issue: "Only a gh credential has a host."
|
|
148
|
+
})),
|
|
149
|
+
success: SecretStatus,
|
|
150
|
+
error: Conflict
|
|
151
|
+
}),
|
|
152
|
+
Action.make("removeCredential", {
|
|
153
|
+
readOnly: false,
|
|
154
|
+
caller: CurrentPrincipal,
|
|
155
|
+
description: "Delete a credential. Refused while a key is still granted it.",
|
|
156
|
+
input: Schema.Struct({ name: Name }),
|
|
157
|
+
success: Schema.Void,
|
|
158
|
+
error: [NotFound, Conflict]
|
|
159
|
+
}),
|
|
160
|
+
Action.make("startSignIn", {
|
|
161
|
+
readOnly: false,
|
|
162
|
+
caller: CurrentPrincipal,
|
|
163
|
+
description: "Start a sign-in for a ChatGPT, Grok or Cursor login; the credential is created, or its login replaced, when the sign-in completes. Open the returned verificationUrl and approve, entering userCode where one is given, then poll getSignIn.",
|
|
164
|
+
input: Schema.Struct({
|
|
165
|
+
name: Name,
|
|
166
|
+
kind: LoginKind
|
|
167
|
+
}),
|
|
168
|
+
success: SignInState,
|
|
169
|
+
error: [Conflict, UpstreamUnavailable]
|
|
170
|
+
}),
|
|
171
|
+
GetSignIn,
|
|
172
|
+
Action.make("refreshCredential", {
|
|
173
|
+
readOnly: false,
|
|
174
|
+
caller: CurrentPrincipal,
|
|
175
|
+
description: "Refresh one login now instead of waiting for its schedule.",
|
|
176
|
+
input: Schema.Struct({ name: Name }),
|
|
177
|
+
success: LoginStatus,
|
|
178
|
+
error: [
|
|
179
|
+
NotFound,
|
|
180
|
+
Conflict,
|
|
181
|
+
NeedsSignIn,
|
|
182
|
+
UpstreamUnavailable
|
|
183
|
+
]
|
|
184
|
+
}),
|
|
185
|
+
Action.make("startClaudeSignIn", {
|
|
186
|
+
readOnly: false,
|
|
187
|
+
caller: CurrentPrincipal,
|
|
188
|
+
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.",
|
|
189
|
+
input: Schema.Struct({ name: Name }),
|
|
190
|
+
success: ClaudeSignIn,
|
|
191
|
+
error: Conflict
|
|
192
|
+
}),
|
|
193
|
+
Action.make("completeClaudeSignIn", {
|
|
194
|
+
readOnly: false,
|
|
195
|
+
caller: CurrentPrincipal,
|
|
196
|
+
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.",
|
|
197
|
+
input: Schema.Struct({
|
|
198
|
+
signInId: Schema.String,
|
|
199
|
+
/** As the callback page shows it: `<code>#<state>`. */
|
|
200
|
+
code: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4096))
|
|
201
|
+
}),
|
|
202
|
+
success: SecretStatus,
|
|
203
|
+
error: [
|
|
204
|
+
NotFound,
|
|
205
|
+
Conflict,
|
|
206
|
+
SignInRefused,
|
|
207
|
+
UpstreamUnavailable
|
|
208
|
+
]
|
|
209
|
+
}),
|
|
210
|
+
Action.make("listKeys", {
|
|
211
|
+
readOnly: true,
|
|
212
|
+
caller: CurrentPrincipal,
|
|
213
|
+
description: "Every machine key with the credentials it is granted.",
|
|
214
|
+
success: Schema.Struct({ keys: Schema.Array(KeyInfo) })
|
|
215
|
+
}),
|
|
216
|
+
Action.make("putKey", {
|
|
217
|
+
readOnly: false,
|
|
218
|
+
caller: CurrentPrincipal,
|
|
219
|
+
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.",
|
|
220
|
+
input: Schema.Struct({
|
|
221
|
+
id: KeyId,
|
|
222
|
+
credentials: Schema.Array(Name)
|
|
223
|
+
}),
|
|
224
|
+
success: KeyInfo,
|
|
225
|
+
error: NotFound
|
|
226
|
+
}),
|
|
227
|
+
Action.make("listAudit", {
|
|
228
|
+
readOnly: true,
|
|
229
|
+
caller: CurrentPrincipal,
|
|
230
|
+
description: "The audit log, newest first. Filter by action, credential, key or client id; page with `before`.",
|
|
231
|
+
input: AuditQuery,
|
|
232
|
+
success: Schema.Struct({ entries: Schema.Array(AuditEntry) })
|
|
233
|
+
})
|
|
234
|
+
];
|
|
235
|
+
/**
|
|
236
|
+
* An operator's bearer token on the private port: an access token or API key of the admin
|
|
237
|
+
* resource, verified by its own provider.
|
|
238
|
+
*/
|
|
239
|
+
const AdminLogin = Authentication.make("clankercreds.admin", CurrentPrincipal, { error: ProviderUnavailable });
|
|
240
|
+
const AdminHttp = ActionHttp.make(Admin, {
|
|
241
|
+
prefix: "/v1",
|
|
242
|
+
authentication: AdminLogin
|
|
243
|
+
});
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region package.json
|
|
246
|
+
var version = "0.4.0";
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/admin.ts
|
|
249
|
+
/** A refusal of this command's own, printed as its message on stderr, exiting 1. */
|
|
250
|
+
const refused = (message) => new CliError.UserError({
|
|
251
|
+
cause: message,
|
|
252
|
+
userMessage: message
|
|
253
|
+
});
|
|
254
|
+
/** What an operator signs in to clankerauth for: every admin action. */
|
|
255
|
+
const operatorScopes = `${scopes.read} ${scopes.admin}`;
|
|
256
|
+
/**
|
|
257
|
+
* The admin port, and the resource its tokens are for: the port's origin root, unless the
|
|
258
|
+
* service names its resource otherwise.
|
|
259
|
+
*/
|
|
260
|
+
const admin = Command.make("admin").pipe(Command.withSharedFlags({
|
|
261
|
+
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"))),
|
|
262
|
+
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)
|
|
263
|
+
}), 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.`));
|
|
264
|
+
/** Run `clankerauth token` for `resource`: the CLI that holds the operator's sign-in. */
|
|
265
|
+
const clankerauthToken = (resource) => Effect.callback((resume) => {
|
|
266
|
+
execFile("clankerauth", [
|
|
267
|
+
"token",
|
|
268
|
+
"--resource",
|
|
269
|
+
resource
|
|
270
|
+
], (error, stdout, stderr) => {
|
|
271
|
+
if (error === null) {
|
|
272
|
+
resume(Effect.succeed(stdout.trim()));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
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}`)));
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
/**
|
|
279
|
+
* The client every admin command calls through: the admin origin and a bearer token,
|
|
280
|
+
* CLANKERCREDS_ADMIN_TOKEN or a current one from `clankerauth token`.
|
|
281
|
+
*/
|
|
282
|
+
const operator = Effect.gen(function* () {
|
|
283
|
+
const { url, resource } = yield* admin;
|
|
284
|
+
const origin = yield* Effect.try({
|
|
285
|
+
try: () => new URL(url).origin,
|
|
286
|
+
catch: () => refused(`The admin URL must be a URL, such as https://clankercreds.example: ${url}`)
|
|
287
|
+
});
|
|
288
|
+
const given = yield* Config.option(Config.Redacted("CLANKERCREDS_ADMIN_TOKEN")).pipe(Effect.mapError(() => refused("CLANKERCREDS_ADMIN_TOKEN cannot be read")));
|
|
289
|
+
const token = yield* Option.match(given, {
|
|
290
|
+
onSome: (secret) => Effect.succeed(Redacted.value(secret)),
|
|
291
|
+
onNone: () => clankerauthToken(Option.getOrElse(resource, () => `${origin}/`))
|
|
292
|
+
});
|
|
293
|
+
return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(origin), HttpClientRequest.bearerToken(token)));
|
|
294
|
+
});
|
|
295
|
+
/**
|
|
296
|
+
* The secret's value, from stdin: a flag would leave it in the process list and the shell's
|
|
297
|
+
* history. Line endings a pipe adds, such as `op read`'s, are not part of it.
|
|
298
|
+
*/
|
|
299
|
+
const valueFromStdin = Effect.gen(function* () {
|
|
300
|
+
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.");
|
|
301
|
+
const value = (yield* Effect.promise(() => text(process.stdin))).replace(/[\r\n]+$/, "");
|
|
302
|
+
if (value === "") return yield* refused("The value on stdin is empty.");
|
|
303
|
+
return value;
|
|
304
|
+
});
|
|
305
|
+
const json = (value) => Console.log(JSON.stringify(value, null, 2));
|
|
306
|
+
/** `putCredential`, its value read from stdin and every other field a flag. */
|
|
307
|
+
const putCredential = Command.make("put-credential", {
|
|
308
|
+
name: Flag.String("name").pipe(Flag.withDescription("The credential's name")),
|
|
309
|
+
kind: Flag.Literals("kind", SecretKind.literals).pipe(Flag.withDescription("What the secret is: a gh or claude token a machine places, or a token, any other service's API key")),
|
|
310
|
+
host: Flag.String("host").pipe(Flag.withDescription("A gh token's host; github.com when left out"), Flag.optional),
|
|
311
|
+
expiresAt: Flag.String("expires-at").pipe(Flag.withDescription("When the secret expires, any date string; it never does when left out"), Flag.optional)
|
|
312
|
+
}, ({ name, kind, host, expiresAt }) => Effect.gen(function* () {
|
|
313
|
+
const value = yield* valueFromStdin;
|
|
314
|
+
const expiry = yield* Option.match(expiresAt, {
|
|
315
|
+
onNone: () => Effect.succeedNone,
|
|
316
|
+
onSome: (given) => {
|
|
317
|
+
const date = new Date(given);
|
|
318
|
+
return Number.isNaN(date.getTime()) ? Effect.fail(refused(`--expires-at is not a date: ${given}`)) : Effect.succeedSome(date);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
const status = yield* (yield* ActionHttp.client(AdminHttp)).putCredential({
|
|
322
|
+
name,
|
|
323
|
+
kind,
|
|
324
|
+
value,
|
|
325
|
+
...Option.match(host, {
|
|
326
|
+
onNone: () => ({}),
|
|
327
|
+
onSome: (named) => ({ host: named })
|
|
328
|
+
}),
|
|
329
|
+
...Option.match(expiry, {
|
|
330
|
+
onNone: () => ({}),
|
|
331
|
+
onSome: (date) => ({ expiresAt: date })
|
|
332
|
+
})
|
|
333
|
+
}).pipe(Effect.mapError((error) => refused(`${error._tag}: ${error.message}`)));
|
|
334
|
+
yield* json(status);
|
|
335
|
+
})).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));
|
|
336
|
+
/** Every other admin action, as a command derived from its contract. */
|
|
337
|
+
const derived = Admin.filter((action) => action.name !== "putCredential").map((action) => ActionCli.remoteCommand(AdminHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, operator)));
|
|
338
|
+
const cli = Command.make("clankercreds").pipe(Command.withSubcommands([admin.pipe(Command.withSubcommands([putCredential, ...derived]))]));
|
|
339
|
+
/** Run `clankercreds admin …`; `args` are the process's, after the script. */
|
|
340
|
+
const run = (args) => Command.runWith(cli, { version })(args).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeServices.layer), ActionCli.logToStderr, NodeRuntime.runMain);
|
|
341
|
+
//#endregion
|
|
342
|
+
export { run };
|
package/dist/main.mjs
CHANGED
|
@@ -1,159 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { c as NotFound, d as claimsOf, m as ProviderUnavailable, o as Name, p as CurrentPrincipal, r as CredentialKind, s as NeedsSignIn, t as ClientId, u as UpstreamUnavailable } from "./shared-C56uw1xx.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 { 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/
|
|
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
|
-
};
|
|
13
|
+
//#region ../../packages/api/src/credentials.ts
|
|
26
14
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
15
|
+
* The credential an entry was delivered from, as it was asked for by name, and when what it
|
|
16
|
+
* carries expires, as ISO: a secret's as the operator entered it, a login's access token's
|
|
17
|
+
* own. Null never expires. A login delivered past it could not be renewed in time, as when its
|
|
18
|
+
* provider is away: sync again later. Whether it needs a new sign-in is the operator's to see.
|
|
29
19
|
*/
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
20
|
+
const fromCredential = {
|
|
21
|
+
credential: Name,
|
|
22
|
+
expiresAt: Schema.NullOr(Schema.String)
|
|
63
23
|
};
|
|
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
|
|
145
24
|
const GhCredential = Schema.Struct({
|
|
146
25
|
type: Schema.Literal("gh"),
|
|
26
|
+
...fromCredential,
|
|
147
27
|
host: Schema.String,
|
|
148
28
|
token: Schema.String
|
|
149
29
|
});
|
|
150
30
|
const ClaudeCredential = Schema.Struct({
|
|
151
31
|
type: Schema.Literal("claude"),
|
|
32
|
+
...fromCredential,
|
|
152
33
|
token: Schema.String
|
|
153
34
|
});
|
|
154
35
|
/**
|
|
155
36
|
* 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.
|
|
37
|
+
* login. No refresh token: the client writes `<clientId>:<credential>:<key>` in its place.
|
|
157
38
|
*/
|
|
158
39
|
const CodexLogin = Schema.Struct({
|
|
159
40
|
accessToken: Schema.String,
|
|
@@ -162,6 +43,7 @@ const CodexLogin = Schema.Struct({
|
|
|
162
43
|
});
|
|
163
44
|
const CodexCredential = Schema.Struct({
|
|
164
45
|
type: Schema.Literal("codex"),
|
|
46
|
+
...fromCredential,
|
|
165
47
|
...CodexLogin.fields
|
|
166
48
|
});
|
|
167
49
|
/**
|
|
@@ -170,24 +52,38 @@ const CodexCredential = Schema.Struct({
|
|
|
170
52
|
*/
|
|
171
53
|
const PiCredential = Schema.Struct({
|
|
172
54
|
type: Schema.Literal("pi"),
|
|
55
|
+
...fromCredential,
|
|
173
56
|
accessToken: Schema.String
|
|
174
57
|
});
|
|
175
58
|
/**
|
|
59
|
+
* A credential no machine places: an API key, or a Grok or Cursor login's access token, for
|
|
60
|
+
* a service such as clankerusage that knows which provider each name is for. A machine
|
|
61
|
+
* whose config names one refuses the sync.
|
|
62
|
+
*/
|
|
63
|
+
const TokenCredential = Schema.Struct({
|
|
64
|
+
type: Schema.Literal("token"),
|
|
65
|
+
...fromCredential,
|
|
66
|
+
token: Schema.String
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
176
69
|
* The service sends typed credentials, never file contents or paths, so it cannot write
|
|
177
70
|
* 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.
|
|
71
|
+
* tool, or a placement fix, needs a client release, which new machines install. Each entry
|
|
72
|
+
* names the credential it came from: a ChatGPT login arrives as a `codex` and a `pi` entry
|
|
73
|
+
* under the same name.
|
|
179
74
|
*
|
|
180
75
|
* Each machine keeps the client its setup installed, so old clients read what the service sends. A change
|
|
181
76
|
* an old client would get wrong is a new type name, which it skips: the decoder drops fields
|
|
182
77
|
* 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
|
|
184
|
-
*
|
|
78
|
+
* an old client can safely ignore may be added; the service ships first, since a new client
|
|
79
|
+
* refuses an entry that lacks it.
|
|
185
80
|
*/
|
|
186
81
|
const TypedCredential = Schema.Union([
|
|
187
82
|
GhCredential,
|
|
188
83
|
ClaudeCredential,
|
|
189
84
|
CodexCredential,
|
|
190
|
-
PiCredential
|
|
85
|
+
PiCredential,
|
|
86
|
+
TokenCredential
|
|
191
87
|
]);
|
|
192
88
|
/** The types this build knows, derived from the union so there is one list. */
|
|
193
89
|
const knownTypes = TypedCredential.members.map((member) => member.fields.type.literal);
|
|
@@ -198,77 +94,54 @@ const isKnownType = (type) => knownTypes.some((known) => known === type);
|
|
|
198
94
|
* schema must not land here, or a broken credential would be placed as an empty one.
|
|
199
95
|
*/
|
|
200
96
|
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
|
|
202
|
-
const
|
|
97
|
+
/** Everything the caller asked for, sent in full on every sync. */
|
|
98
|
+
const Delivery = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
|
|
203
99
|
//#endregion
|
|
204
|
-
//#region ../../packages/api/src/
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
|
|
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) }))
|
|
100
|
+
//#region ../../packages/api/src/machine.ts
|
|
101
|
+
/** A credential the calling key may read, without its value. */
|
|
102
|
+
const Grant = Schema.Struct({
|
|
103
|
+
name: Name,
|
|
104
|
+
kind: CredentialKind
|
|
226
105
|
});
|
|
227
106
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
107
|
+
* What a machine key can do: read the credentials it is granted, by name, and nothing else.
|
|
108
|
+
* The caller names what it wants; the grants on the server decide whether it may. A machine
|
|
109
|
+
* is one caller; a service such as clankerusage, holding a key of its own, is another.
|
|
230
110
|
*/
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
111
|
+
const Machine = [
|
|
112
|
+
Action.make("sync", {
|
|
113
|
+
readOnly: false,
|
|
114
|
+
caller: CurrentPrincipal,
|
|
115
|
+
description: "Fetch the named credentials as typed entries, each with its expiry; a login near expiry is refreshed first. Refused whole when the calling key is not granted any one of them.",
|
|
116
|
+
input: Schema.Struct({
|
|
117
|
+
clientId: ClientId,
|
|
118
|
+
credentials: Schema.Array(Name)
|
|
239
119
|
}),
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
success: CodexLogin,
|
|
266
|
-
error: [
|
|
267
|
-
NotFound,
|
|
268
|
-
NeedsSignIn,
|
|
269
|
-
UpstreamUnavailable
|
|
270
|
-
]
|
|
271
|
-
})];
|
|
120
|
+
success: Delivery,
|
|
121
|
+
error: NotFound
|
|
122
|
+
}),
|
|
123
|
+
Action.make("codexLogin", {
|
|
124
|
+
readOnly: false,
|
|
125
|
+
caller: CurrentPrincipal,
|
|
126
|
+
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.",
|
|
127
|
+
input: Schema.Struct({
|
|
128
|
+
clientId: ClientId,
|
|
129
|
+
credential: Name
|
|
130
|
+
}),
|
|
131
|
+
success: CodexLogin,
|
|
132
|
+
error: [
|
|
133
|
+
NotFound,
|
|
134
|
+
NeedsSignIn,
|
|
135
|
+
UpstreamUnavailable
|
|
136
|
+
]
|
|
137
|
+
}),
|
|
138
|
+
Action.make("listGrants", {
|
|
139
|
+
readOnly: true,
|
|
140
|
+
caller: CurrentPrincipal,
|
|
141
|
+
description: "The names and kinds of the credentials the calling key is granted, so a caller can offer them to pick from. Values are never returned.",
|
|
142
|
+
success: Schema.Struct({ credentials: Schema.Array(Grant) })
|
|
143
|
+
})
|
|
144
|
+
];
|
|
272
145
|
/**
|
|
273
146
|
* Codex treats a 401 as permanent and stops retrying; every other failure is transient.
|
|
274
147
|
* The codes are the ones Codex classifies: `refresh_token_invalidated` reads as revoked.
|
|
@@ -283,7 +156,7 @@ var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUn
|
|
|
283
156
|
}) }, { httpApiStatus: 503 }) {};
|
|
284
157
|
/**
|
|
285
158
|
* 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
|
|
159
|
+
* field carries `<clientId>:<credential>:<key>`, so the contract is public and its handler authenticates
|
|
287
160
|
* the key against the machine resource. The answer leaves `refresh_token` out, so Codex keeps the
|
|
288
161
|
* composite it has. Its input alone accepts fields it does not name: Codex may add one,
|
|
289
162
|
* and every other action refuses them.
|
|
@@ -291,7 +164,7 @@ var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUn
|
|
|
291
164
|
const CodexRefresh = Action.make("codexRefresh", {
|
|
292
165
|
readOnly: false,
|
|
293
166
|
caller: Action.Anyone,
|
|
294
|
-
description: "Answer Codex's token refresh with the
|
|
167
|
+
description: "Answer Codex's token refresh with the current tokens of the ChatGPT credential its composite names.",
|
|
295
168
|
input: Schema.StructWithRest(Schema.Struct({
|
|
296
169
|
grant_type: Schema.Literal("refresh_token"),
|
|
297
170
|
refresh_token: Schema.String,
|
|
@@ -304,14 +177,21 @@ const CodexRefresh = Action.make("codexRefresh", {
|
|
|
304
177
|
}),
|
|
305
178
|
error: [CodexRefreshRejected, CodexRefreshUnavailable]
|
|
306
179
|
});
|
|
307
|
-
/**
|
|
308
|
-
|
|
180
|
+
/**
|
|
181
|
+
* `<clientId>:<credential>:<key>`: a key may be granted several ChatGPT logins, so the
|
|
182
|
+
* composite names the one Codex holds. Client ids and names exclude `:`, so the key is the rest.
|
|
183
|
+
*/
|
|
184
|
+
const formatCodexRefreshToken = (clientId, credential, key) => `${clientId}:${credential}:${key}`;
|
|
309
185
|
const parseCodexRefreshToken = (value) => {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
186
|
+
const first = value.indexOf(":");
|
|
187
|
+
const second = value.indexOf(":", first + 1);
|
|
188
|
+
if (first < 1 || second === -1 || second === value.length - 1) return void 0;
|
|
189
|
+
const credential = value.slice(first + 1, second);
|
|
190
|
+
return Schema.is(Name)(credential) ? {
|
|
191
|
+
clientId: value.slice(0, first),
|
|
192
|
+
credential,
|
|
193
|
+
key: value.slice(second + 1)
|
|
194
|
+
} : void 0;
|
|
315
195
|
};
|
|
316
196
|
/**
|
|
317
197
|
* A machine key's bearer token on the public port, verified by the machine resource's own
|
|
@@ -324,6 +204,99 @@ const MachineHttp = ActionHttp.make([...Machine, CodexRefresh], {
|
|
|
324
204
|
authentication: MachineLogin
|
|
325
205
|
});
|
|
326
206
|
//#endregion
|
|
207
|
+
//#region src/files.ts
|
|
208
|
+
/** Secrets are readable by their owner only, in directories only the owner can enter. */
|
|
209
|
+
const fileMode = 384;
|
|
210
|
+
const directoryMode = 448;
|
|
211
|
+
const isMissing = (error) => Predicate.hasProperty(error, "code") && error.code === "ENOENT";
|
|
212
|
+
/** A missing file is `undefined`. A file that exists but cannot be read is not ours to replace. */
|
|
213
|
+
const readText = (path) => {
|
|
214
|
+
try {
|
|
215
|
+
return readFileSync(path, "utf8");
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (isMissing(error)) return void 0;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Write-then-rename, so a tool reading the file sees the old content or the new and never
|
|
223
|
+
* half of one. The directory is created private; an existing one keeps its mode.
|
|
224
|
+
*/
|
|
225
|
+
const writeAtomic = (path, content) => {
|
|
226
|
+
mkdirSync(dirname(path), {
|
|
227
|
+
recursive: true,
|
|
228
|
+
mode: directoryMode
|
|
229
|
+
});
|
|
230
|
+
const partial = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
231
|
+
try {
|
|
232
|
+
writeFileSync(partial, content, { mode: fileMode });
|
|
233
|
+
chmodSync(partial, fileMode);
|
|
234
|
+
renameSync(partial, path);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
rmSync(partial, { force: true });
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
/** True when there was a file to remove. */
|
|
241
|
+
const remove = (path) => {
|
|
242
|
+
try {
|
|
243
|
+
unlinkSync(path);
|
|
244
|
+
return true;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (isMissing(error)) return false;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
251
|
+
/** The file as an object; a missing file is empty. Anything else is not ours to overwrite. */
|
|
252
|
+
const readJsonObject = (path) => {
|
|
253
|
+
const text = readText(path);
|
|
254
|
+
if (text === void 0 || text.trim() === "") return {};
|
|
255
|
+
const parsed = JSON.parse(text);
|
|
256
|
+
if (!isJsonObject(parsed)) throw new Error(`${path} does not hold a JSON object`);
|
|
257
|
+
return parsed;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* A nested object a placement merges into; a missing one is empty. Anything else under the
|
|
261
|
+
* key is someone else's, however malformed, and not ours to overwrite.
|
|
262
|
+
*/
|
|
263
|
+
const objectAt = (parent, key, path) => {
|
|
264
|
+
const value = parent[key];
|
|
265
|
+
if (value === void 0) return {};
|
|
266
|
+
if (!isJsonObject(value)) throw new Error(`${key} in ${path} is not a JSON object; refusing to replace it`);
|
|
267
|
+
return value;
|
|
268
|
+
};
|
|
269
|
+
const writeJson = (path, value) => writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/config.ts
|
|
272
|
+
/** Where the recipe puts `config.json` and `key`. The variable exists for tests and odd images. */
|
|
273
|
+
const configDirectory = () => process.env["CLANKERCREDS_CONFIG_DIR"] ?? "/etc/clankercreds";
|
|
274
|
+
/** The environment is read here and nowhere else, so a placement goes only where it is told. */
|
|
275
|
+
const configHome = (home) => {
|
|
276
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
277
|
+
return xdg === void 0 || xdg === "" ? join(home, ".config") : xdg;
|
|
278
|
+
};
|
|
279
|
+
const ConfigFile = Schema.Struct({
|
|
280
|
+
service: Schema.String,
|
|
281
|
+
credentials: Schema.Array(Name)
|
|
282
|
+
});
|
|
283
|
+
/** The machine names what it syncs; the service decides whether its key may read it. */
|
|
284
|
+
const loadConfig = () => {
|
|
285
|
+
const directory = configDirectory();
|
|
286
|
+
const file = join(directory, "config.json");
|
|
287
|
+
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`));
|
|
288
|
+
const key = readText(join(directory, "key"))?.trim();
|
|
289
|
+
if (key === void 0 || key === "") throw new Error(`${join(directory, "key")} must hold the machine key`);
|
|
290
|
+
const home = homedir();
|
|
291
|
+
return {
|
|
292
|
+
service: service.replace(/\/+$/, ""),
|
|
293
|
+
credentials,
|
|
294
|
+
key,
|
|
295
|
+
home,
|
|
296
|
+
configHome: configHome(home)
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
//#endregion
|
|
327
300
|
//#region src/service.ts
|
|
328
301
|
/** The service answered and said no. Retrying the same request will not help. */
|
|
329
302
|
var Refused = class extends Error {};
|
|
@@ -350,8 +323,15 @@ const classify = (error) => {
|
|
|
350
323
|
* the command about ten seconds, and a hang would cost it a token it already holds.
|
|
351
324
|
*/
|
|
352
325
|
const within = (call, deadlineMs) => Effect.runPromise(Effect.timeout(call, deadlineMs)).catch((error) => Promise.reject(classify(error)));
|
|
353
|
-
|
|
354
|
-
const
|
|
326
|
+
/** The credentials the config names, refused whole when the key is not granted any one. */
|
|
327
|
+
const fetchDelivery = (config, clientId, deadlineMs = 3e4) => within(connect(config).sync({
|
|
328
|
+
clientId,
|
|
329
|
+
credentials: config.credentials
|
|
330
|
+
}), deadlineMs);
|
|
331
|
+
const fetchCodexLogin = (config, clientId, credential, deadlineMs = 5e3) => within(connect(config).codexLogin({
|
|
332
|
+
clientId,
|
|
333
|
+
credential
|
|
334
|
+
}), deadlineMs);
|
|
355
335
|
//#endregion
|
|
356
336
|
//#region src/place/claude.ts
|
|
357
337
|
const settingsFile = (home) => join(home, ".claude/settings.json");
|
|
@@ -393,8 +373,8 @@ const removeClaude = (home) => {
|
|
|
393
373
|
const authFile$1 = (home) => join(home, ".codex/auth.json");
|
|
394
374
|
/**
|
|
395
375
|
* 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
|
|
397
|
-
* refresh token there.
|
|
376
|
+
* it, for whichever identity and credential the composite names. A login anyone else wrote
|
|
377
|
+
* has a real refresh token there.
|
|
398
378
|
*/
|
|
399
379
|
const ownTokens = (auth) => {
|
|
400
380
|
const tokens = auth["tokens"];
|
|
@@ -410,19 +390,20 @@ const ownTokens = (auth) => {
|
|
|
410
390
|
/**
|
|
411
391
|
* The one writer of the file, and it always writes the whole login: the three tokens
|
|
412
392
|
* belong together. The refresh-token field never holds OpenAI's token: it names this
|
|
413
|
-
* machine to the service, which Codex calls when
|
|
414
|
-
* set. A login someone else made is never replaced
|
|
393
|
+
* machine and the credential to the service, which Codex calls when
|
|
394
|
+
* `CODEX_REFRESH_TOKEN_URL_OVERRIDE` is set. A login someone else made is never replaced,
|
|
395
|
+
* nor one a release before named credentials wrote: its two-part composite is not ours.
|
|
415
396
|
*/
|
|
416
397
|
const placeCodex = (home, login, identity) => {
|
|
417
398
|
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`);
|
|
399
|
+
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
400
|
writeJson(authFile$1(home), {
|
|
420
401
|
auth_mode: "chatgpt",
|
|
421
402
|
OPENAI_API_KEY: null,
|
|
422
403
|
tokens: {
|
|
423
404
|
id_token: login.idToken,
|
|
424
405
|
access_token: login.accessToken,
|
|
425
|
-
refresh_token: formatCodexRefreshToken(identity.clientId, identity.key),
|
|
406
|
+
refresh_token: formatCodexRefreshToken(identity.clientId, login.credential, identity.key),
|
|
426
407
|
account_id: login.accountId
|
|
427
408
|
},
|
|
428
409
|
last_refresh: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -533,8 +514,21 @@ const clientId = (home, machineIdFile = clankerboxMachineId) => {
|
|
|
533
514
|
writeAtomic(file, `${generated}\n`);
|
|
534
515
|
return generated;
|
|
535
516
|
};
|
|
536
|
-
/**
|
|
537
|
-
|
|
517
|
+
/**
|
|
518
|
+
* pi's access token, and the ChatGPT credential it is of, which `token` renews it from. The
|
|
519
|
+
* client's own copy; no tool reads it.
|
|
520
|
+
*/
|
|
521
|
+
const HeldToken = Schema.Struct({
|
|
522
|
+
credential: Schema.String,
|
|
523
|
+
accessToken: Schema.String
|
|
524
|
+
});
|
|
525
|
+
/** Encoding writes the schema's fields only, so pi's whole entry may be passed. */
|
|
526
|
+
const HeldTokenJson = Schema.fromJsonString(HeldToken);
|
|
527
|
+
const heldTokenFile = (home) => join(stateDirectory(home), "access-token");
|
|
528
|
+
/** The copy as `token` reads it. One that does not name its credential is no copy. */
|
|
529
|
+
const readHeldToken = (home) => Option.getOrUndefined(Schema.decodeUnknownOption(HeldTokenJson)(readText(heldTokenFile(home))));
|
|
530
|
+
const writeHeldToken = (home, held) => writeAtomic(heldTokenFile(home), `${Schema.encodeSync(HeldTokenJson)(held)}\n`);
|
|
531
|
+
const removeHeldToken = (home) => remove(heldTokenFile(home));
|
|
538
532
|
//#endregion
|
|
539
533
|
//#region src/place/pi.ts
|
|
540
534
|
const modelsFile = (home) => join(home, ".pi/agent/models.json");
|
|
@@ -552,10 +546,11 @@ const refuseStoredLogin = (home) => {
|
|
|
552
546
|
};
|
|
553
547
|
/**
|
|
554
548
|
* 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
|
|
549
|
+
* first, with its credential, so whatever stops the key, `token` never answers for a
|
|
550
|
+
* credential the config no longer names.
|
|
556
551
|
*/
|
|
557
|
-
const placePi = (home,
|
|
558
|
-
|
|
552
|
+
const placePi = (home, held) => {
|
|
553
|
+
writeHeldToken(home, held);
|
|
559
554
|
refuseStoredLogin(home);
|
|
560
555
|
const models = readJsonObject(modelsFile(home));
|
|
561
556
|
const providers = objectAt(models, "providers", modelsFile(home));
|
|
@@ -594,7 +589,7 @@ const removeEntry = (home) => {
|
|
|
594
589
|
* is left alone when it is someone else's.
|
|
595
590
|
*/
|
|
596
591
|
const removePi = (home) => {
|
|
597
|
-
|
|
592
|
+
removeHeldToken(home);
|
|
598
593
|
return removeEntry(home);
|
|
599
594
|
};
|
|
600
595
|
//#endregion
|
|
@@ -617,11 +612,14 @@ const placements = {
|
|
|
617
612
|
remove: (config) => removeCodex(config.home)
|
|
618
613
|
},
|
|
619
614
|
pi: {
|
|
620
|
-
place: (config, _identity, [credential]) => placePi(config.home, credential
|
|
615
|
+
place: (config, _identity, [credential]) => placePi(config.home, credential),
|
|
621
616
|
remove: (config) => removePi(config.home)
|
|
622
617
|
}
|
|
623
618
|
};
|
|
624
|
-
/** What
|
|
619
|
+
/** What this client places; anything else delivered, a token or a type it does not know, is skipped. */
|
|
620
|
+
const isPlaceable = (credential) => Object.hasOwn(placements, credential.type);
|
|
621
|
+
const placeableTypes = knownTypes.filter((type) => Object.hasOwn(placements, type));
|
|
622
|
+
/** What the delivery carries of one type is placed; a type it does not carry is undone. */
|
|
625
623
|
const settle = (config, identity, known, type) => {
|
|
626
624
|
const credentials = known.filter((credential) => credential.type === type);
|
|
627
625
|
if (credentials.length === 0) return placements[type].remove(config) ? "removed" : void 0;
|
|
@@ -629,25 +627,51 @@ const settle = (config, identity, known, type) => {
|
|
|
629
627
|
return "placed";
|
|
630
628
|
};
|
|
631
629
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
|
|
635
|
-
|
|
630
|
+
* What a machine holds one of, named as the operator stored it: a GitHub token per host, one
|
|
631
|
+
* Claude token, and one ChatGPT login, which arrives as a codex and a pi entry under one name.
|
|
632
|
+
*/
|
|
633
|
+
const slot = (credential) => {
|
|
634
|
+
switch (credential.type) {
|
|
635
|
+
case "gh": return `GitHub credentials for ${credential.host}`;
|
|
636
|
+
case "claude": return "Claude credentials";
|
|
637
|
+
case "codex":
|
|
638
|
+
case "pi": return "ChatGPT credentials";
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
/**
|
|
642
|
+
* Two credentials for one slot would leave the machine holding whichever came last: the
|
|
643
|
+
* whole sync is refused before anything is written.
|
|
644
|
+
*/
|
|
645
|
+
const oneEach = (placed) => {
|
|
646
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
647
|
+
for (const credential of placed) {
|
|
648
|
+
const other = claimed.get(slot(credential));
|
|
649
|
+
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`);
|
|
650
|
+
claimed.set(slot(credential), credential.credential);
|
|
651
|
+
}
|
|
652
|
+
return placed;
|
|
653
|
+
};
|
|
654
|
+
/**
|
|
655
|
+
* Idempotent: every credential the config names is fetched and placed every time, and every
|
|
656
|
+
* type the delivery does not carry is undone, whether or not a previous sync placed it.
|
|
657
|
+
* Nothing on disk changes until the service has answered, so an unreachable service leaves
|
|
658
|
+
* every file as it was. Each type stands alone: a file that is not ours to touch stops its
|
|
659
|
+
* own type only.
|
|
636
660
|
*/
|
|
637
661
|
const sync = async (config) => {
|
|
638
662
|
const identity = {
|
|
639
663
|
clientId: clientId(config.home),
|
|
640
664
|
key: config.key
|
|
641
665
|
};
|
|
642
|
-
const
|
|
643
|
-
const known =
|
|
666
|
+
const delivery = await fetchDelivery(config, identity.clientId);
|
|
667
|
+
const known = oneEach(delivery.credentials.filter(isPlaceable));
|
|
644
668
|
const report = {
|
|
645
669
|
placed: new Array(),
|
|
646
670
|
removed: new Array(),
|
|
647
|
-
skipped:
|
|
671
|
+
skipped: delivery.credentials.flatMap((credential) => isPlaceable(credential) ? [] : [credential.type]),
|
|
648
672
|
failed: new Array()
|
|
649
673
|
};
|
|
650
|
-
for (const type of
|
|
674
|
+
for (const type of placeableTypes) try {
|
|
651
675
|
const outcome = settle(config, identity, known, type);
|
|
652
676
|
if (outcome !== void 0) report[outcome].push(type);
|
|
653
677
|
} catch (error) {
|
|
@@ -666,20 +690,24 @@ const margin = 3e5;
|
|
|
666
690
|
const remaining = (jwt) => (claimsOf(jwt).exp ?? 0) * 1e3 - Date.now();
|
|
667
691
|
/**
|
|
668
692
|
* 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
|
|
670
|
-
*
|
|
671
|
-
* still-valid token away.
|
|
693
|
+
* the client's own copy, placed by sync with the credential it is of, so no other tool's file
|
|
694
|
+
* is read or written. Near expiry the service is asked again for that credential; an outage
|
|
695
|
+
* does not take a still-valid token away. With no copy there is nothing to renew.
|
|
672
696
|
*/
|
|
673
697
|
const accessToken = async (config) => {
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
if (
|
|
698
|
+
const held = readHeldToken(config.home);
|
|
699
|
+
if (held === void 0) throw new Error("no ChatGPT credential is placed for pi; run `clankercreds sync`");
|
|
700
|
+
if (remaining(held.accessToken) > margin) return held.accessToken;
|
|
677
701
|
try {
|
|
678
|
-
const { accessToken: fresh } = await fetchCodexLogin(config, clientId(config.home));
|
|
679
|
-
|
|
702
|
+
const { accessToken: fresh } = await fetchCodexLogin(config, clientId(config.home), held.credential);
|
|
703
|
+
const current = readHeldToken(config.home);
|
|
704
|
+
if (current?.credential === held.credential && current.accessToken === held.accessToken) writeHeldToken(config.home, {
|
|
705
|
+
credential: held.credential,
|
|
706
|
+
accessToken: fresh
|
|
707
|
+
});
|
|
680
708
|
return fresh;
|
|
681
709
|
} catch (error) {
|
|
682
|
-
if (error instanceof Unreachable &&
|
|
710
|
+
if (error instanceof Unreachable && remaining(held.accessToken) > 0) return held.accessToken;
|
|
683
711
|
throw error;
|
|
684
712
|
}
|
|
685
713
|
};
|
|
@@ -687,12 +715,13 @@ const accessToken = async (config) => {
|
|
|
687
715
|
//#region src/main.ts
|
|
688
716
|
const usage = `Usage: clankercreds <command>
|
|
689
717
|
|
|
690
|
-
sync Fetch
|
|
718
|
+
sync Fetch the credentials the config names and place each where its tool expects it.
|
|
691
719
|
token Print the current ChatGPT access token, for pi.
|
|
720
|
+
admin Administer the service over its admin port: \`clankercreds admin --help\`.
|
|
692
721
|
`;
|
|
693
722
|
const runSync = async () => {
|
|
694
723
|
const report = await sync(loadConfig());
|
|
695
|
-
for (const type of report.skipped) console.error(`warning: skipped
|
|
724
|
+
for (const type of report.skipped) console.error(`warning: skipped credential type "${type}", which this client does not place; if it is new, update clankercreds`);
|
|
696
725
|
for (const type of report.placed) console.log(`placed ${type}`);
|
|
697
726
|
for (const type of report.removed) console.log(`removed ${type}`);
|
|
698
727
|
for (const { type, message } of report.failed) console.error(`error: ${type}: ${message}`);
|
|
@@ -711,6 +740,10 @@ const commandFor = (name) => {
|
|
|
711
740
|
}
|
|
712
741
|
};
|
|
713
742
|
const main = async () => {
|
|
743
|
+
if (process.argv[2] === "admin") {
|
|
744
|
+
(await import("./admin-DC0cTmmT.mjs")).run(process.argv.slice(2));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
714
747
|
const command = commandFor(process.argv[2]);
|
|
715
748
|
if (command === void 0) {
|
|
716
749
|
console.error(usage);
|
|
@@ -0,0 +1,127 @@
|
|
|
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.16.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 login is dead, or was never completed. Only a new sign-in revives it. */
|
|
66
|
+
var NeedsSignIn = class extends Schema.TaggedError()("NeedsSignIn", { message: Schema.String }, { httpApiStatus: 409 }) {};
|
|
67
|
+
/** The provider 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
|
+
/**
|
|
76
|
+
* What an operator stores a value for: a GitHub token, a Claude token, or `token`, any other
|
|
77
|
+
* service's API key, which a service such as clankerusage reads and no machine places.
|
|
78
|
+
*/
|
|
79
|
+
const SecretKind = Schema.Literals([
|
|
80
|
+
"gh",
|
|
81
|
+
"claude",
|
|
82
|
+
"token"
|
|
83
|
+
]);
|
|
84
|
+
/**
|
|
85
|
+
* What a sign-in creates and the service keeps refreshed. The refresh token never leaves
|
|
86
|
+
* the service; only access tokens are delivered.
|
|
87
|
+
*/
|
|
88
|
+
const LoginKind = Schema.Literals([
|
|
89
|
+
"chatgpt",
|
|
90
|
+
"grok",
|
|
91
|
+
"cursor"
|
|
92
|
+
]);
|
|
93
|
+
const CredentialKind = Schema.Literals([...SecretKind.literals, ...LoginKind.literals]);
|
|
94
|
+
/** A GitHub host, lowercase: it is written into `gh`'s and git's configuration as is. */
|
|
95
|
+
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));
|
|
96
|
+
/**
|
|
97
|
+
* The machine's self-generated label. It identifies nothing; it only groups audit rows.
|
|
98
|
+
* It excludes `:` because the Codex refresh token is `<clientId>:<credential>:<key>`.
|
|
99
|
+
*/
|
|
100
|
+
const ClientId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/));
|
|
101
|
+
const Claims = Schema.Struct({
|
|
102
|
+
exp: Schema.optionalKey(Schema.Number),
|
|
103
|
+
email: Schema.optionalKey(Schema.String),
|
|
104
|
+
"https://api.openai.com/auth": Schema.optionalKey(Schema.Struct({ chatgpt_account_id: Schema.optionalKey(Schema.String) }))
|
|
105
|
+
});
|
|
106
|
+
/**
|
|
107
|
+
* The claims of a JWT, unverified: they are read for their timing, email and, in OpenAI's,
|
|
108
|
+
* account, never trusted for anything. Anything unreadable is simply no claims.
|
|
109
|
+
*/
|
|
110
|
+
const claimsOf = (jwt) => {
|
|
111
|
+
const payload = Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8");
|
|
112
|
+
const claims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims))(payload);
|
|
113
|
+
return Option.match(claims, {
|
|
114
|
+
onNone: () => ({
|
|
115
|
+
exp: void 0,
|
|
116
|
+
email: void 0,
|
|
117
|
+
accountId: void 0
|
|
118
|
+
}),
|
|
119
|
+
onSome: (value) => ({
|
|
120
|
+
exp: value.exp,
|
|
121
|
+
email: value.email,
|
|
122
|
+
accountId: value["https://api.openai.com/auth"]?.chatgpt_account_id
|
|
123
|
+
})
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
127
|
+
export { LoginKind as a, NotFound as c, claimsOf as d, scopes as f, Host as i, SecretKind as l, ProviderUnavailable as m, Conflict as n, Name as o, CurrentPrincipal as p, CredentialKind as r, NeedsSignIn as s, ClientId as t, UpstreamUnavailable 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.4.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,19 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@
|
|
23
|
-
"effect": "4.0.
|
|
22
|
+
"@effect/platform-node": "4.0.2",
|
|
23
|
+
"@effect/platform-node-shared": "4.0.2",
|
|
24
|
+
"@gjermundgaraba/effect-actions": "0.11.0",
|
|
25
|
+
"effect": "4.0.2",
|
|
24
26
|
"yaml": "2.9.1"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
27
|
-
"@
|
|
28
|
-
"@gjermundgaraba/
|
|
29
|
-
"@types/node": "26.6.
|
|
29
|
+
"@gjermundgaraba/clankerauth-sdk": "0.16.0",
|
|
30
|
+
"@gjermundgaraba/clankercreds-api": "0.4.0",
|
|
31
|
+
"@types/node": "26.6.5",
|
|
30
32
|
"typescript": "7.0.2",
|
|
31
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
32
|
-
"vite-plus": "1.
|
|
33
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
34
|
+
"vite-plus": "1.1.0"
|
|
33
35
|
},
|
|
34
36
|
"engines": {
|
|
35
37
|
"node": ">=26"
|