@zackbart/connecta 0.31.0 → 0.33.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 (169) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +30 -20
  3. package/bin/connecta.mjs +25 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/auth-elicitation.js +1 -1
  10. package/dist/call-admission-context.js +15 -0
  11. package/dist/call-admission.js +30 -43
  12. package/dist/call-recovery.js +6 -2
  13. package/dist/catalog-service.js +7 -2
  14. package/dist/config-value-sources.js +14 -9
  15. package/dist/config.d.ts +58 -78
  16. package/dist/config.js +143 -47
  17. package/dist/connector-access.d.ts +4 -33
  18. package/dist/connector-access.js +15 -99
  19. package/dist/connector-caller.d.ts +7 -1
  20. package/dist/connector-caller.js +3 -2
  21. package/dist/connectors/api-connector.d.ts +27 -4
  22. package/dist/connectors/api-connector.js +2 -0
  23. package/dist/connectors/guarded-fetch.d.ts +9 -0
  24. package/dist/connectors/guarded-fetch.js +7 -0
  25. package/dist/connectors/remote-mcp.js +2 -0
  26. package/dist/describe-config.d.ts +32 -7
  27. package/dist/describe-config.js +9 -18
  28. package/dist/errors.d.ts +1 -1
  29. package/dist/errors.js +1 -0
  30. package/dist/execute.js +16 -14
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +12 -7
  34. package/dist/membership.d.ts +51 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/meta-tools.js +17 -19
  37. package/dist/module-contracts.d.ts +23 -6
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -2
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/contract.d.ts +151 -29
  43. package/dist/operator-ui/endpoints.js +192 -0
  44. package/dist/operator-ui/fix-prompts.js +4 -4
  45. package/dist/operator-ui/generated.js +5 -4
  46. package/dist/operator-ui/member-page.js +112 -0
  47. package/dist/operator-ui/model.d.ts +15 -7
  48. package/dist/operator-ui/view.d.ts +84 -19
  49. package/dist/operator-ui/view.js +83 -21
  50. package/dist/page-styles.js +1 -1
  51. package/dist/program-writes.js +2 -2
  52. package/dist/provider.d.ts +63 -11
  53. package/dist/provider.js +46 -7
  54. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  55. package/dist/providers/_shared/google/workspace.js +31 -9
  56. package/dist/providers/_shared/rest/tools.js +2 -2
  57. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  58. package/dist/providers/_shared/rest/value-safety.js +699 -7
  59. package/dist/providers/breeze/skill.generated.js +2 -2
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +3 -3
  62. package/dist/providers/cloudflare/rest.js +25 -3
  63. package/dist/providers/cloudflare/skill.generated.js +2 -2
  64. package/dist/providers/cloudflare/value-safety.js +32 -0
  65. package/dist/providers/docs/index.d.ts +1 -1
  66. package/dist/providers/docs/index.js +5 -5
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.d.ts +1 -1
  69. package/dist/providers/drive/index.js +4 -4
  70. package/dist/providers/drive/skill.generated.js +2 -2
  71. package/dist/providers/forms/index.d.ts +1 -1
  72. package/dist/providers/forms/index.js +5 -5
  73. package/dist/providers/forms/skill.generated.js +2 -2
  74. package/dist/providers/github/skill.generated.js +2 -2
  75. package/dist/providers/gmail/index.d.ts +1 -1
  76. package/dist/providers/gmail/index.js +279 -26
  77. package/dist/providers/gmail/skill.generated.js +2 -2
  78. package/dist/providers/linear/index.js +1 -1
  79. package/dist/providers/mixpanel/index.d.ts +27 -12
  80. package/dist/providers/mixpanel/index.js +58 -6
  81. package/dist/providers/mixpanel/operations.js +20 -0
  82. package/dist/providers/mixpanel/rest.js +603 -0
  83. package/dist/providers/mixpanel/skill.generated.js +3 -2
  84. package/dist/providers/mixpanel/value-safety.js +16 -0
  85. package/dist/providers/notion/index.d.ts +7 -0
  86. package/dist/providers/notion/index.js +116 -11
  87. package/dist/providers/notion/rest.js +183 -24
  88. package/dist/providers/notion/skill.generated.js +4 -4
  89. package/dist/providers/notion/value-safety.js +30 -5
  90. package/dist/providers/overflow/index.js +1 -1
  91. package/dist/providers/planning-center/index.d.ts +27 -0
  92. package/dist/providers/planning-center/index.js +287 -25
  93. package/dist/providers/planning-center/skill.generated.js +24 -4
  94. package/dist/providers/sheets/index.d.ts +4 -1
  95. package/dist/providers/sheets/index.js +9 -6
  96. package/dist/providers/sheets/skill.generated.js +2 -2
  97. package/dist/providers/slides/index.d.ts +1 -1
  98. package/dist/providers/slides/index.js +6 -6
  99. package/dist/providers/slides/skill.generated.js +2 -2
  100. package/dist/providers/stripe/rest.js +9 -2
  101. package/dist/providers/stripe/skill.generated.js +2 -2
  102. package/dist/providers/stripe/value-safety.js +22 -0
  103. package/dist/providers/vercel/index.js +2 -2
  104. package/dist/providers/vercel/rest.js +20 -2
  105. package/dist/providers/vercel/skill.generated.js +2 -2
  106. package/dist/providers/vercel/value-safety.js +22 -0
  107. package/dist/providers/wispr-flow/index.d.ts +14 -0
  108. package/dist/providers/wispr-flow/index.js +117 -0
  109. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  110. package/dist/registry.d.ts +2 -3
  111. package/dist/registry.js +8 -9
  112. package/dist/routes/access-tokens.js +82 -75
  113. package/dist/routes/activity-disclosure.js +17 -18
  114. package/dist/routes/activity.js +34 -54
  115. package/dist/routes/actor-labels.js +33 -4
  116. package/dist/routes/agent-path.js +43 -0
  117. package/dist/routes/connect.js +16 -12
  118. package/dist/routes/credentials.js +16 -40
  119. package/dist/routes/mcp.js +155 -85
  120. package/dist/routes/oauth-management.js +11 -5
  121. package/dist/routes/oauth.js +3 -9
  122. package/dist/routes/operator.js +25 -17
  123. package/dist/routes/shared.d.ts +60 -20
  124. package/dist/routes/shared.js +187 -96
  125. package/dist/routes/ui-config.js +437 -97
  126. package/dist/routes/ui-data.js +2 -2
  127. package/dist/routes/ui.js +9 -16
  128. package/dist/runtime/call-admission.js +9 -0
  129. package/dist/server.js +8 -0
  130. package/dist/skills.js +5 -4
  131. package/dist/storage/keys.js +9 -1
  132. package/dist/tool-safety.js +13 -5
  133. package/dist/types.d.ts +12 -1
  134. package/dist/ui.d.ts +2 -2
  135. package/dist/ui.js +17 -15
  136. package/dist/usage-guide.js +3 -3
  137. package/dist/version.d.ts +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/workspace-address.js +9 -0
  140. package/dist/workspace-subject.js +23 -0
  141. package/documentation/README.md +46 -22
  142. package/documentation/architecture.md +378 -58
  143. package/documentation/auth.md +806 -215
  144. package/documentation/code-mode.md +93 -81
  145. package/documentation/deploying.md +128 -26
  146. package/documentation/integrating.md +145 -22
  147. package/documentation/meta-tools.md +58 -46
  148. package/documentation/migration-0.33.md +344 -0
  149. package/documentation/operating.md +14 -9
  150. package/documentation/operator-tests.md +117 -31
  151. package/documentation/operator-ui.md +326 -55
  152. package/documentation/provider-auth-migration.md +42 -27
  153. package/documentation/provider-migration-0.29.md +3 -3
  154. package/examples/worker/AGENTS.md +35 -8
  155. package/examples/worker/README.md +92 -53
  156. package/examples/worker/src/connecta.config.ts +41 -20
  157. package/examples/worker/wrangler.jsonc +3 -0
  158. package/package.json +7 -2
  159. package/templates/node/.env.example +3 -0
  160. package/templates/node/AGENTS.md +33 -10
  161. package/templates/node/Dockerfile +2 -2
  162. package/templates/node/README.md +71 -44
  163. package/templates/node/docker-compose.yml +3 -2
  164. package/templates/node/package.json +4 -3
  165. package/templates/node/src/adopt-token.ts +23 -0
  166. package/templates/node/src/connecta.config.ts +33 -12
  167. package/templates/node/src/rotate-token.ts +17 -0
  168. package/dist/tool-safety.d.ts +0 -9
  169. package/templates/node/src/provision-token.ts +0 -17
