@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.
- package/CHANGELOG.md +97 -0
- package/README.md +30 -20
- package/bin/connecta.mjs +25 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/auth-elicitation.js +1 -1
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/catalog-service.js +7 -2
- package/dist/config-value-sources.js +14 -9
- package/dist/config.d.ts +58 -78
- package/dist/config.js +143 -47
- package/dist/connector-access.d.ts +4 -33
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +3 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/guarded-fetch.d.ts +9 -0
- package/dist/connectors/guarded-fetch.js +7 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +32 -7
- package/dist/describe-config.js +9 -18
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.d.ts +4 -3
- package/dist/index.js +30 -10
- package/dist/invocation.js +12 -7
- package/dist/membership.d.ts +51 -0
- package/dist/membership.js +121 -0
- package/dist/meta-tools.js +17 -19
- package/dist/module-contracts.d.ts +23 -6
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -2
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/contract.d.ts +151 -29
- package/dist/operator-ui/endpoints.js +192 -0
- package/dist/operator-ui/fix-prompts.js +4 -4
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +112 -0
- package/dist/operator-ui/model.d.ts +15 -7
- package/dist/operator-ui/view.d.ts +84 -19
- package/dist/operator-ui/view.js +83 -21
- package/dist/page-styles.js +1 -1
- package/dist/program-writes.js +2 -2
- package/dist/provider.d.ts +63 -11
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +53 -16
- package/dist/providers/_shared/google/workspace.js +31 -9
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/breeze/skill.generated.js +2 -2
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +2 -2
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.d.ts +1 -1
- package/dist/providers/docs/index.js +5 -5
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.d.ts +1 -1
- package/dist/providers/drive/index.js +4 -4
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.d.ts +1 -1
- package/dist/providers/forms/index.js +5 -5
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/github/skill.generated.js +2 -2
- package/dist/providers/gmail/index.d.ts +1 -1
- package/dist/providers/gmail/index.js +279 -26
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/linear/index.js +1 -1
- package/dist/providers/mixpanel/index.d.ts +27 -12
- package/dist/providers/mixpanel/index.js +58 -6
- package/dist/providers/mixpanel/operations.js +20 -0
- package/dist/providers/mixpanel/rest.js +603 -0
- package/dist/providers/mixpanel/skill.generated.js +3 -2
- package/dist/providers/mixpanel/value-safety.js +16 -0
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +116 -11
- package/dist/providers/notion/rest.js +183 -24
- package/dist/providers/notion/skill.generated.js +4 -4
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.d.ts +27 -0
- package/dist/providers/planning-center/index.js +287 -25
- package/dist/providers/planning-center/skill.generated.js +24 -4
- package/dist/providers/sheets/index.d.ts +4 -1
- package/dist/providers/sheets/index.js +9 -6
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.d.ts +1 -1
- package/dist/providers/slides/index.js +6 -6
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +2 -2
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/vercel/index.js +2 -2
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +2 -2
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/providers/wispr-flow/index.d.ts +14 -0
- package/dist/providers/wispr-flow/index.js +117 -0
- package/dist/providers/wispr-flow/skill.generated.js +8 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +8 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +155 -85
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +3 -9
- package/dist/routes/operator.js +25 -17
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +8 -0
- package/dist/skills.js +5 -4
- package/dist/storage/keys.js +9 -1
- package/dist/tool-safety.js +13 -5
- package/dist/types.d.ts +12 -1
- package/dist/ui.d.ts +2 -2
- package/dist/ui.js +17 -15
- package/dist/usage-guide.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +46 -22
- package/documentation/architecture.md +378 -58
- package/documentation/auth.md +806 -215
- package/documentation/code-mode.md +93 -81
- package/documentation/deploying.md +128 -26
- package/documentation/integrating.md +145 -22
- package/documentation/meta-tools.md +58 -46
- package/documentation/migration-0.33.md +344 -0
- package/documentation/operating.md +14 -9
- package/documentation/operator-tests.md +117 -31
- package/documentation/operator-ui.md +326 -55
- package/documentation/provider-auth-migration.md +42 -27
- package/documentation/provider-migration-0.29.md +3 -3
- package/examples/worker/AGENTS.md +35 -8
- package/examples/worker/README.md +92 -53
- package/examples/worker/src/connecta.config.ts +41 -20
- package/examples/worker/wrangler.jsonc +3 -0
- package/package.json +7 -2
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +33 -10
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +71 -44
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +33 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/dist/tool-safety.d.ts +0 -9
- package/templates/node/src/provision-token.ts +0 -17
package/dist/access-tokens.js
CHANGED
|
@@ -1,19 +1,130 @@
|
|
|
1
1
|
import { authorizationCredential, isMachineCredential } from "./inbound-credential.js";
|
|
2
|
-
import { assertKnownOptions,
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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.
|
|
59
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
244
|
+
agents;
|
|
245
|
+
agentOrder;
|
|
110
246
|
constructor(storage, options = {}) {
|
|
111
247
|
this.storage = storage;
|
|
112
|
-
// Issuance
|
|
113
|
-
//
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
this.
|
|
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:
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
throw
|
|
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:
|
|
346
|
+
name: agent,
|
|
173
347
|
tokenHash: hash,
|
|
174
348
|
tokenPrefix: token.slice(0, 12),
|
|
175
349
|
createdAt: new Date().toISOString(),
|
|
176
|
-
createdBy:
|
|
177
|
-
|
|
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.
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
736
|
+
const tokenId = slot.tokenId;
|
|
737
|
+
if ((await window.run(() => this.read(tokenId)))?.tokenHash === digest)
|
|
738
|
+
return name;
|
|
235
739
|
}
|
|
236
|
-
|
|
740
|
+
return undefined;
|
|
237
741
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
+
}
|