@nylorun/runtime 0.20.0-beta → 0.21.0-beta

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 (95) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +15 -10
  3. package/dist/api/components.d.ts +155 -36
  4. package/dist/api/components.js +3 -3
  5. package/dist/api/http/app.js +2 -0
  6. package/dist/api/http/routes/mcp.d.ts +9 -0
  7. package/dist/api/http/routes/mcp.js +32 -0
  8. package/dist/api/http/routes/vaults.d.ts +1 -9
  9. package/dist/api/http/routes/vaults.js +3 -103
  10. package/dist/api/openapi.js +6 -3
  11. package/dist/artifacts/media-types.d.ts +2 -0
  12. package/dist/artifacts/media-types.js +5 -0
  13. package/dist/gates/http-tool.d.ts +7 -0
  14. package/dist/gates/http-tool.js +34 -3
  15. package/dist/gates/mcp-handler.d.ts +4 -1
  16. package/dist/gates/mcp-handler.js +64 -8
  17. package/dist/gates/tenant-vaults.d.ts +9 -15
  18. package/dist/gates/tenant-vaults.js +9 -17
  19. package/dist/gates/tool-client.js +36 -17
  20. package/dist/gates/tool-contract.d.ts +19 -3
  21. package/dist/gates/tool-contract.js +9 -3
  22. package/dist/gates/tool-gate.d.ts +2 -2
  23. package/dist/harness/calls.d.ts +2 -0
  24. package/dist/harness/calls.js +13 -3
  25. package/dist/harness-api/record.d.ts +7 -6
  26. package/dist/harness-api/record.js +99 -17
  27. package/dist/harness-api/server.js +35 -27
  28. package/dist/harness-api/start.js +2 -1
  29. package/dist/host/config-for.d.ts +0 -2
  30. package/dist/host/config-for.js +0 -1
  31. package/dist/host/gates.js +0 -1
  32. package/dist/host/main.js +13 -3
  33. package/dist/host/stack-config.d.ts +5 -8
  34. package/dist/host/stack-config.js +8 -19
  35. package/dist/index.d.ts +0 -1
  36. package/dist/keys/client.js +1 -9
  37. package/dist/keys/keys.d.ts +11 -13
  38. package/dist/keys/keys.js +5 -7
  39. package/dist/management-openapi.json +315 -345
  40. package/dist/mcp/connect.d.ts +159 -13
  41. package/dist/mcp/connect.js +538 -55
  42. package/dist/mcp/pool.d.ts +11 -2
  43. package/dist/mcp/pool.js +74 -27
  44. package/dist/mcp/preview.d.ts +35 -0
  45. package/dist/mcp/preview.js +215 -0
  46. package/dist/mcp/snapshot.d.ts +79 -5
  47. package/dist/mcp/snapshot.js +145 -27
  48. package/dist/model/catalog.d.ts +14 -0
  49. package/dist/model/catalog.js +14 -1
  50. package/dist/model/pi-model.js +36 -2
  51. package/dist/openapi.json +362 -123
  52. package/dist/redact.d.ts +19 -1
  53. package/dist/redact.js +42 -13
  54. package/dist/sandbox/session-sandbox.d.ts +7 -0
  55. package/dist/sandbox/session-sandbox.js +89 -28
  56. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  57. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  58. package/dist/store/postgres/schema.d.ts +2 -261
  59. package/dist/store/postgres/schema.js +2 -36
  60. package/dist/store/postgres/store.js +1 -18
  61. package/dist/store/postgres/tenant.d.ts +14 -0
  62. package/dist/store/postgres/tenant.js +22 -2
  63. package/dist/store/types.d.ts +2 -8
  64. package/dist/tenant/artifact-tool.d.ts +9 -1
  65. package/dist/tenant/artifact-tool.js +113 -9
  66. package/dist/tenant/context.d.ts +5 -6
  67. package/dist/tenant/effects.d.ts +2 -2
  68. package/dist/tenant/effects.js +20 -5
  69. package/dist/tenant/ephemeral.d.ts +0 -6
  70. package/dist/tenant/ephemeral.js +0 -1
  71. package/dist/tenant/outbound.d.ts +14 -5
  72. package/dist/tenant/outbound.js +21 -9
  73. package/dist/tenant/runtime.js +9 -14
  74. package/dist/tenant/sessions.js +20 -3
  75. package/dist/tenant/store-pg.js +9 -0
  76. package/dist/tenant/tool-results.d.ts +57 -0
  77. package/dist/tenant/tool-results.js +298 -0
  78. package/dist/tenant/tool-search.d.ts +43 -0
  79. package/dist/tenant/tool-search.js +100 -0
  80. package/dist/tenant/transcript.d.ts +108 -0
  81. package/dist/tenant/transcript.js +13 -0
  82. package/dist/tenant/types.d.ts +0 -14
  83. package/dist/vault/crypto.d.ts +0 -7
  84. package/dist/vault/crypto.js +0 -16
  85. package/dist/vault/headers.d.ts +17 -0
  86. package/dist/vault/headers.js +61 -0
  87. package/dist/vault/service.d.ts +11 -68
  88. package/dist/vault/service.js +83 -438
  89. package/dist/vault/sources.d.ts +20 -48
  90. package/dist/vault/sources.js +25 -189
  91. package/dist/version.d.ts +1 -1
  92. package/dist/version.js +1 -1
  93. package/package.json +3 -3
  94. package/dist/vault/oauth.d.ts +0 -55
  95. package/dist/vault/oauth.js +0 -186