@@ -1,19 +1,130 @@
1
1
  import { authorizationCredential, isMachineCredential } from "./inbound-credential.js";
2
- import { assertKnownOptions, keys, optionsOf } from "./config-schema.js";
2
+ import { assertKnownOptions, ConfigError, optionsOf } from "./config-schema.js";
3
3
  import { routeAccessTokens } from "./routes/access-tokens.js";
4
4
  import { validIdentityReference } from "./identity.js";
5
5
  import { accessTokenKeys } from "./storage/keys.js";
6
+ import { AGENT_NAMESPACE, agentSubjectId, assertAgentNames, isAgentName, trustAgentProvider, } from "./agent-identity.js";
6
7
  const TOKEN_PREFIX = "cta_";
7
8
  const TOKEN_BYTES = 32;
8
9
  const TOKEN_VALUE_RE = /^cta_[A-Za-z0-9_-]{43}$/;
9
10
  const MAX_NAME_CHARACTERS = 80;
10
- const DEFAULT_MAX_ACTIVE = 100;
11
- const MAX_CONFIGURED_ACTIVE = 1_000;
12
11
  const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
13
12
  const encoder = new TextEncoder();
13
+ /** The claiming agent, or a sentinel that matches no agent when the claim is unreadable. */
14
+ function parseClaim(raw) {
15
+ try {
16
+ const value = JSON.parse(raw);
17
+ if (value.version === 1 && isAgentName(value.agent))
18
+ return value.agent;
19
+ }
20
+ catch {
21
+ // Unreadable: fail closed below.
22
+ }
23
+ return "<unreadable claim>";
24
+ }
25
+ /** Why a token operation was refused, for routes and provisioning scripts. */
26
+ export class AccessTokenError extends Error {
27
+ code;
28
+ name = "AccessTokenError";
29
+ constructor(code, message) {
30
+ super(message);
31
+ this.code = code;
32
+ }
33
+ }
34
+ /** What each operation tells its caller when its outcome cannot be settled. */
35
+ const UNKNOWN = {
36
+ rotate: "Rotation outcome unknown: no secret was returned, so rotate again for a replacement",
37
+ revoke: "Revocation outcome unknown; revoke again",
38
+ adopt: "Adoption outcome unknown; run the same adoption again",
39
+ };
40
+ const isOutcomeUnknown = (error) => error instanceof AccessTokenError && error.code === "outcome_unknown";
41
+ function slotChanged() {
42
+ return new AccessTokenError("slot_changed", "The agent's token slot changed since it was read; reload and retry");
43
+ }
44
+ function throwIfAborted(signal) {
45
+ if (signal?.aborted)
46
+ throw new AccessTokenError("aborted", "The request ended before the token operation finished");
47
+ }
48
+ /** Attempts any one reconciliation makes before reporting an unknown outcome. */
49
+ const RECONCILE_ATTEMPTS = 8;
50
+ /** Wall-clock time one token operation may take, unless the request ends sooner. */
51
+ const OPERATION_WINDOW_MS = 2_000;
52
+ /**
53
+ * The one wall-clock deadline for a whole token operation, created at its
54
+ * entry: every storage await, before and after the commit (the adoption
55
+ * claim, record and lookup writes, the commit's own acknowledgement, reading
56
+ * back an unanswered commit, revoking an undeliverable rotation, completing an
57
+ * adoption) is raced against it. None starts once it has passed, and a
58
+ * success decided after it is not reported; at the deadline the operation
59
+ * reports `outcome_unknown`, or `aborted` when the request ended before
60
+ * anything could have been published.
61
+ *
62
+ * An operation still in flight at the deadline is abandoned, not cancelled.
63
+ * That is safe because the only ones that can be in flight are:
64
+ * - reads, which change nothing;
65
+ * - writes of this call's own record or lookup, which are inert until a slot
66
+ * names them;
67
+ * - the adoption claim, which is inert to admission and leaves the token
68
+ * resumable by the same agent;
69
+ * - the commit compare-and-set itself, whose outcome is then unknown and is
70
+ * reported as such; whatever the slot ends up naming is what admits;
71
+ * - the revoke compare-and-set from `issued` naming one record to `revoked`,
72
+ * which can only stop a token, and fails if the slot changed meanwhile;
73
+ * - the adoption compare-and-set of a lookup from the legacy record to the
74
+ * new one, which fails if anyone else moved it.
75
+ * If the abandoned revoke never lands, an undelivered rotation secret may keep
76
+ * admitting, but that secret never left the server: a liveness problem the
77
+ * operator sees as `outcome_unknown` and fixes by rotating, never a credential
78
+ * exposure.
79
+ */
80
+ class OperationWindow {
81
+ unknownMessage;
82
+ endsAt;
83
+ constructor(deadline, unknownMessage) {
84
+ this.unknownMessage = unknownMessage;
85
+ const window = Date.now() + OPERATION_WINDOW_MS;
86
+ this.endsAt = deadline === undefined ? window : Math.min(window, deadline);
87
+ }
88
+ /** The `outcome_unknown` error this operation reports. */
89
+ unknown() {
90
+ return new AccessTokenError("outcome_unknown", this.unknownMessage);
91
+ }
92
+ get open() {
93
+ return Date.now() < this.endsAt;
94
+ }
95
+ /** A result decided after the deadline is not trusted: it becomes `outcome_unknown`. */
96
+ settle(value) {
97
+ if (!this.open)
98
+ throw this.unknown();
99
+ return value;
100
+ }
101
+ /** Start `operation` only while the window is open, and stop waiting when it closes. */
102
+ async run(operation) {
103
+ const remaining = this.endsAt - Date.now();
104
+ if (remaining <= 0)
105
+ throw this.unknown();
106
+ let timer;
107
+ try {
108
+ return await Promise.race([
109
+ operation(),
110
+ new Promise((_, reject) => {
111
+ timer = setTimeout(() => reject(this.unknown()), remaining);
112
+ }),
113
+ ]);
114
+ }
115
+ finally {
116
+ clearTimeout(timer);
117
+ }
118
+ }
119
+ }
120
+ /** A revoked slot carrying `revocationId`, which one revoking call generates once and reuses. */
121
+ function revokedSlot(agent, revocationId) {
122
+ return { version: 1, agent, state: "revoked", revokedAt: new Date().toISOString(), revocationId };
123
+ }
14
124
  const recordKey = accessTokenKeys.record;
