@gjermundgaraba/clankercreds 0.1.1 → 0.2.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.
Files changed (2) hide show
  1. package/dist/main.mjs +85 -96
  2. package/package.json +6 -6
package/dist/main.mjs CHANGED
@@ -3,12 +3,11 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
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";
6
+ import { Context, Data, Effect, Option, Predicate, Schema } from "effect";
7
+ import { HttpClient, HttpClientRequest } from "effect/http";
9
8
  import * as Action from "@gjermundgaraba/effect-actions/Action";
10
- import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
11
9
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
10
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
12
11
  import { YAMLMap, isMap, parseDocument } from "yaml";
13
12
  //#region src/files.ts
14
13
  /** Secrets are readable by their owner only, in directories only the owner can enter. */
@@ -98,44 +97,49 @@ const loadConfig = () => {
98
97
  configHome: configHome(home)
99
98
  };
100
99
  };
101
- //#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
100
+ Data.TaggedError("Unauthorized");
103
101
  /**
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.
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.
109
106
  */
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
107
  var ProviderUnavailable = class extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
118
108
  constructor(options) {
119
109
  super({ operation: options.operation });
120
110
  Object.defineProperty(this, "cause", { value: options.cause });
121
111
  }
122
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
123
117
  /**
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.
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`.
126
122
  */
127
- var InsufficientScope = class extends Schema.TaggedError()("InsufficientScope", { scope: Schema.String }, { httpApiStatus: 403 }) {};
128
- Schema.TaggedError()("ConfigurationError", { message: Schema.String });
129
123
  /**
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.
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)`.
132
129
  */
133
- const authenticationErrors = [
134
- Unauthorized,
135
- InsufficientScope,
136
- RateLimited,
137
- ProviderUnavailable
138
- ];
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
+ });
139
143
  //#endregion
140
144
  //#region ../../packages/api/src/bundle.ts
141
145
  const GhCredential = Schema.Struct({
@@ -169,7 +173,11 @@ const PiCredential = Schema.Struct({
169
173
  accessToken: Schema.String
170
174
  });
171
175
  /**
172
- * The client is baked into images, so old clients read what the service sends. A change
176
+ * The service sends typed credentials, never file contents or paths, so it cannot write
177
+ * 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.
179
+ *
180
+ * Each machine keeps the client its setup installed, so old clients read what the service sends. A change
173
181
  * an old client would get wrong is a new type name, which it skips: the decoder drops fields
174
182
  * it does not know, so one ignoring `host` would place a GHE token as github.com's. A field
175
183
  * an old client can safely ignore may be added, as pi's `accessToken` was; the service ships
@@ -194,8 +202,6 @@ const UnknownCredential = Schema.Struct({ type: Schema.String.check(Schema.makeF
194
202
  const Bundle = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
195
203
  //#endregion
196
204
  //#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
205
  /**
200
206
  * What was asked for does not exist: a bundle, credential or sign-in by that name, a
201
207
  * bundle for the calling key, or a ChatGPT login in the bundle that has none.
@@ -206,19 +212,6 @@ Schema.TaggedError()("Conflict", { message: Schema.String }, { httpApiStatus: 40
206
212
  var NeedsSignIn = class extends Schema.TaggedError()("NeedsSignIn", { message: Schema.String }, { httpApiStatus: 409 }) {};
207
213
  /** OpenAI could not be reached and no valid token is held. Waiting helps; retrying now does not. */
208
214
  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
215
  Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/));
223
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));
224
217
  /**
@@ -257,28 +250,25 @@ const claimsOf = (jwt) => {
257
250
  * What a machine key can do: read the one bundle bound to it, and nothing else. The
258
251
  * bundle is never named: the key is bound to it on the server.
259
252
  */
260
- const Machine = ActionGroup.make({
261
- name: "machine",
262
- schemaError
263
- }, Action.make("bundle", {
264
- access: "read",
253
+ const Machine = [Action.make("bundle", {
254
+ readOnly: false,
255
+ caller: CurrentPrincipal,
265
256
  description: "Fetch the typed credentials of the bundle bound to the calling key.",
266
257
  input: Schema.Struct({ clientId: ClientId }),
267
258
  success: Bundle,
268
- errors: [NotFound],
269
- mcp: false
259
+ error: NotFound
270
260
  }), Action.make("codexLogin", {
271
- access: "read",
261
+ readOnly: false,
262
+ caller: CurrentPrincipal,
272
263
  description: "The current ChatGPT login of the bundle bound to the calling key, as the codex type carries it, refreshed first when it has expired.",
273
264
  input: Schema.Struct({ clientId: ClientId }),
274
265
  success: CodexLogin,
275
- errors: [
266
+ error: [
276
267
  NotFound,
277
268
  NeedsSignIn,
278
269
  UpstreamUnavailable
279
- ],
280
- mcp: false
281
- }));
270
+ ]
271
+ })];
282
272
  /**
283
273
  * Codex treats a 401 as permanent and stops retrying; every other failure is transient.
284
274
  * The codes are the ones Codex classifies: `refresh_token_invalidated` reads as revoked.
@@ -293,26 +283,27 @@ var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUn
293
283
  }) }, { httpApiStatus: 503 }) {};
294
284
  /**
295
285
  * 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.
286
+ * field carries `<clientId>:<key>`, so the contract is public and its handler authenticates
287
+ * the key against the machine resource. The answer leaves `refresh_token` out, so Codex keeps the
288
+ * composite it has. Its input alone accepts fields it does not name: Codex may add one,
289
+ * and every other action refuses them.
299
290
  */