@@ -7,7 +7,7 @@
7
7
  * ## Public surface
8
8
  *
9
9
  * ```ts
10
- * new VaultService({ store: SessionStore, kek: () => Buffer, fetch: typeof fetch })
10
+ * new VaultService({ store: SessionStore, kek: () => Buffer })
11
11
  *
12
12
  * // Administration: each opens its own transaction and writes its audit rows
13
13
  * // in that transaction. Creates and rotations replay by idempotency key.
@@ -25,13 +25,10 @@
25
25
  * assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[], options?: { opaque?: boolean }): Promise<void>
26
26
  * recordAttachment(t: Tx, sessionId: string, vaultIds: readonly string[]): Promise<void>
27
27
  *
28
- * // Use: opens its own transactions; never call it inside one. `CredentialSources`
29
- * // (`vault/sources.ts`) wraps it with the operator's credential resolver.
30
- * authorize(input: { sessionId; vaultIds; credentialSelections; url; serverName? }): Promise<AuthorizeResult>
31
- *
32
- * // MCP OAuth connect (F9 C2, installation vaults only): network calls outside any transaction.
33
- * startOAuth(input: { vaultId; server; url; clientId?; redirectUri }): Promise<{ authorizeUrl; expiresAt }>
34
- * finishOAuth(input: { state; code?; error? }): Promise<{ vaultId; credentialId }>
28
+ * // Use: opens its own transactions; never call it inside one. `sessionCredentials`
29
+ * // (`vault/sources.ts`) calls it for a session's MCP servers and HTTP tools; a tool preview
30
+ * // (R2b C12, `mcp/preview.ts`) for no session.
31
+ * authorize(input: { sessionId?; vaultIds; credentialSelections; url; serverName? }): Promise<AuthorizeResult>
35
32
  *
36
33
  * // Host model credential: each opens its own transaction.
37
34
  * getHostModel(): Promise<HostModelView>
@@ -61,42 +58,37 @@
61
58
  * (ciphertext only) in one transaction and decides there. A refusal decided
62
59
  * from those rows (missing vault, ambiguous, selection mismatch) writes its
63
60
  * audit row in that same transaction. Decryption happens after it commits,
64
- * so the plaintext never enters a transaction. An OAuth refresh calls the
65
- * token endpoint outside any transaction, then stores the rotated secret and
66
- * its `refresh` audit row in one follow-up transaction (re-reading the
67
- * current row there). The `use`/`approved` audit row is written in a final
68
- * transaction before the header is returned: if that write fails,
69
- * `authorize` rejects and no header leaves the vault, so every approved use
70
- * has an audit row. Unreadable ciphertext and refresh failures are audited
71
- * in their own transaction before the refusal is returned.
61
+ * so the plaintext never enters a transaction. The `use`/`approved` audit
62
+ * row is written in a final transaction before the header is returned: if
63
+ * that write fails, `authorize` rejects and no header leaves the vault, so
64
+ * every approved use has an audit row. Unreadable ciphertext is audited in
65
+ * its own transaction before the refusal is returned.
66
+ *
67
+ * ## Credentials (R2b C2)
68
+ *
69
+ * A credential is bound to the URL a manifest names and sends a `bearer` token or a `headers`
70
+ * map, both sealed. Its `via` (where requests go, such as a gateway) and `identity` (the header
71
+ * that names the session owner) are not secret: they live unsealed in the binding, beside the
72
+ * URL and the header names, and a rotation keeps them unless it changes them. The seal's AAD
73
+ * binds the type and URL only, and matching is by URL only, so `via` never decides which
74
+ * credential is chosen.
72
75
  */
73
- import { createHash, randomBytes, randomUUID } from "node:crypto";
76
+ import { createHash, randomUUID } from "node:crypto";
74
77
  import { INSTALLATION_OWNER } from "@nylorun/core/contracts";
75
78
  import { canonical } from "../store/canonical.js";
76
- import { decryptSecret, encryptSecret, kekId as kekIdOf, openBytes, sealBytes, VaultCryptoError, } from "./crypto.js";
77
- import { HttpError } from "../tenant/http.js";
78
- import { authorizationUrl, discoverOAuthServer, exchangeCode, oauthClient, } from "./oauth.js";
79
+ import { decryptSecret, encryptSecret, VaultCryptoError } from "./crypto.js";
79
80
  import { VaultError } from "./error.js";
81
+ import { checkCredentialHeaders } from "./headers.js";
80
82
  import { hostModelCatalog } from "../model/catalog.js";
81
83
  import { normalizeVaultUrl } from "./url.js";
82
- const REFRESH_SKEW_MS = 60_000;
83
- const REFRESH_TIMEOUT_MS = 30_000;
84
- /** How long an MCP OAuth connect may take between its start and its callback (F9 C2). */
85
- export const OAUTH_PENDING_MS = 10 * 60_000;
86
84
  export const HOST_VAULT_ID = "host";
87
85
  const HOST_MODEL_ID = "host-model";
88
86
  export class VaultService {
89
87
  store;
90
88
  kek;
91
- fetchImpl;
92
- refreshTimeoutMs;
93
- /** Refreshes in progress by credential id: concurrent uses share one. */
94
- refreshing = new Map();
95
89
  constructor(options) {
96
90
  this.store = options.store;
97
91
  this.kek = options.kek;
98
- this.fetchImpl = options.fetch;
99
- this.refreshTimeoutMs = options.refreshTimeoutMs ?? REFRESH_TIMEOUT_MS;
100
92
  }
101
93
  // --- administration --------------------------------------------------------
102
94
  async createVault(body) {
@@ -166,14 +158,16 @@ export class VaultService {
166
158
  const id = randomUUID();
167
159
  const url = normalizeVaultUrl(body.auth.url);
168
160
  const type = body.auth.type;
169
- const sealed = encryptSecret(kek, credentialAad(vaultId, id, type, url), Buffer.from(JSON.stringify(payloadFromCreate(body)), "utf8"));
161
+ const payload = payloadOf(body.auth);
162
+ const binding = bindingOf(url, payload, body.auth);
163
+ const sealed = encryptSecret(kek, credentialAad(vaultId, id, type, url), Buffer.from(JSON.stringify(payload), "utf8"));
170
164
  const row = {
171
165
  id,
172
166
  vaultId,
173
167
  name: body.name,
174
168
  type,
175
- bindingJson: JSON.stringify({ url }),
176
- expiresAt: expiresFromCreate(body),
169
+ bindingJson: JSON.stringify(binding),
170
+ expiresAt: null,
177
171
  createdAt: new Date().toISOString(),
178
172
  rotatedAt: null,
179
173
  ...sealed,
@@ -206,18 +200,13 @@ export class VaultService {
206
200
  const row = await this.credentialRow(t, vaultId, id);
207
201
  if (row.type !== body.auth.type)
208
202
  throw new VaultError(409, "Credential type cannot change");
209
- const next = { ...readPayload(kek, row) };
210
- let expiresAt = row.expiresAt;
211
- if (body.auth.type === "bearer")
212
- next.token = body.auth.token;
213
- else {
214
- next.accessToken = body.auth.accessToken;
215
- expiresAt =
216
- body.auth.expiresAt === undefined || body.auth.expiresAt === null
217
- ? null
218
- : requireTimestamp(body.auth.expiresAt);
219
- }
220
- const updated = await this.writePayload(t, kek, row, next, expiresAt, new Date().toISOString());
203
+ const current = credentialBinding(row);
204
+ const next = payloadOf(body.auth);
205
+ const binding = bindingOf(current.url, next, {
206
+ via: body.auth.via === undefined ? current.via : body.auth.via,
207
+ identity: body.auth.identity === undefined ? current.identity : body.auth.identity,
208
+ });
209
+ const updated = await this.writePayload(t, kek, row, next, binding, new Date().toISOString());
221
210
  await this.audit(t, {
222
211
  actor: "application",
223
212
  action: "rotate",
@@ -286,9 +275,13 @@ export class VaultService {
286
275
  async authorize(input) {
287
276
  const url = normalizeVaultUrl(input.url);
288
277
  const decided = await this.store.tx(async (t) => {
278
+ const scopes = new Map();
289
279
  for (const id of input.vaultIds) {
290
- if (await t.getVault(id))
280
+ const found = await t.getVault(id);
281
+ if (found) {
282
+ scopes.set(id, found.scope);
291
283
  continue;
284
+ }
292
285
  await this.audit(t, {
293
286
  actor: "host",
294
287
  action: "use",
@@ -334,7 +327,10 @@ export class VaultService {
334
327
  },
335
328
  };
336
329
  }
337
- return { row: chosen.row };
330
+ return {
331
+ row: chosen.row,
332
+ vault: scopes.get(chosen.row.vaultId) === "installation" ? "installation" : "user",
333
+ };
338
334
  });
339
335
  if ("result" in decided)
340
336
  return decided.result;
@@ -364,14 +360,8 @@ export class VaultService {
364
360
  reason: "unreadable",
365
361
  };
366
362
  }
367
- if (row.type === "oauth" && dueForRefresh(row.expiresAt)) {
368
- const refreshed = await this.refreshShared(kek, row, payload, input.sessionId, url);
369
- if (refreshed.status === "refused")
370
- return refreshed;
371
- payload = refreshed.payload;
372
- }
373
- const token = row.type === "bearer" ? payload.token : payload.accessToken;
374
- if (!token) {
363
+ const headers = sendHeaders(row, payload);
364
+ if (!headers) {
375
365
  return {
376
366
  status: "refused",
377
367
  url,
@@ -388,198 +378,15 @@ export class VaultService {
388
378
  target: url,
389
379
  outcome: "approved",
390
380
  }));
381
+ const binding = credentialBinding(row);
391
382
  return {
392
383
  status: "authorized",
393
384
  url,
394
- headers: { authorization: `Bearer ${token}` },
395
- };
396
- }
397
- // --- MCP OAuth connect (F9 C2) ---------------------------------------------
398
- /**
399
- * Starts signing the installation in to the MCP server at `url`: discovery, a client (the
400
- * given id, or one registered now), an S256 PKCE verifier and a `state`, kept for ten minutes
401
- * in a pending row (verifier and secret sealed, state hashed). Returns where to send the
402
- * browser. Installation vaults only.
403
- */
404
- async startOAuth(input) {
405
- const url = normalizeVaultUrl(input.url);
406
- const redirectUri = callbackUrl(input.redirectUri);
407
- await this.store.tx(async (t) => {
408
- await this.installationVault(t, input.vaultId);
409
- await t.deleteExpiredOAuthPending(new Date().toISOString());
410
- });
411
- const kek = this.kek();
412
- const server = await discoverOAuthServer(url, this.fetchImpl);
413
- const client = await oauthClient(server, {
414
- ...(input.clientId === undefined ? {} : { clientId: input.clientId }),
415
- redirectUri,
416
- fetchFn: this.fetchImpl,
417
- });
418
- const state = randomBytes(32).toString("base64url");
419
- const { authorizeUrl, codeVerifier } = await authorizationUrl(server, client, { redirectUri, state });
420
- const stateHash = hashState(state);
421
- const now = Date.now();
422
- const expiresAt = new Date(now + OAUTH_PENDING_MS).toISOString();
423
- const row = {
424
- stateHash,
425
- vaultId: input.vaultId,
426
- server: input.server,
427
- url,
428
- tokenEndpoint: server.metadata.token_endpoint,
429
- clientId: client.clientId,
430
- tokenEndpointAuth: client.tokenEndpointAuth,
431
- resource: server.resource ?? null,
432
- kekId: "",
433
- clientSecret: null,
434
- codeVerifier: new Uint8Array(),
435
- redirectUri,
436
- expiresAt,
437
- createdAt: new Date(now).toISOString(),
438
- };
439
- const verifier = sealBytes(kek, pendingAad(row, "codeVerifier"), Buffer.from(codeVerifier, "utf8"));
440
- const secret = client.clientSecret
441
- ? sealBytes(kek, pendingAad(row, "clientSecret"), Buffer.from(client.clientSecret, "utf8"))
442
- : null;
443
- await this.store.tx(async (t) => {
444
- await this.installationVault(t, input.vaultId);
445
- await t.insertOAuthPending({
446
- ...row,
447
- kekId: kekIdOf(kek),
448
- codeVerifier: verifier,
449
- clientSecret: secret,
450
- });
451
- await this.audit(t, {
452
- actor: "application",
453
- action: "oauth_start",
454
- vaultId: input.vaultId,
455
- target: url,
456
- outcome: "started",
457
- });
458
- });
459
- return { authorizeUrl, expiresAt };
460
- }
461
- /**
462
- * Finishes a connect from its callback: takes the pending row of `state` (deleted whatever
463
- * happens next), exchanges the code, and seals an `oauth` credential bound to the server's
464
- * URL in the installation vault, rotating the vault's OAuth credential for that URL when it
465
- * has one.
466
- */
467
- async finishOAuth(input) {
468
- const row = await this.store.tx((t) => t.takeOAuthPending(hashState(input.state)));
469
- if (!row || Date.parse(row.expiresAt) <= Date.now())
470
- throw new HttpError(400, "This sign-in is unknown, already used or expired: start the connect again", { code: "oauth_state_invalid" });
471
- const failed = async (error) => {
472
- await this.store.tx((t) => this.audit(t, {
473
- actor: "application",
474
- action: "oauth_connect",
475
- vaultId: row.vaultId,
476
- target: row.url,
477
- outcome: "failed",
478
- }));
479
- throw error;
385
+ headers,
386
+ ...(binding.via === undefined ? {} : { via: binding.via }),
387
+ ...(binding.identity === undefined ? {} : { identity: binding.identity }),
388
+ vault: decided.vault,
480
389
  };
481
- if (input.error !== undefined || !input.code)
482
- return failed(new HttpError(400, `The authorization server did not grant access (${oauthErrorName(input.error)})`, { code: "oauth_failed" }));
483
- const kek = this.kek();
484
- let client;
485
- let codeVerifier;
486
- try {
487
- codeVerifier = openSealed(kek, row, "codeVerifier", row.codeVerifier);
488
- client = {
489
- clientId: row.clientId,
490
- tokenEndpointAuth: row.tokenEndpointAuth,
491
- ...(row.clientSecret ? { clientSecret: openSealed(kek, row, "clientSecret", row.clientSecret) } : {}),
492
- };
493
- }
494
- catch (error) {
495
- if (!(error instanceof VaultCryptoError))
496
- throw error;
497
- return failed(new HttpError(500, "The sign-in could not be read with this vault key", { code: "oauth_failed" }));
498
- }
499
- let tokens;
500
- try {
501
- tokens = await exchangeCode({
502
- tokenEndpoint: row.tokenEndpoint,
503
- client,
504
- code: input.code,
505
- codeVerifier,
506
- redirectUri: row.redirectUri,
507
- ...(row.resource === null ? {} : { resource: row.resource }),
508
- fetchFn: this.fetchImpl,
509
- });
510
- }
511
- catch (error) {
512
- return failed(error instanceof HttpError ? error : new HttpError(502, "The OAuth code exchange failed", { code: "oauth_failed" }));
513
- }
514
- if (tokens.token_type.toLowerCase() !== "bearer")
515
- return failed(new HttpError(502, `The authorization server issued a ${tokens.token_type} token; MCP needs a bearer token`, {
516
- code: "oauth_failed",
517
- }));
518
- const payload = {
519
- accessToken: tokens.access_token,
520
- ...(tokens.refresh_token
521
- ? {
522
- refreshToken: tokens.refresh_token,
523
- tokenEndpoint: row.tokenEndpoint,
524
- clientId: client.clientId,
525
- tokenEndpointAuth: client.tokenEndpointAuth,
526
- ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
527
- }
528
- : {}),
529
- };
530
- const now = new Date();
531
- const expiresAt = typeof tokens.expires_in === "number" && Number.isFinite(tokens.expires_in)
532
- ? new Date(now.getTime() + tokens.expires_in * 1000).toISOString()
533
- : null;
534
- return this.store.tx(async (t) => {
535
- await this.installationVault(t, row.vaultId);
536
- const existing = (await t.credentialsForVault(row.vaultId, { type: "oauth" })).find((item) => bindingUrl(item) === row.url);
537
- if (existing) {
538
- await this.writePayload(t, kek, existing, payload, expiresAt, now.toISOString());
539
- await this.audit(t, {
540
- actor: "application",
541
- action: "oauth_connect",
542
- vaultId: row.vaultId,
543
- credentialId: existing.id,
544
- target: row.url,
545
- outcome: "rotated",
546
- });
547
- return { vaultId: row.vaultId, credentialId: existing.id };
548
- }
549
- const id = randomUUID();
550
- const sealed = encryptSecret(kek, credentialAad(row.vaultId, id, "oauth", row.url), Buffer.from(JSON.stringify(payload), "utf8"));
551
- await t.insertCredential({
552
- id,
553
- vaultId: row.vaultId,
554
- name: row.server,
555
- type: "oauth",
556
- bindingJson: JSON.stringify({ url: row.url }),
557
- expiresAt,
558
- createdAt: now.toISOString(),
559
- rotatedAt: null,
560
- ...sealed,
561
- });
562
- await this.audit(t, {
563
- actor: "application",
564
- action: "oauth_connect",
565
- vaultId: row.vaultId,
566
- credentialId: id,
567
- target: row.url,
568
- outcome: "created",
569
- });
570
- return { vaultId: row.vaultId, credentialId: id };
571
- });
572
- }
573
- /** An installation vault, else the 404 of a missing one (a user vault is a 400). */
574
- async installationVault(t, id) {
575
- const row = await t.getVault(id);
576
- if (!row || row.scope === "host")
577
- throw new VaultError(404, "Vault not found");
578
- if (row.scope !== "installation")
579
- throw new HttpError(400, "MCP OAuth connect stores its credential in an installation vault only", {
580
- code: "request_rejected",
581
- });
582
- return row;
583
390
  }
584
391
  // --- host model ----------------------------------------------------------------
585
392
  async getHostModel() {
@@ -675,128 +482,6 @@ export class VaultService {
675
482
  }));
676
483
  }
677
484
  // --- internals -----------------------------------------------------------------
678
- /** Calls the token endpoint outside any transaction, then stores the result. */
679
- /**
680
- * Refreshes an OAuth grant once for every concurrent use in this process. A rotating refresh
681
- * token is valid once, so two refreshes with it would fail the second, or make the provider
682
- * revoke the grant.
683
- */
684
- refreshShared(kek, row, payload, sessionId, url) {
685
- let pending = this.refreshing.get(row.id);
686
- if (!pending) {
687
- pending = this.refreshLatest(kek, row, payload, sessionId, url).finally(() => this.refreshing.delete(row.id));
688
- this.refreshing.set(row.id, pending);
689
- }
690
- return pending;
691
- }
692
- /** Uses a token another process refreshed instead of refreshing again, before and after. */
693
- async refreshLatest(kek, row, payload, sessionId, url) {
694
- const before = await this.rotatedSince(kek, row);
695
- if (before)
696
- return { status: "ok", payload: before };
697
- const refreshed = await this.refresh(kek, row, payload, sessionId, url);
698
- if (refreshed.status === "ok")
699
- return refreshed;
700
- // The loser of a race with another process fails with the spent refresh token.
701
- const after = await this.rotatedSince(kek, row);
702
- return after ? { status: "ok", payload: after } : refreshed;
703
- }
704
- /** The credential's payload when it was rotated after `row` was read and is not due. */
705
- async rotatedSince(kek, row) {
706
- const current = await this.store.tx((t) => t.getCredential(row.id));
707
- if (!current || current.vaultId !== row.vaultId || !isUserCredential(current))
708
- return undefined;
709
- if (current.rotatedAt === row.rotatedAt || dueForRefresh(current.expiresAt))
710
- return undefined;
711
- try {
712
- return readPayload(kek, current);
713
- }
714
- catch {
715
- return undefined;
716
- }
717
- }
718
- async refresh(kek, row, payload, sessionId, url) {
719
- const failRefresh = async () => {
720
- await this.store.tx((t) => this.audit(t, {
721
- actor: "host",
722
- action: "refresh",
723
- vaultId: row.vaultId,
724
- credentialId: row.id,
725
- sessionId,
726
- target: payload.tokenEndpoint,
727
- outcome: "refresh_failed",
728
- }));
729
- return {
730
- status: "refused",
731
- url,
732
- credentialIds: [row.id],
733
- reason: "refresh_failed",
734
- };
735
- };
736
- if (!payload.refreshToken || !payload.tokenEndpoint || !payload.clientId)
737
- return failRefresh();
738
- let next;
739
- try {
740
- const body = new URLSearchParams({
741
- grant_type: "refresh_token",
742
- refresh_token: payload.refreshToken,
743
- client_id: payload.clientId,
744
- });
745
- const headers = {
746
- "content-type": "application/x-www-form-urlencoded",
747
- };
748
- if (payload.tokenEndpointAuth === "client_secret_post") {
749
- if (!payload.clientSecret)
750
- return failRefresh();
751
- body.set("client_secret", payload.clientSecret);
752
- }
753
- if (payload.tokenEndpointAuth === "client_secret_basic") {
754
- if (!payload.clientSecret)
755
- return failRefresh();
756
- const encoded = Buffer.from(`${encodeURIComponent(payload.clientId)}:${encodeURIComponent(payload.clientSecret)}`).toString("base64");
757
- headers.authorization = `Basic ${encoded}`;
758
- }
759
- const response = await this.fetchImpl(payload.tokenEndpoint, {
760
- method: "POST",
761
- headers,
762
- body: body.toString(),
763
- redirect: "error",
764
- signal: AbortSignal.timeout(this.refreshTimeoutMs),
765
- });
766
- if (!response.ok)
767
- return failRefresh();
768
- const json = (await response.json());
769
- if (typeof json.access_token !== "string" || json.access_token.length === 0)
770
- return failRefresh();
771
- next = {
772
- ...payload,
773
- accessToken: json.access_token,
774
- refreshToken: typeof json.refresh_token === "string"
775
- ? json.refresh_token
776
- : payload.refreshToken,
777
- };
778
- const expiresAt = typeof json.expires_in === "number" && Number.isFinite(json.expires_in)
779
- ? new Date(Date.now() + json.expires_in * 1000).toISOString()
780
- : null;
781
- await this.store.tx(async (t) => {
782
- const current = await this.credentialRow(t, row.vaultId, row.id);
783
- await this.writePayload(t, kek, current, next, expiresAt, new Date().toISOString());
784
- await this.audit(t, {
785
- actor: "host",
786
- action: "refresh",
787
- vaultId: row.vaultId,
788
- credentialId: row.id,
789
- sessionId,
790
- target: payload.tokenEndpoint,
791
- outcome: "approved",
792
- });
793
- });
794
- }
795
- catch {
796
- return failRefresh();
797
- }
798
- return { status: "ok", payload: next };
799
- }
800
485
  async replay(t, key, body, create) {
801
486
  const hash = createHash("sha256")
802
487
  .update(requestHash(body))
@@ -815,10 +500,10 @@ export class VaultService {
815
500
  });
816
501
  return created;
817
502
  }
818
- /** Re-seals a user credential's payload and returns the updated row. */
819
- async writePayload(t, kek, row, payload, expiresAt, rotatedAt) {
503
+ /** Re-seals a user credential's payload with its new binding and returns the updated row. */
504
+ async writePayload(t, kek, row, payload, binding, rotatedAt) {
820
505
  const sealed = encryptSecret(kek, credentialAad(row.vaultId, row.id, row.type, bindingUrl(row)), Buffer.from(JSON.stringify(payload), "utf8"));
821
- const patch = { expiresAt, rotatedAt, ...sealed };
506
+ const patch = { rotatedAt, bindingJson: JSON.stringify(binding), ...sealed };
822
507
  if (!(await t.updateCredential(row.vaultId, row.id, patch)))
823
508
  throw new VaultError(404, "Credential not found");
824
509
  return { ...row, ...patch };
@@ -937,40 +622,6 @@ export class VaultService {
937
622
  });
938
623
  }
939
624
  }
940
- function hashState(state) {
941
- return createHash("sha256").update(state, "utf8").digest("hex");
942
- }
943
- function pendingAad(row, field) {
944
- return Buffer.from(canonical({ table: "oauth_pending", stateHash: row.stateHash, vaultId: row.vaultId, field }), "utf8");
945
- }
946
- function openSealed(kek, row, field, sealed) {
947
- const plaintext = openBytes(kek, pendingAad(row, field), Buffer.from(sealed), row.kekId);
948
- try {
949
- return plaintext.toString("utf8");
950
- }
951
- finally {
952
- plaintext.fill(0);
953
- }
954
- }
955
- /** The callback URL the authorization server sends the browser back to. */
956
- function callbackUrl(value) {
957
- let url;
958
- try {
959
- url = new URL(value);
960
- }
961
- catch {
962
- throw new VaultError(400, "The OAuth redirect URI is invalid");
963
- }
964
- if (url.protocol !== "https:" && url.protocol !== "http:")
965
- throw new VaultError(400, "The OAuth redirect URI must be http or https");
966
- return url.href;
967
- }
968
- /** An authorization server's `error`, safe to show: an OAuth error code, or a stand-in. */
969
- function oauthErrorName(error) {
970
- if (error === undefined)
971
- return "no code";
972
- return /^[A-Za-z0-9_.-]{1,64}$/.test(error) ? error : "error";
973
- }
974
625
  function isUserCredential(row) {
975
626
  return row.type !== "model";
976
627
  }
@@ -988,14 +639,20 @@ function vaultInfoOf(row) {
988
639
  createdAt: row.createdAt,
989
640
  };
990
641
  }
642
+ function credentialBinding(row) {
643
+ return JSON.parse(row.bindingJson);
644
+ }
991
645
  function credentialInfoOf(row) {
646
+ const binding = credentialBinding(row);
992
647
  return {
993
648
  id: row.id,
994
649
  vaultId: row.vaultId,
995
650
  name: row.name,
996
651
  type: row.type,
997
- binding: JSON.parse(row.bindingJson),
998
- ...(row.expiresAt ? { expiresAt: row.expiresAt } : {}),
652
+ binding: { url: binding.url },
653
+ ...(binding.headerNames === undefined ? {} : { headerNames: binding.headerNames }),
654
+ ...(binding.via === undefined ? {} : { via: binding.via }),
655
+ ...(binding.identity === undefined ? {} : { identity: binding.identity }),
999
656
  createdAt: row.createdAt,
1000
657
  ...(row.rotatedAt ? { rotatedAt: row.rotatedAt } : {}),
1001
658
  };
@@ -1099,44 +756,32 @@ function choose(matches, selection) {
1099
756
  };
1100
757
  return { kind: "use", row: chosen };
1101
758
  }
1102
- function dueForRefresh(expiresAt) {
1103
- if (!expiresAt)
1104
- return false;
1105
- return Date.parse(expiresAt) - Date.now() <= REFRESH_SKEW_MS;
1106
- }
1107
- function payloadFromCreate(body) {
1108
- if (body.auth.type === "bearer")
1109
- return { token: body.auth.token };
1110
- const refresh = body.auth.refresh;
1111
- const tokenEndpoint = refresh
1112
- ? normalizeVaultUrl(refresh.tokenEndpoint, { httpsOnly: true })
1113
- : undefined;
1114
- if (body.auth.expiresAt)
1115
- requireTimestamp(body.auth.expiresAt);
759
+ /** The sealed part of a create or rotate: a token, or a header map with lower-cased names. */
760
+ function payloadOf(auth) {
761
+ if (auth.type === "bearer")
762
+ return { token: auth.token };
1116
763
  return {
1117
- accessToken: body.auth.accessToken,
1118
- ...(refresh
1119
- ? {
1120
- refreshToken: refresh.refreshToken,
1121
- tokenEndpoint,
1122
- clientId: refresh.clientId,
1123
- tokenEndpointAuth: refresh.tokenEndpointAuth.type,
1124
- ...(refresh.tokenEndpointAuth.type === "none"
1125
- ? {}
1126
- : { clientSecret: refresh.tokenEndpointAuth.clientSecret }),
1127
- }
1128
- : {}),
764
+ headers: Object.fromEntries(Object.entries(auth.headers).map(([name, value]) => [name.toLowerCase(), value])),
1129
765
  };
1130
766
  }
1131
- function expiresFromCreate(body) {
1132
- if (body.auth.type !== "oauth" || !body.auth.expiresAt)
1133
- return null;
1134
- return requireTimestamp(body.auth.expiresAt);
767
+ /** The unsealed binding of a credential that sends `payload`; refuses a reserved header name. */
768
+ function bindingOf(url, payload, routing) {
769
+ const sent = payload.headers ? Object.keys(payload.headers) : ["authorization"];
770
+ checkCredentialHeaders(sent, routing.identity ?? undefined);
771
+ return {
772
+ url,
773
+ ...(routing.via ? { via: routing.via } : {}),
774
+ ...(routing.identity ? { identity: { header: routing.identity.header.toLowerCase() } } : {}),
775
+ ...(payload.headers ? { headerNames: sent } : {}),
776
+ };
1135
777
  }
1136
- function requireTimestamp(value) {
1137
- if (Number.isNaN(Date.parse(value)))
1138
- throw new VaultError(400, "Credential expiry is invalid");
1139
- return new Date(Date.parse(value)).toISOString();
778
+ /** The headers a credential sends, or undefined when its payload holds none. */
779
+ function sendHeaders(row, payload) {
780
+ if (row.type === "headers")
781
+ return payload.headers && Object.keys(payload.headers).length > 0
782
+ ? { ...payload.headers }
783
+ : undefined;
784
+ return payload.token ? { authorization: `Bearer ${payload.token}` } : undefined;
1140
785
  }
1141
786
  export function modelBinding(row) {
1142
787
  const binding = JSON.parse(row.bindingJson);