15
125
  const lookupKey = accessTokenKeys.lookup;
16
- const ACTIVE_KEY = accessTokenKeys.active;
126
+ const slotKey = accessTokenKeys.agent;
127
+ const adoptedKey = accessTokenKeys.adopted;
17
128
  function bytesToBase64Url(bytes) {
18
129
  let binary = "";
19
130
  for (const byte of bytes)
@@ -26,18 +137,27 @@ function bytesToHex(bytes) {
26
137
  async function hashToken(token) {
27
138
  return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(token))));
28
139
  }
29
- function normalizeName(value) {
30
- if (typeof value !== "string") {
31
- throw new Error("Token name must be a string");
32
- }
33
- const compact = value.replace(/\s+/gu, " ").trim();
34
- if (!compact)
35
- throw new Error("Token name cannot be empty");
36
- if (Array.from(compact).length > MAX_NAME_CHARACTERS) {
37
- throw new Error(`Token name cannot exceed ${MAX_NAME_CHARACTERS} characters`);
38
- }
39
- return compact;
140
+ const REVISION_RE = /^[A-Za-z0-9_-]{22}$/u;
141
+ /** Whether `value` has the shape of a slot revision. */
142
+ export function isSlotRevision(value) {
143
+ return typeof value === "string" && REVISION_RE.test(value);
40
144
  }
145
+ /**
146
+ * A slot's revision: the first 128 bits of a domain-separated SHA-256 of the
147
+ * agent's name and its raw stored slot, base64url. Every commit stores a new
148
+ * value (a fresh record id, or the revocation of one), so the revision
149
+ * changes on each and never otherwise. The raw slot names a record id, which
150
+ * only reaches out hashed; the slot holds no secret or digest to begin with.
151
+ */
152
+ async function slotRevision(agent, raw) {
153
+ const input = `connecta:access-token-slot-revision:v1\n${agent}\n${raw === null ? "none" : `slot:${raw}`}`;
154
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(input)));
155
+ return bytesToBase64Url(digest.slice(0, 16));
156
+ }
157
+ async function matchesExpected(agent, raw, expect) {
158
+ return expect === undefined || (await slotRevision(agent, raw)) === expect;
159
+ }
160
+ const isTime = (value) => typeof value === "string" && value !== "" && Number.isFinite(Date.parse(value));
41
161
  function parseRecord(raw) {
42
162
  try {
43
163
  const value = JSON.parse(raw);
@@ -51,12 +171,11 @@ function parseRecord(raw) {
51
171
  !/^[0-9a-f]{64}$/u.test(value.tokenHash) ||
52
172
  typeof value.tokenPrefix !== "string" ||
53
173
  !/^cta_[A-Za-z0-9_-]{8}$/u.test(value.tokenPrefix) ||
54
- typeof value.createdAt !== "string" ||
55
- !Number.isFinite(Date.parse(value.createdAt)) ||
174
+ !isTime(value.createdAt) ||
56
175
  typeof value.createdBy !== "string" ||
57
176
  (value.principal !== undefined && !validIdentityReference(value.principal)) ||
58
- (value.revokedAt !== undefined &&
59
- (typeof value.revokedAt !== "string" || !value.revokedAt || !Number.isFinite(Date.parse(value.revokedAt)))) ||
177
+ (value.agent !== undefined && !isAgentName(value.agent)) ||
178
+ (value.revokedAt !== undefined && !isTime(value.revokedAt)) ||
60
179
  (value.revokedBy !== undefined && typeof value.revokedBy !== "string")) {
61
180
  throw new Error("invalid token record");
62
181
  }
@@ -77,14 +196,29 @@ function parseLookup(raw) {
77
196
  return null;
78
197
  }
79
198
  }
80
- function metadata(record) {
81
- return {
82
- id: record.id,
83
- name: record.name,
84
- tokenPrefix: record.tokenPrefix,
85
- createdAt: record.createdAt,
86
- ...(record.revokedAt ? { revokedAt: record.revokedAt } : {}),
87
- };
199
+ function parseSlot(raw, agent) {
200
+ try {
201
+ const value = JSON.parse(raw);
202
+ const valid = value.version === 1 &&
203
+ value.agent === agent &&
204
+ (value.state === "issued"
205
+ ? typeof value.tokenId === "string" && ID_RE.test(value.tokenId) && isTime(value.issuedAt)
206
+ : value.state === "revoked" && isTime(value.revokedAt));
207
+ if (!valid)
208
+ throw new Error("invalid slot");
209
+ return value;
210
+ }
211
+ catch {
212
+ throw new Error("Stored agent token slot is invalid or corrupted");
213
+ }
214
+ }
215
+ async function slotView(name, raw, slot) {
216
+ const revision = await slotRevision(name, raw);
217
+ if (!slot)
218
+ return { name, state: "none", revision };
219
+ return slot.state === "issued"
220
+ ? { name, state: "issued", issuedAt: slot.issuedAt, revision }
221
+ : { name, state: "revoked", revokedAt: slot.revokedAt, revision };
88
222
  }
89
223
  function unauthorized() {
90
224
  return {
@@ -99,32 +233,36 @@ function unauthorized() {
99
233
  };
100
234
  }
101
235
  /**
102
- * Deployment-scoped personal access tokens. Secret material is never
236
+ * Agent tokens: one slot per agent declared in code. Secret material is never
103
237
  * recoverable: authentication indexes a SHA-256 digest of a random 256-bit
104
- * token, while separately enumerable metadata powers operator management.
238
+ * token, and only that digest persists. A token admits only while its record
239
+ * is bound to a currently declared agent and that agent's slot names it.
105
240
  */
106
241
  export class AccessTokenManager {
107
242
  storage;
108
243
  auth;
109
- maxActive;
244
+ agents;
245
+ agentOrder;
110
246
  constructor(storage, options = {}) {
111
247
  this.storage = storage;
112
- // Issuance claims capacity by compare-and-set and listing enumerates
113
- // records; refuse a store without either here, not at the first create.
248
+ // Issuance swaps slots by compare-and-set; refuse a store without it here,
249
+ // not at the first rotation.
114
250
  if (typeof storage?.list !== "function" || typeof storage.compareAndSet !== "function") {
115
251
  throw new Error("accessTokens requires storage that implements list and compareAndSet " +
116
252
  "(d1Storage, sqliteStorage, or memoryStorage)");
117
253
  }
118
- const maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
119
- if (!Number.isInteger(maxActive) || maxActive < 1 || maxActive > MAX_CONFIGURED_ACTIVE) {
120
- throw new Error(`accessTokens.maxActive must be a whole number from 1 to ${MAX_CONFIGURED_ACTIVE}`);
121
- }
122
- this.maxActive = maxActive;
123
- this.auth = {
254
+ const agents = options.agents ?? [];
255
+ assertAgentNames(agents, "agents");
256
+ this.agentOrder = Object.freeze([...agents]);
257
+ this.agents = new Set(agents);
258
+ this.auth = trustAgentProvider({
124
259
  kind: "access_token",
125
260
  recognizesCredential: isMachineCredential,
126
- activityActorNamespace: "connecta:access-tokens:v1",
261
+ activityActorNamespace: AGENT_NAMESPACE,
127
262
  activityActorLabel: async (id) => {
263
+ if (this.agents.has(id))
264
+ return id;
265
+ // Rows written before agents carry the old token record id.
128
266
  try {
129
267
  return (await this.read(id))?.name;
130
268
  }
@@ -133,7 +271,13 @@ export class AccessTokenManager {
133
271
  }
134
272
  },
135
273
  authorize: (request) => this.authorize(request).catch(() => unauthorized()),
136
- };
274
+ });
275
+ }
276
+ declared(name) {
277
+ if (typeof name !== "string" || !this.agents.has(name)) {
278
+ throw new AccessTokenError("unknown_agent", "That agent is not declared in this deployment's agents");
279
+ }
280
+ return name;
137
281
  }
138
282
  async read(id) {
139
283
  if (!ID_RE.test(id))
@@ -144,115 +288,496 @@ export class AccessTokenManager {
144
288
  throw new Error("Stored access token id mismatch");
145
289
  return record;
146
290
  }
291
+ async readSlot(name) {
292
+ const raw = await this.storage.get(slotKey(name));
293
+ return { raw, slot: raw === null ? null : parseSlot(raw, name) };
294
+ }
295
+ /** One slot per declared agent, in declaration order. */
147
296
  async list() {
148
- const keys = await this.storage.list(accessTokenKeys.recordPrefix);
149
- const records = await Promise.all(keys.map(async (key) => {
150
- const raw = await this.storage.get(key);
151
- return raw ? parseRecord(raw) : null;
297
+ return Promise.all(this.agentOrder.map(async (name) => {
298
+ const { raw, slot } = await this.readSlot(name);
299
+ return slotView(name, raw, slot);
152
300
  }));
153
- return records
154
- .filter((record) => Boolean(record))
155
- .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
156
- .map(metadata);
157
301
  }
158
- async create(name, createdBy) {
159
- const normalizedName = normalizeName(name);
160
- if (typeof createdBy !== "string" && !validIdentityReference(createdBy)) {
161
- throw new Error("Invalid token owner");
302
+ /**
303
+ * Issue the agent's first token, or replace its current one.
304
+ *
305
+ * 1. The slot is read. With `expect`, a slot whose revision differs refuses
306
+ * with `slot_changed` before any secret is minted.
307
+ * 2. The new record and its lookup are written. They are inert: admission
308
+ * requires the slot to name the record.
309
+ * 3. `signal` is checked.
310
+ * 4. One compare-and-set of the slot, from exactly what was read to
311
+ * `issued` with the new record, is the only commit point. From it the
312
+ * new secret admits and the previous one refuses. Of concurrent
313
+ * rotations exactly one commits; the others publish nothing and refuse
314
+ * with `slot_changed` when they named `expect`, a conflict otherwise.
315
+ * 5. If the request has ended once the commit is decided, the secret cannot
316
+ * be delivered, so the slot moves forward to `revoked`. Everything after
317
+ * the commit runs inside one `OperationWindow`; if it cannot settle in
318
+ * time this rejects with `outcome_unknown` ("rotate again"), never success.
319
+ *
320
+ * Nothing is ever deleted except this call's own record and lookup, while
321
+ * they are known to be uncommitted. The previous record and lookup stay
322
+ * behind, inert, because the slot no longer names them.
323
+ */
324
+ async rotate(name, issuedBy = "operator", options = {}) {
325
+ const { signal, expect } = options;
326
+ throwIfAborted(signal);
327
+ const agent = this.declared(name);
328
+ const window = new OperationWindow(options.deadline, UNKNOWN.rotate);
329
+ let raw;
330
+ try {
331
+ ({ raw } = await window.run(() => this.readSlot(agent)));
162
332
  }
163
- const secretBytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
164
- const token = TOKEN_PREFIX + bytesToBase64Url(secretBytes);
165
- const hash = await hashToken(token);
166
- if (await this.storage.get(lookupKey(hash))) {
167
- throw new Error("Access token collision; create another token");
333
+ catch (error) {
334
+ // Nothing was written: a caller that left hears `aborted`.
335
+ if (isOutcomeUnknown(error))
336
+ throwIfAborted(signal);
337
+ throw error;
168
338
  }
339
+ if (!(await matchesExpected(agent, raw, expect)))
340
+ throw slotChanged();
341
+ const token = TOKEN_PREFIX + bytesToBase64Url(crypto.getRandomValues(new Uint8Array(TOKEN_BYTES)));
342
+ const hash = await hashToken(token);
169
343
  const record = {
170
344
  version: 1,
171
345
  id: crypto.randomUUID(),
172
- name: normalizedName,
346
+ name: agent,
173
347
  tokenHash: hash,
174
348
  tokenPrefix: token.slice(0, 12),
175
349
  createdAt: new Date().toISOString(),
176
- createdBy: typeof createdBy === "string" ? createdBy : `${createdBy.namespace}:${createdBy.id}`,
177
- ...(typeof createdBy === "string" ? {} : { principal: { ...createdBy } }),
350
+ createdBy: issuedBy,
351
+ agent,
352
+ };
353
+ const recordRaw = JSON.stringify(record);
354
+ const lookupRaw = JSON.stringify({ version: 1, id: record.id });
355
+ const next = { version: 1, agent, state: "issued", tokenId: record.id, issuedAt: record.createdAt };
356
+ const nextRaw = JSON.stringify(next);
357
+ const view = await slotView(agent, nextRaw, next);
358
+ // This call's own record and lookup, while known to be uncommitted: one
359
+ // conditional delete each, matching exactly what was written, inside the
360
+ // window like everything else.
361
+ // Each delete is raced on its own, so a stalled first delete can never
362
+ // start the second after the deadline.
363
+ const discard = async () => {
364
+ await window.run(() => this.storage.compareAndSet(lookupKey(hash), lookupRaw, null)).catch(() => false);
365
+ await window.run(() => this.storage.compareAndSet(recordKey(record.id), recordRaw, null)).catch(() => false);
178
366
  };
179
367
  try {
180
- await this.updateActive(record.id, true);
181
- await this.storage.set(recordKey(record.id), JSON.stringify(record));
368
+ if (await window.run(() => this.storage.get(lookupKey(hash)))) {
369
+ throw new AccessTokenError("conflict", "Access token collision; rotate again");
370
+ }
371
+ throwIfAborted(signal);
372
+ await window.run(() => this.storage.set(recordKey(record.id), recordRaw));
373
+ await window.run(() => this.storage.set(lookupKey(hash), lookupRaw));
374
+ throwIfAborted(signal);
182
375
  }
183
376
  catch (error) {
184
- // No lookup write has started, so this secret can never authenticate.
185
- // A failed record or reservation write may still have committed: revoke
186
- // whatever metadata exists and release the reservation even on a miss.
187
- const revoked = await this.revoke(record.id, record.createdBy);
188
- if (!revoked)
189
- await this.updateActive(record.id, false);
377
+ await discard();
378
+ // Nothing was published: a caller that left hears `aborted`.
379
+ if (isOutcomeUnknown(error))
380
+ throwIfAborted(signal);
190
381
  throw error;
191
382
  }
192
- // A failed lookup write may have committed. Keep its reservation and
193
- // metadata, so a manager can revoke it without ever returning the secret.
194
- await this.storage.set(lookupKey(hash), JSON.stringify({ version: 1, id: record.id }));
195
- return { token, accessToken: metadata(record) };
383
+ let committed;
384
+ try {
385
+ committed = await window.run(() => this.storage.compareAndSet(slotKey(agent), raw, nextRaw));
386
+ }
387
+ catch (error) {
388
+ // No acknowledgement by the deadline: unknown, and the records stay.
389
+ if (isOutcomeUnknown(error))
390
+ throw error;
391
+ // An unanswered commit is read back, never assumed or replayed. If the
392
+ // slot does not name it, it is inert and left where it is.
393
+ if (!(await this.slotNames(window, agent, record.id)))
394
+ throw error;
395
+ committed = true;
396
+ }
397
+ if (!committed) {
398
+ await discard();
399
+ if (expect !== undefined)
400
+ throw slotChanged();
401
+ throw new AccessTokenError("conflict", "The agent's token changed during rotation; retry");
402
+ }
403
+ if (!signal?.aborted)
404
+ return window.settle({ token, agent: view });
405
+ // Committed, but the response cannot carry the secret.
406
+ await this.revokeIfNamed(window, agent, record.id);
407
+ throw new AccessTokenError("aborted", "The request ended after the rotation committed; the token is revoked");
196
408
  }
197
- async rename(id, name) {
198
- const normalized = normalizeName(name);
199
- const record = await this.updateRecord(id, (current) => ({ ...current, name: normalized }));
200
- return record ? metadata(record) : null;
409
+ /** Whether the agent's slot names `tokenId`, read back inside the window. */
410
+ async slotNames(window, agent, tokenId) {
411
+ for (let attempt = 0; attempt < RECONCILE_ATTEMPTS && window.open; attempt++) {
412
+ try {
413
+ const { slot } = await window.run(() => this.readSlot(agent));
414
+ return slot?.state === "issued" && slot.tokenId === tokenId;
415
+ }
416
+ catch (error) {
417
+ if (isOutcomeUnknown(error))
418
+ throw error;
419
+ // Transient: read again while the window is open.
420
+ }
421
+ }
422
+ throw window.unknown();
201
423
  }
202
- async revoke(id, revokedBy) {
203
- const record = await this.read(id);
204
- if (!record)
205
- return null;
206
- // Remove admission first. A metadata failure cannot leave a successful
207
- // revocation's lookup alive. Retry deletion even if already marked revoked.
208
- await this.storage.delete(lookupKey(record.tokenHash));
209
- const updated = await this.updateRecord(id, (current) => current.revokedAt
210
- ? current
211
- : {
212
- ...current,
213
- revokedAt: new Date().toISOString(),
214
- revokedBy,
215
- });
216
- await this.updateActive(id, false);
217
- return updated ? metadata(updated) : null;
424
+ /**
425
+ * Move the slot forward to `revoked` if it still names `tokenId`, inside the
426
+ * window. An unsettled outcome is reported, never swallowed.
427
+ */
428
+ async revokeIfNamed(window, agent, tokenId) {
429
+ const revocationId = crypto.randomUUID();
430
+ for (let attempt = 0; attempt < RECONCILE_ATTEMPTS && window.open; attempt++) {
431
+ try {
432
+ const { raw, slot } = await window.run(() => this.readSlot(agent));
433
+ if (slot?.state !== "issued" || slot.tokenId !== tokenId)
434
+ return;
435
+ const revoked = JSON.stringify(revokedSlot(agent, revocationId));
436
+ if (await window.run(() => this.storage.compareAndSet(slotKey(agent), raw, revoked)))
437
+ return;
438
+ }
439
+ catch (error) {
440
+ if (isOutcomeUnknown(error))
441
+ throw error;
442
+ // Ambiguous or transient: the next read decides.
443
+ }
444
+ }
445
+ throw window.unknown();
218
446
  }
219
- async updateRecord(id, update) {
220
- if (!ID_RE.test(id))
221
- return null;
222
- for (let attempt = 0; attempt < 32; attempt++) {
223
- const raw = await this.storage.get(recordKey(id));
224
- if (!raw)
225
- return null;
226
- const record = parseRecord(raw);
227
- if (record.id !== id)
228
- throw new Error("Stored access token id mismatch");
229
- const next = update(record);
230
- // A rename racing revocation must retain revokedAt, including when
231
- // an in-flight create has not yet published its lookup.
232
- if (!(await this.storage.compareAndSet(recordKey(id), raw, JSON.stringify(next))))
447
+ /**
448
+ * Revoke the agent's token. One compare-and-set of the slot to `revoked` is
449
+ * the commit; `signal` is checked after the read and before it. The record
450
+ * and lookup stay behind, inert. With `expect`, a slot whose revision
451
+ * differs refuses with `slot_changed` before anything is written, and so
452
+ * does a slot that changes between the read and the commit; without it,
453
+ * whatever the slot names by then is revoked instead.
454
+ */
455
+ async revoke(name, options = {}) {
456
+ const { signal, expect } = options;
457
+ throwIfAborted(signal);
458
+ const agent = this.declared(name);
459
+ const window = new OperationWindow(options.deadline, UNKNOWN.revoke);
460
+ let read;
461
+ try {
462
+ read = await window.run(() => this.readSlot(agent));
463
+ }
464
+ catch (error) {
465
+ // Nothing was written: a caller that left hears `aborted`.
466
+ if (isOutcomeUnknown(error))
467
+ throwIfAborted(signal);
468
+ throw error;
469
+ }
470
+ const { raw, slot } = read;
471
+ if (!(await matchesExpected(agent, raw, expect)))
472
+ throw slotChanged();
473
+ if (slot?.state !== "issued")
474
+ return window.settle(await slotView(agent, raw, slot));
475
+ throwIfAborted(signal);
476
+ // One id for this call's revocation, reused by every compare-and-set
477
+ // below: a stored slot carrying it is this call's own write, and no other
478
+ // call's revocation can carry it, even one built in the same millisecond.
479
+ const revocationId = crypto.randomUUID();
480
+ const next = revokedSlot(agent, revocationId);
481
+ const nextRaw = JSON.stringify(next);
482
+ let committed;
483
+ try {
484
+ committed = await window.run(() => this.storage.compareAndSet(slotKey(agent), raw, nextRaw));
485
+ }
486
+ catch (error) {
487
+ if (isOutcomeUnknown(error))
488
+ throw error;
489
+ if (expect !== undefined)
490
+ return this.settleRevocation(window, agent, raw, revocationId);
491
+ // Ambiguous: settled below, inside the window.
492
+ committed = false;
493
+ }
494
+ if (committed)
495
+ return window.settle(await slotView(agent, nextRaw, next));
496
+ // A definitive refusal under `expect`: someone else changed the slot.
497
+ if (expect !== undefined)
498
+ throw slotChanged();
499
+ // Unanswered, or a concurrent change won the slot, settled inside the
500
+ // window: whatever the slot names now is revoked the same way.
501
+ for (let attempt = 0; attempt < RECONCILE_ATTEMPTS && window.open; attempt++) {
502
+ try {
503
+ const current = await window.run(() => this.readSlot(agent));
504
+ if (current.slot?.state !== "issued") {
505
+ return window.settle(await slotView(agent, current.raw, current.slot));
506
+ }
507
+ const revoked = revokedSlot(agent, revocationId);
508
+ const revokedRaw = JSON.stringify(revoked);
509
+ if (await window.run(() => this.storage.compareAndSet(slotKey(agent), current.raw, revokedRaw))) {
510
+ return window.settle(await slotView(agent, revokedRaw, revoked));
511
+ }
512
+ }
513
+ catch (error) {
514
+ if (error instanceof AccessTokenError)
515
+ throw error;
516
+ }
517
+ }
518
+ throw window.unknown();
519
+ }
520
+ /**
521
+ * Settle a conditional revocation whose commit went unanswered, inside the
522
+ * window. Only a slot carrying this call's own `revocationId` is its
523
+ * success. A slot still as read is revoked again from exactly that slot,
524
+ * with the same id; anything else is `slot_changed`.
525
+ */
526
+ async settleRevocation(window, agent, raw, revocationId) {
527
+ for (let attempt = 0; attempt < RECONCILE_ATTEMPTS && window.open; attempt++) {
528
+ try {
529
+ const current = await window.run(() => this.readSlot(agent));
530
+ if (current.slot?.state === "revoked" && current.slot.revocationId === revocationId) {
531
+ return window.settle(await slotView(agent, current.raw, current.slot));
532
+ }
533
+ if (current.raw !== raw)
534
+ throw slotChanged();
535
+ const next = revokedSlot(agent, revocationId);
536
+ const nextRaw = JSON.stringify(next);
537
+ if (await window.run(() => this.storage.compareAndSet(slotKey(agent), raw, nextRaw))) {
538
+ return window.settle(await slotView(agent, nextRaw, next));
539
+ }
540
+ // Refused: the next read decides whether the slot moved, and to whose value.
541
+ }
542
+ catch (error) {
543
+ if (error instanceof AccessTokenError)
544
+ throw error;
545
+ // Ambiguous or transient: the next read decides.
546
+ }
547
+ }
548
+ throw window.unknown();
549
+ }
550
+ /**
551
+ * Trusted provisioning: let a declared agent keep using a machine token
552
+ * issued before agents. The legacy record is never changed.
553
+ *
554
+ * 1. A write-once claim, `access-token:v1:adopted:<legacy id>`, is set from
555
+ * absent to the agent's name. It is never deleted: once any adoption has
556
+ * started, the legacy token belongs to that agent forever, even after it
557
+ * is revoked or rotated away, so a retired secret can never be adopted
558
+ * again. It is inert to admission; an abort after it leaves the token
559
+ * adoptable only by the same agent.
560
+ * 2. A new record carrying `agent` and the legacy digest is written; it is
561
+ * inert until the slot names it.
562
+ * 3. `signal` is checked.
563
+ * 4. One compare-and-set of the slot from none to `issued` with the new
564
+ * record is the commit. Adopt delivers no secret, so once committed it is
565
+ * completed even if the request ended.
566
+ * 5. The digest's lookup is moved from the legacy record to the new one by
567
+ * compare-and-set; from then the existing secret admits as the agent.
568
+ *
569
+ * The commit and everything after it run inside one `OperationWindow`.
570
+ * Retrying an adoption whose slot already names a record with this digest
571
+ * completes step 5 and succeeds. With `expect`, a slot whose revision
572
+ * differs refuses with `slot_changed` before the claim, and so does a
573
+ * commit that loses its compare-and-set. Human-bound, revoked, and claimed tokens are
574
+ * refused, as is an agent whose slot names anything else.
575
+ */
576
+ async adopt(tokenId, name, options = {}) {
577
+ const { signal, expect } = options;
578
+ throwIfAborted(signal);
579
+ const agent = this.declared(name);
580
+ const window = new OperationWindow(options.deadline, UNKNOWN.adopt);
581
+ const refused = (message) => new AccessTokenError("not_adoptable", message);
582
+ const run = (operation) => window.run(operation);
583
+ let claimed = false;
584
+ let record;
585
+ let recordRaw = "";
586
+ let legacy;
587
+ let slotRaw;
588
+ try {
589
+ const found = await run(() => this.read(tokenId));
590
+ if (!found)
591
+ throw refused("No access token record has that id");
592
+ legacy = found;
593
+ if (legacy.agent !== undefined)
594
+ throw refused("That record belongs to an agent and cannot be adopted");
595
+ if (legacy.principal)
596
+ throw refused("Human-bound access tokens cannot be adopted by an agent");
597
+ if (legacy.revokedAt)
598
+ throw refused("That access token is revoked");
599
+ const digest = legacy.tokenHash;
600
+ const claimRaw = await run(() => this.storage.get(adoptedKey(legacy.id)));
601
+ const claimant = claimRaw === null ? undefined : parseClaim(claimRaw);
602
+ if (claimant !== undefined && claimant !== agent) {
603
+ throw refused(`That access token was already adopted by "${claimant}"`);
604
+ }
605
+ const current = await run(() => this.readSlot(agent));
606
+ slotRaw = current.raw;
607
+ if (!(await matchesExpected(agent, slotRaw, expect)))
608
+ throw slotChanged();
609
+ const slot = current.slot;
610
+ if (slot) {
611
+ if (slot.state === "issued" && (await run(() => this.read(slot.tokenId)))?.tokenHash === digest) {
612
+ return window.settle(await this.completeAdoption(window, agent, slot, current.raw, legacy));
613
+ }
614
+ throw refused(claimant === agent
615
+ ? "That access token was adopted by this agent and later replaced; it cannot be adopted again"
616
+ : "That agent already has a token slot; rotate it instead");
617
+ }
618
+ const lookupRaw = await run(() => this.storage.get(lookupKey(digest)));
619
+ if (!lookupRaw || parseLookup(lookupRaw)?.id !== legacy.id) {
620
+ throw refused("That access token is already adopted or no longer authenticates");
621
+ }
622
+ const holder = await this.agentCarrying(window, digest);
623
+ if (holder !== undefined)
624
+ throw refused(`That access token is being adopted by "${holder}"`);
625
+ throwIfAborted(signal);
626
+ if (claimant === undefined) {
627
+ // A pending claim acknowledgement is an unknown outcome: the claim may
628
+ // stand, which leaves the token resumable by this agent only.
629
+ claimed = true;
630
+ const claim = JSON.stringify({ version: 1, agent });
631
+ if (!(await run(() => this.storage.compareAndSet(adoptedKey(legacy.id), null, claim)))) {
632
+ const now = await run(() => this.storage.get(adoptedKey(legacy.id)));
633
+ if (now === null || parseClaim(now) !== agent) {
634
+ throw refused("That access token was just adopted by another agent");
635
+ }
636
+ }
637
+ claimed = false;
638
+ }
639
+ record = {
640
+ version: 1,
641
+ id: crypto.randomUUID(),
642
+ name: agent,
643
+ tokenHash: digest,
644
+ tokenPrefix: legacy.tokenPrefix,
645
+ createdAt: new Date().toISOString(),
646
+ createdBy: "adoption",
647
+ agent,
648
+ };
649
+ recordRaw = JSON.stringify(record);
650
+ const written = recordRaw;
651
+ const id = record.id;
652
+ await run(() => this.storage.set(recordKey(id), written));
653
+ throwIfAborted(signal);
654
+ }
655
+ catch (error) {
656
+ if (record) {
657
+ const { id } = record;
658
+ await run(() => this.storage.compareAndSet(recordKey(id), recordRaw, null)).catch(() => false);
659
+ }
660
+ // Nothing was published. A caller that left hears `aborted`, unless the
661
+ // claim's own acknowledgement was what timed out.
662
+ if (isOutcomeUnknown(error) && !claimed)
663
+ throwIfAborted(signal);
664
+ throw error;
665
+ }
666
+ const own = record;
667
+ const discard = () => run(() => this.storage.compareAndSet(recordKey(own.id), recordRaw, null)).catch(() => false);
668
+ const next = { version: 1, agent, state: "issued", tokenId: own.id, issuedAt: legacy.createdAt };
669
+ const nextRaw = JSON.stringify(next);
670
+ let committed;
671
+ try {
672
+ committed = await run(() => this.storage.compareAndSet(slotKey(agent), slotRaw, nextRaw));
673
+ }
674
+ catch (error) {
675
+ if (isOutcomeUnknown(error))
676
+ throw error;
677
+ if (!(await this.slotNames(window, agent, own.id)))
678
+ throw error;
679
+ committed = true;
680
+ }
681
+ if (committed)
682
+ return window.settle(await this.completeAdoption(window, agent, next, nextRaw, legacy));
683
+ // Someone else committed first, and ours is uncommitted and removed. A
684
+ // caller that named `expect` hears that the slot changed; otherwise a
685
+ // concurrent adoption of this same token by this agent is completed, and
686
+ // anything else refused.
687
+ await discard();
688
+ if (expect !== undefined)
689
+ throw slotChanged();
690
+ const { raw: currentRaw, slot: current } = await run(() => this.readSlot(agent));
691
+ if (current?.state === "issued") {
692
+ const named = await run(() => this.read(current.tokenId));
693
+ if (named?.tokenHash === legacy.tokenHash) {
694
+ return window.settle(await this.completeAdoption(window, agent, current, currentRaw, legacy));
695
+ }
696
+ }
697
+ throw new AccessTokenError("conflict", "That agent's token slot changed during adoption; retry");
698
+ }
699
+ /**
700
+ * Step 4 of adoption, idempotent and inside the window: point the digest's
701
+ * lookup at the record the slot names. If another agent's adoption took the
702
+ * lookup first, this slot can never admit, so it moves forward to `revoked`
703
+ * and this refuses.
704
+ */
705
+ async completeAdoption(window, agent, slot, slotRaw, legacy) {
706
+ const pointer = JSON.stringify({ version: 1, id: slot.tokenId });
707
+ const key = lookupKey(legacy.tokenHash);
708
+ for (let attempt = 0; attempt < RECONCILE_ATTEMPTS && window.open; attempt++) {
709
+ try {
710
+ const current = await window.run(() => this.storage.get(key));
711
+ const id = current === null ? null : parseLookup(current)?.id;
712
+ if (id === slot.tokenId)
713
+ return await slotView(agent, slotRaw, slot);
714
+ if (current === null || id !== legacy.id) {
715
+ await this.revokeIfNamed(window, agent, slot.tokenId);
716
+ throw new AccessTokenError("conflict", "That access token was adopted by another agent; this agent's slot is revoked");
717
+ }
718
+ if (await window.run(() => this.storage.compareAndSet(key, current, pointer))) {
719
+ return await slotView(agent, slotRaw, slot);
720
+ }
721
+ }
722
+ catch (error) {
723
+ if (error instanceof AccessTokenError)
724
+ throw error;
725
+ // Ambiguous or transient: the next read decides.
726
+ }
727
+ }
728
+ throw window.unknown();
729
+ }
730
+ /** The declared agent whose issued slot names a record with this digest, if any, read inside the window. */
731
+ async agentCarrying(window, digest) {
732
+ for (const name of this.agentOrder) {
733
+ const { slot } = await window.run(() => this.readSlot(name));
734
+ if (slot?.state !== "issued")
233
735
  continue;
234
- return next;
736
+ const tokenId = slot.tokenId;
737
+ if ((await window.run(() => this.read(tokenId)))?.tokenHash === digest)
738
+ return name;
235
739
  }
236
- throw new Error("Access token metadata is busy; retry the operation");
740
+ return undefined;
237
741
  }
238
- async updateActive(id, adding) {
239
- for (let attempt = 0; attempt < 32; attempt++) {
240
- const raw = await this.storage.get(ACTIVE_KEY);
241
- const ids = raw === null
242
- ? (await this.list()).filter((token) => !token.revokedAt).map((token) => token.id)
243
- : JSON.parse(raw);
244
- if (!Array.isArray(ids) ||
245
- !ids.every((value) => typeof value === "string" && ID_RE.test(value)) ||
246
- new Set(ids).size !== ids.length) {
247
- throw new Error("Stored access token capacity is invalid");
742
+ /**
743
+ * Trusted provisioning: the legacy records `adopt` can take, oldest first:
744
+ * unbound, unrevoked, never claimed by an adoption, still the owner of their
745
+ * digest's lookup, and with a digest no declared agent's slot carries. Ids, names and creation times
746
+ * only; never secret material.
747
+ */
748
+ async unboundTokens() {
749
+ const keys = await this.storage.list(accessTokenKeys.recordPrefix);
750
+ const records = await Promise.all(keys.map(async (key) => {
751
+ const raw = await this.storage.get(key);
752
+ try {
753
+ return raw ? parseRecord(raw) : null;
248
754
  }
249
- if (adding && ids.length >= this.maxActive)
250
- throw new Error(`This deployment already has the maximum of ${this.maxActive} active access tokens`);
251
- const next = adding ? [...ids, id] : ids.filter((value) => value !== id);
252
- if (await this.storage.compareAndSet(ACTIVE_KEY, raw, JSON.stringify(next)))
253
- return;
755
+ catch {
756
+ return null;
757
+ }
758
+ }));
759
+ const carried = new Set();
760
+ for (const name of this.agentOrder) {
761
+ const { slot } = await this.readSlot(name);
762
+ const named = slot?.state === "issued" ? await this.read(slot.tokenId) : null;
763
+ if (named)
764
+ carried.add(named.tokenHash);
765
+ }
766
+ const adoptable = [];
767
+ for (const record of records) {
768
+ if (!record || record.agent !== undefined || record.principal || record.revokedAt)
769
+ continue;
770
+ if (carried.has(record.tokenHash))
771
+ continue;
772
+ if ((await this.storage.get(adoptedKey(record.id))) !== null)
773
+ continue;
774
+ const lookup = await this.storage.get(lookupKey(record.tokenHash));
775
+ if (lookup && parseLookup(lookup)?.id === record.id)
776
+ adoptable.push(record);
254
777
  }
255
- throw new Error("Access token capacity is busy; retry the operation");
778
+ return adoptable
779
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
780
+ .map(({ id, name, createdAt }) => ({ id, name, createdAt }));
256
781
  }
257
782
  async authorize(request) {
258
783
  const credential = authorizationCredential(request);
@@ -270,26 +795,37 @@ export class AccessTokenManager {
270
795
  if (!record || record.revokedAt || record.tokenHash !== hash || record.tokenPrefix !== token.slice(0, 12)) {
271
796
  return unauthorized();
272
797
  }
273
- return {
274
- ok: true,
275
- subjectId: record.id,
276
- ...(record.principal ? { principal: { ...record.principal } } : {}),
277
- };
798
+ // The slot is the authority: a surviving lookup or record it does not
799
+ // name is inert. Human-bound and unbound records, and agents no longer in
800
+ // code, refuse exactly as an unknown token does.
801
+ if (record.principal || record.agent === undefined || !this.agents.has(record.agent))
802
+ return unauthorized();
803
+ const { slot } = await this.readSlot(record.agent);
804
+ if (slot?.state !== "issued" || slot.tokenId !== record.id)
805
+ return unauthorized();
806
+ return { ok: true, subjectId: agentSubjectId(record.agent), agent: record.agent };
278
807
  }
279
808
  }
280
- /** The closed options accessTokens() accepts; see `assertKnownOptions`. */
281
- const ACCESS_TOKENS_OPTIONS = optionsOf()(keys("maxActive"));
282
- /** Opt in using the same storage namespace that held the v0.23 records. */
283
- export function accessTokens(storage, options = {}) {
284
- options = assertKnownOptions(options, "accessTokens()", ACCESS_TOKENS_OPTIONS);
285
- const manager = new AccessTokenManager(storage, options);
286
- const maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
287
- const optionSources = Object.freeze({
288
- maxActive: options.maxActive === undefined ? "default" : "config",
289
- });
809
+ const ACCESS_TOKENS_OPTIONS = optionsOf()({});
810
+ /** Build the module bound to one deployment's declared agents. */
811
+ function moduleFor(storage, agents) {
812
+ const manager = new AccessTokenManager(storage, { agents });
290
813
  return {
291
814
  auth: manager.auth,
292
815
  handle: (context) => routeAccessTokens(context, manager),
293
- describe: () => ({ maxActive, optionSources }),
816
+ slots: () => manager.list(),
817
+ forAgents: (declared) => moduleFor(storage, declared),
294
818
  };
295
819
  }
820
+ /**
821
+ * Agent token authentication and management over the deployment's storage.
822
+ * Reuse the storage that held earlier tokens so their records stay readable;
823
+ * `createConnecta` binds the module to `agents`.
824
+ */
825
+ export function accessTokens(storage, options = {}) {
826
+ if (options && typeof options === "object" && Object.hasOwn(options, "maxActive")) {
827
+ throw new ConfigError("accessTokens().maxActive was removed: each agent declared in ConnectaConfig.agents has exactly one token slot.");
828
+ }
829
+ assertKnownOptions(options, "accessTokens()", ACCESS_TOKENS_OPTIONS);
830
+ return moduleFor(storage, []);
831
+ }