300
- const Codex = ActionGroup.make({ name: "codex" }, Action.make("refresh", {
301
- access: "read",
291
+ const CodexRefresh = Action.make("codexRefresh", {
292
+ readOnly: false,
293
+ caller: Action.Anyone,
302
294
  description: "Answer Codex's token refresh with the bundle's current ChatGPT tokens.",
303
- input: Schema.Struct({
295
+ input: Schema.StructWithRest(Schema.Struct({
304
296
  grant_type: Schema.Literal("refresh_token"),
305
297
  refresh_token: Schema.String,
306
298
  client_id: Schema.optionalKey(Schema.String),
307
299
  scope: Schema.optionalKey(Schema.String)
308
- }),
300
+ }), [Schema.Record(Schema.String, Schema.Json)]),
309
301
  success: Schema.Struct({
310
302
  access_token: Schema.String,
311
303
  id_token: Schema.String
312
304
  }),
313
- errors: [CodexRefreshRejected, CodexRefreshUnavailable],
314
- mcp: false
315
- }));
305
+ error: [CodexRefreshRejected, CodexRefreshUnavailable]
306
+ });
316
307
  /** `<clientId>:<key>`. Client ids exclude `:`, so the key is the rest. */
317
308
  const formatCodexRefreshToken = (clientId, key) => `${clientId}:${key}`;
318
309
  const parseCodexRefreshToken = (value) => {
@@ -322,47 +313,45 @@ const parseCodexRefreshToken = (value) => {
322
313
  key: value.slice(colon + 1)
323
314
  };
324
315
  };
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(".", "/")}`);
316
+ /**
317
+ * A machine key's bearer token on the public port, verified by the machine resource's own
318
+ * provider, which requires `clankercreds:machine`: a key without that scope is refused here.
319
+ */
320
+ const MachineLogin = Authentication.make("clankercreds.machine", CurrentPrincipal, { error: ProviderUnavailable });
321
+ /** Everything the public listener serves: the machine actions, and Codex's refresh beside them. */
322
+ const MachineHttp = ActionHttp.make([...Machine, CodexRefresh], {
323
+ prefix: "/v1",
324
+ authentication: MachineLogin
325
+ });
331
326
  //#endregion
332
327
  //#region src/service.ts
333
328
  /** The service answered and said no. Retrying the same request will not help. */
334
329
  var Refused = class extends Error {};
335
330
  /** No answer worth acting on. Whatever the machine already holds stays in place. */
336
331
  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, {
332
+ const connect = (config) => ActionHttp.fetchClient(MachineHttp, {
342
333
  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
- }
334
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(config.key))
352
335
  });
353
336
  /**
354
- * The key's own refusals and the declared answers are refusals. Whatever is left is an
355
- * outage: transport, upstream, a broken answer, or a service that never answers.
337
+ * The key's own refusals, input the service will not take, and the declared answers are
338
+ * refusals. Whatever is left is an outage: transport, upstream, a broken answer, or a
339
+ * service that never answers within its deadline.
356
340
  */
357
341
  const classify = (error) => {
358
- if (error instanceof Unauthorized) return new Refused("The machine key was refused. It may have been revoked.");
359
- if (error instanceof InsufficientScope) return new Refused("The machine key does not carry the machine scope.");
360
- if (error instanceof NotFound || error instanceof BadRequest || error instanceof NeedsSignIn) return new Refused(error.message);
342
+ 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.");
343
+ 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.");
344
+ if (error instanceof Action.InvalidInput || error instanceof NotFound || error instanceof NeedsSignIn) return new Refused(error.message);
361
345
  return new Unreachable(error instanceof Error ? error.message : String(error));
362
346
  };
363
- const refuse = (error) => Promise.reject(classify(error));
364
- const fetchBundle = (config, clientId) => connect(config, 3e4).machine.bundle({ clientId }).catch(refuse);
365
- const fetchCodexLogin = (config, clientId) => connect(config, 5e3).machine.codexLogin({ clientId }).catch(refuse);
347
+ /**
348
+ * One call, within its deadline. The deadline covers the whole request, the body included:
349
+ * the call is interrupted, which aborts its `fetch`. It matters most to `token`: pi gives
350
+ * the command about ten seconds, and a hang would cost it a token it already holds.
351
+ */
352
+ const within = (call, deadlineMs) => Effect.runPromise(Effect.timeout(call, deadlineMs)).catch((error) => Promise.reject(classify(error)));
353
+ const fetchBundle = (config, clientId, deadlineMs = 3e4) => within(connect(config).bundle({ clientId }), deadlineMs);
354
+ const fetchCodexLogin = (config, clientId, deadlineMs = 5e3) => within(connect(config).codexLogin({ clientId }), deadlineMs);
366
355
  //#endregion
367
356
  //#region src/place/claude.ts
368
357
  const settingsFile = (home) => join(home, ".claude/settings.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankercreds",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The machine client of clankercreds: fetches a bundle of credentials and places each where its tool expects it.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,17 +19,17 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@gjermundgaraba/effect-actions": "0.7.0",
23
- "effect": "4.0.0-rc.117",
22
+ "@gjermundgaraba/effect-actions": "0.10.0",
23
+ "effect": "4.0.0",
24
24
  "yaml": "2.9.1"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@clankercreds/api": "0.0.0",
28
- "@gjermundgaraba/clankerauth-sdk": "0.9.0",
28
+ "@gjermundgaraba/clankerauth-sdk": "0.13.0",
29
29
  "@types/node": "26.6.2",
30
30
  "typescript": "7.0.2",
31
- "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0-rc.1",
32
- "vite-plus": "1.0.0-rc.1"
31
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
32
+ "vite-plus": "1.0.0"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=26"