@agent-native/core 0.201.0-nightly-20261005153421 → 0.201.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/README.md +22 -22
- package/dist/a2a/server.d.ts +2 -2
- package/dist/a2a/server.js +13 -2
- package/dist/agent/thread-data-builder.d.ts +4 -0
- package/dist/agent/thread-data-builder.js +8 -0
- package/dist/agent-sql/analysis.d.ts +31 -0
- package/dist/agent-sql/analysis.js +169 -0
- package/dist/agent-sql/index.d.ts +4 -0
- package/dist/agent-sql/index.js +4 -0
- package/dist/agent-sql/lexer.d.ts +37 -0
- package/dist/agent-sql/lexer.js +357 -0
- package/dist/agent-sql/postgres.d.ts +51 -0
- package/dist/agent-sql/postgres.js +197 -0
- package/dist/agent-sql/query.d.ts +30 -0
- package/dist/agent-sql/query.js +514 -0
- package/dist/catalog.json +24 -24
- package/dist/client/analytics/index.d.ts +1 -1
- package/dist/client/analytics/index.js +1 -1
- package/dist/client/analytics.d.ts +8 -0
- package/dist/client/analytics.js +17 -2
- package/dist/client/session-replay.js +3 -2
- package/dist/identity/offboard.js +31 -3
- package/dist/identity/rekey.d.ts +12 -1
- package/dist/identity/rekey.js +77 -6
- package/dist/identity/retired-emails.d.ts +23 -0
- package/dist/identity/retired-emails.js +55 -0
- package/dist/mcp/actions/create-org-service-token.js +20 -8
- package/dist/mcp/actions/service-token-access.d.ts +1 -0
- package/dist/mcp/actions/service-token-access.js +41 -24
- package/dist/mcp/build-server.d.ts +20 -6
- package/dist/mcp/build-server.js +131 -35
- package/dist/mcp/connect-route.d.ts +4 -3
- package/dist/mcp/connect-route.js +140 -82
- package/dist/mcp/connect-store.d.ts +17 -11
- package/dist/mcp/connect-store.js +88 -60
- package/dist/mcp/credential-issuance.d.ts +23 -0
- package/dist/mcp/credential-issuance.js +77 -0
- package/dist/mcp/credential-membership.d.ts +26 -0
- package/dist/mcp/credential-membership.js +67 -0
- package/dist/mcp/oauth-route.js +133 -45
- package/dist/mcp/oauth-store.d.ts +15 -5
- package/dist/mcp/oauth-store.js +119 -34
- package/dist/mcp/oauth-token.d.ts +4 -0
- package/dist/mcp/oauth-token.js +5 -0
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/mcp/server.js +14 -0
- package/dist/onboarding/default-steps.js +1 -1
- package/dist/org/handlers.d.ts +1 -1
- package/dist/org/index.d.ts +2 -2
- package/dist/org/membership.d.ts +3 -1
- package/dist/org/membership.js +5 -2
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +10 -10
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resource-changes/store.d.ts +2 -0
- package/dist/resource-changes/store.js +4 -0
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/db/exec.js +28 -101
- package/dist/scripts/db/patch.js +14 -34
- package/dist/scripts/db/postgres-client.d.ts +8 -1
- package/dist/scripts/db/postgres-client.js +20 -6
- package/dist/scripts/db/query.js +31 -33
- package/dist/scripts/db/safety.d.ts +47 -2
- package/dist/scripts/db/safety.js +383 -137
- package/dist/scripts/db/scoping.d.ts +1 -0
- package/dist/scripts/db/scoping.js +3 -2
- package/dist/search/index-store.d.ts +21 -0
- package/dist/search/index-store.js +47 -0
- package/dist/search/indexer.js +35 -32
- package/dist/search/registry.d.ts +6 -3
- package/dist/search/registry.js +30 -8
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-chat-plugin.js +18 -6
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-default-model-route.d.ts +1 -1
- package/dist/server/agent-run-context.js +9 -0
- package/dist/server/attribution.d.ts +2 -0
- package/dist/server/attribution.js +7 -1
- package/dist/server/auth.js +9 -0
- package/dist/server/credential-membership-unavailable.d.ts +13 -0
- package/dist/server/credential-membership-unavailable.js +22 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/onboarding-html.js +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/recap-image-route.js +4 -0
- package/dist/server/release-schema.js +4 -0
- package/dist/shared/docs-url.d.ts +3 -1
- package/dist/shared/docs-url.js +6 -3
- package/dist/shared/environment-lanes.d.ts +8 -0
- package/dist/shared/environment-lanes.js +26 -0
- package/dist/templates/chat/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/templates/chat/README.md +2 -2
- package/dist/templates/default/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/templates/default/app/routes/home.tsx +5 -1
- package/dist/templates/headless/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
- package/dist/templates/workspace-core/.agents/skills/storing-data/SKILL.md +12 -7
- package/dist/tracking/providers.js +13 -1
- package/docs/content/authentication.mdx +5 -1
- package/docs/content/durable-background-runs.mdx +17 -8
- package/docs/content/external-agents.mdx +3 -1
- package/docs/content/mcp-protocol.mdx +6 -2
- package/docs/content/security.mdx +1 -1
- package/package.json +5 -4
- package/src/templates/chat/.agents/skills/security/SKILL.md +9 -2
- package/src/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
- package/src/templates/chat/README.md +2 -2
- package/src/templates/default/.agents/skills/security/SKILL.md +9 -2
- package/src/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
- package/src/templates/default/app/routes/home.tsx +5 -1
- package/src/templates/headless/.agents/skills/security/SKILL.md +9 -2
- package/src/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
- package/src/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
- package/src/templates/workspace-core/.agents/skills/storing-data/SKILL.md +12 -7
|
@@ -73,6 +73,9 @@ export async function ensureTable() {
|
|
|
73
73
|
}
|
|
74
74
|
return _initPromise;
|
|
75
75
|
}
|
|
76
|
+
export async function ensureConnectTables() {
|
|
77
|
+
await ensureTable();
|
|
78
|
+
}
|
|
76
79
|
export function serviceIdentityEmail(serviceName, orgId) {
|
|
77
80
|
return `svc-${normalizeServiceName(serviceName)}@service.${orgId}`;
|
|
78
81
|
}
|
|
@@ -96,11 +99,12 @@ export function normalizeServiceName(raw) {
|
|
|
96
99
|
* Persist a record of a minted token. The token value itself (a signed JWT)
|
|
97
100
|
* is NEVER stored — only its `jti`, so revocation is a cheap SQL lookup.
|
|
98
101
|
*/
|
|
99
|
-
export async function recordMintedToken(params) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
export async function recordMintedToken(params, exec) {
|
|
103
|
+
if (!exec)
|
|
104
|
+
await ensureTable();
|
|
105
|
+
const client = exec ?? getDbExec();
|
|
102
106
|
const id = randomUUID();
|
|
103
|
-
await client.execute({
|
|
107
|
+
const result = await client.execute({
|
|
104
108
|
sql: `INSERT INTO mcp_connect_tokens (id, jti, owner_email, org_id, label, kind, service_name, created_by, created_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
105
109
|
args: [
|
|
106
110
|
id,
|
|
@@ -116,6 +120,9 @@ export async function recordMintedToken(params) {
|
|
|
116
120
|
null,
|
|
117
121
|
],
|
|
118
122
|
});
|
|
123
|
+
if (result.rowsAffected !== 1) {
|
|
124
|
+
throw new Error("Unexpected affected row count for MCP connect token insert.");
|
|
125
|
+
}
|
|
119
126
|
return id;
|
|
120
127
|
}
|
|
121
128
|
/**
|
|
@@ -124,40 +131,45 @@ export async function recordMintedToken(params) {
|
|
|
124
131
|
* lock every connected agent out. Signature verification is unaffected — this
|
|
125
132
|
* is only the post-verify revoke check (see `verifyAuth` in build-server.ts).
|
|
126
133
|
*/
|
|
134
|
+
/**
|
|
135
|
+
* Throws when the revocation state can't be read. Answering "not revoked"
|
|
136
|
+
* instead would let a transient read failure admit a revoked token.
|
|
137
|
+
*/
|
|
127
138
|
export async function isJtiRevoked(jti) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (rows.length === 0)
|
|
136
|
-
return false;
|
|
137
|
-
const revokedAt = rows[0].revoked_at ?? rows[0].revokedAt;
|
|
138
|
-
return revokedAt != null;
|
|
139
|
-
}
|
|
140
|
-
catch (err) {
|
|
141
|
-
// Fail open: a DB blip must not turn every minted token into a 401.
|
|
142
|
-
// (Signature checks already passed; this only gates explicit revokes.)
|
|
143
|
-
if (isConnectionError(err))
|
|
144
|
-
return false;
|
|
139
|
+
await ensureTable();
|
|
140
|
+
const client = getDbExec();
|
|
141
|
+
const { rows } = await client.execute({
|
|
142
|
+
sql: `SELECT revoked_at FROM mcp_connect_tokens WHERE jti = ?`,
|
|
143
|
+
args: [jti],
|
|
144
|
+
});
|
|
145
|
+
if (rows.length === 0)
|
|
145
146
|
return false;
|
|
146
|
-
|
|
147
|
+
const revokedAt = rows[0].revoked_at ?? rows[0].revokedAt;
|
|
148
|
+
return revokedAt != null;
|
|
147
149
|
}
|
|
148
150
|
export async function lookupConnectTokenOrg(jti) {
|
|
149
151
|
try {
|
|
150
152
|
await ensureTable();
|
|
151
153
|
const client = getDbExec();
|
|
152
154
|
const { rows } = await client.execute({
|
|
153
|
-
sql: `SELECT org_id FROM mcp_connect_tokens WHERE jti = ?`,
|
|
155
|
+
sql: `SELECT org_id, owner_email, kind FROM mcp_connect_tokens WHERE jti = ?`,
|
|
154
156
|
args: [jti],
|
|
155
157
|
});
|
|
156
158
|
if (rows.length === 0)
|
|
157
159
|
return { status: "missing" };
|
|
158
160
|
const rawOrgId = rows[0].org_id ?? rows[0].orgId;
|
|
161
|
+
const ownerEmail = rows[0].owner_email ?? rows[0].ownerEmail;
|
|
162
|
+
const kind = rows[0].kind;
|
|
163
|
+
if (typeof ownerEmail !== "string" ||
|
|
164
|
+
!ownerEmail.trim() ||
|
|
165
|
+
(kind !== "personal" && kind !== "service") ||
|
|
166
|
+
(rawOrgId != null && (typeof rawOrgId !== "string" || !rawOrgId.trim()))) {
|
|
167
|
+
return { status: "unavailable" };
|
|
168
|
+
}
|
|
159
169
|
return {
|
|
160
170
|
status: "found",
|
|
171
|
+
ownerEmail,
|
|
172
|
+
kind,
|
|
161
173
|
orgId: typeof rawOrgId === "string" && rawOrgId.trim()
|
|
162
174
|
? rawOrgId.trim()
|
|
163
175
|
: null,
|
|
@@ -341,9 +353,10 @@ function mapDeviceRow(r) {
|
|
|
341
353
|
consumedAt: numOrNull(r.consumed_at ?? r.consumedAt),
|
|
342
354
|
};
|
|
343
355
|
}
|
|
344
|
-
export async function getDeviceCode(deviceCode) {
|
|
345
|
-
|
|
346
|
-
|
|
356
|
+
export async function getDeviceCode(deviceCode, exec) {
|
|
357
|
+
if (!exec)
|
|
358
|
+
await ensureTable();
|
|
359
|
+
const client = exec ?? getDbExec();
|
|
347
360
|
const { rows } = await client.execute({
|
|
348
361
|
sql: `SELECT * FROM mcp_device_codes WHERE device_code = ?`,
|
|
349
362
|
args: [deviceCode],
|
|
@@ -352,9 +365,10 @@ export async function getDeviceCode(deviceCode) {
|
|
|
352
365
|
return null;
|
|
353
366
|
return mapDeviceRow(rows[0]);
|
|
354
367
|
}
|
|
355
|
-
export async function getDeviceCodeByUserCode(userCode) {
|
|
356
|
-
|
|
357
|
-
|
|
368
|
+
export async function getDeviceCodeByUserCode(userCode, exec) {
|
|
369
|
+
if (!exec)
|
|
370
|
+
await ensureTable();
|
|
371
|
+
const client = exec ?? getDbExec();
|
|
358
372
|
const { rows } = await client.execute({
|
|
359
373
|
sql: `SELECT * FROM mcp_device_codes WHERE user_code = ?`,
|
|
360
374
|
args: [userCode],
|
|
@@ -363,10 +377,18 @@ export async function getDeviceCodeByUserCode(userCode) {
|
|
|
363
377
|
return null;
|
|
364
378
|
return mapDeviceRow(rows[0]);
|
|
365
379
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
380
|
+
function deviceCodeMutationSucceeded(rowsAffected) {
|
|
381
|
+
if (rowsAffected === 1)
|
|
382
|
+
return true;
|
|
383
|
+
if (rowsAffected === 0)
|
|
384
|
+
return false;
|
|
385
|
+
throw new Error("Unexpected affected row count for MCP device code mutation.");
|
|
386
|
+
}
|
|
387
|
+
export async function approveDeviceCode(userCode, ownerEmail, orgId, exec) {
|
|
388
|
+
if (!exec)
|
|
389
|
+
await ensureTable();
|
|
390
|
+
const client = exec ?? getDbExec();
|
|
391
|
+
const row = await getDeviceCodeByUserCode(userCode, client);
|
|
370
392
|
if (!row)
|
|
371
393
|
return "not_found";
|
|
372
394
|
if ((row.expiresAt ?? 0) < Date.now())
|
|
@@ -374,11 +396,13 @@ export async function approveDeviceCode(userCode, ownerEmail, orgId) {
|
|
|
374
396
|
if (row.status !== "pending")
|
|
375
397
|
return "already";
|
|
376
398
|
const result = await client.execute({
|
|
377
|
-
sql: `UPDATE mcp_device_codes SET status = 'approved', owner_email = ?, org_id = ? WHERE user_code = ? AND status = 'pending'
|
|
378
|
-
args: [ownerEmail, orgId, userCode],
|
|
399
|
+
sql: `UPDATE mcp_device_codes SET status = 'approved', owner_email = ?, org_id = ? WHERE user_code = ? AND status = 'pending' AND expires_at >= ?`,
|
|
400
|
+
args: [ownerEmail, orgId, userCode, Date.now()],
|
|
379
401
|
});
|
|
380
|
-
if (result.rowsAffected
|
|
381
|
-
const fresh = await getDeviceCodeByUserCode(userCode);
|
|
402
|
+
if (!deviceCodeMutationSucceeded(result.rowsAffected)) {
|
|
403
|
+
const fresh = await getDeviceCodeByUserCode(userCode, client);
|
|
404
|
+
if (fresh && (fresh.expiresAt ?? 0) < Date.now())
|
|
405
|
+
return "expired";
|
|
382
406
|
return fresh && fresh.status !== "pending" ? "already" : "not_found";
|
|
383
407
|
}
|
|
384
408
|
return {
|
|
@@ -388,49 +412,53 @@ export async function approveDeviceCode(userCode, ownerEmail, orgId) {
|
|
|
388
412
|
orgId,
|
|
389
413
|
};
|
|
390
414
|
}
|
|
391
|
-
export async function consumeDeviceCode(deviceCode, tokenJti) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
415
|
+
export async function consumeDeviceCode(deviceCode, tokenJti, exec) {
|
|
416
|
+
if (!exec)
|
|
417
|
+
await ensureTable();
|
|
418
|
+
const client = exec ?? getDbExec();
|
|
419
|
+
const row = await getDeviceCode(deviceCode, client);
|
|
395
420
|
if (!row)
|
|
396
421
|
return null;
|
|
397
|
-
if (row.status !== "approved")
|
|
422
|
+
if (row.status !== "approved" || (row.expiresAt ?? 0) < Date.now())
|
|
398
423
|
return null;
|
|
399
424
|
const result = await client.execute({
|
|
400
|
-
sql: `UPDATE mcp_device_codes SET status = 'consumed', token_jti = ?, consumed_at = ? WHERE device_code = ? AND status = 'approved'
|
|
401
|
-
args: [tokenJti, Date.now(), deviceCode],
|
|
425
|
+
sql: `UPDATE mcp_device_codes SET status = 'consumed', token_jti = ?, consumed_at = ? WHERE device_code = ? AND status = 'approved' AND expires_at >= ?`,
|
|
426
|
+
args: [tokenJti, Date.now(), deviceCode, Date.now()],
|
|
402
427
|
});
|
|
403
|
-
if (result.rowsAffected
|
|
428
|
+
if (!deviceCodeMutationSucceeded(result.rowsAffected))
|
|
404
429
|
return null;
|
|
405
430
|
return row;
|
|
406
431
|
}
|
|
407
|
-
export async function claimDeviceCodeForMint(deviceCode, tokenJti) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
432
|
+
export async function claimDeviceCodeForMint(deviceCode, tokenJti, exec) {
|
|
433
|
+
if (!exec)
|
|
434
|
+
await ensureTable();
|
|
435
|
+
const client = exec ?? getDbExec();
|
|
436
|
+
const row = await getDeviceCode(deviceCode, client);
|
|
437
|
+
if (!row || row.status !== "approved" || (row.expiresAt ?? 0) < Date.now())
|
|
412
438
|
return null;
|
|
413
439
|
const result = await client.execute({
|
|
414
|
-
sql: `UPDATE mcp_device_codes SET status = 'minting', token_jti = ?, consumed_at = ? WHERE device_code = ? AND status = 'approved'
|
|
415
|
-
args: [tokenJti, Date.now(), deviceCode],
|
|
440
|
+
sql: `UPDATE mcp_device_codes SET status = 'minting', token_jti = ?, consumed_at = ? WHERE device_code = ? AND status = 'approved' AND expires_at >= ?`,
|
|
441
|
+
args: [tokenJti, Date.now(), deviceCode, Date.now()],
|
|
416
442
|
});
|
|
417
|
-
if (result.rowsAffected
|
|
443
|
+
if (!deviceCodeMutationSucceeded(result.rowsAffected))
|
|
418
444
|
return null;
|
|
419
445
|
return row;
|
|
420
446
|
}
|
|
421
|
-
export async function finishDeviceCodeMint(deviceCode, tokenJti) {
|
|
422
|
-
|
|
423
|
-
|
|
447
|
+
export async function finishDeviceCodeMint(deviceCode, tokenJti, exec) {
|
|
448
|
+
if (!exec)
|
|
449
|
+
await ensureTable();
|
|
450
|
+
const client = exec ?? getDbExec();
|
|
424
451
|
const result = await client.execute({
|
|
425
452
|
sql: `UPDATE mcp_device_codes SET status = 'consumed' WHERE device_code = ? AND status = 'minting' AND token_jti = ?`,
|
|
426
453
|
args: [deviceCode, tokenJti],
|
|
427
454
|
});
|
|
428
|
-
return result.rowsAffected
|
|
455
|
+
return deviceCodeMutationSucceeded(result.rowsAffected);
|
|
429
456
|
}
|
|
430
|
-
export async function releaseDeviceCodeMint(deviceCode, tokenJti) {
|
|
457
|
+
export async function releaseDeviceCodeMint(deviceCode, tokenJti, exec) {
|
|
431
458
|
try {
|
|
432
|
-
|
|
433
|
-
|
|
459
|
+
if (!exec)
|
|
460
|
+
await ensureTable();
|
|
461
|
+
const client = exec ?? getDbExec();
|
|
434
462
|
await client.execute({
|
|
435
463
|
sql: `UPDATE mcp_device_codes SET status = 'approved', token_jti = NULL, consumed_at = NULL WHERE device_code = ? AND status = 'minting' AND token_jti = ?`,
|
|
436
464
|
args: [deviceCode, tokenJti],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type DbExec } from "../db/client.js";
|
|
2
|
+
import type { OrgRole } from "../org/types.js";
|
|
3
|
+
export declare class McpCredentialIssuanceError extends Error {
|
|
4
|
+
/**
|
|
5
|
+
* `not-member`: the owner lacks the live membership or role the credential
|
|
6
|
+
* needs, or holds an address an email change retired.
|
|
7
|
+
*/
|
|
8
|
+
readonly reason: "not-member" | "unavailable";
|
|
9
|
+
constructor(
|
|
10
|
+
/**
|
|
11
|
+
* `not-member`: the owner lacks the live membership or role the credential
|
|
12
|
+
* needs, or holds an address an email change retired.
|
|
13
|
+
*/
|
|
14
|
+
reason: "not-member" | "unavailable", options?: ErrorOptions);
|
|
15
|
+
}
|
|
16
|
+
export declare function withMcpCredentialIssuance<T>(input: {
|
|
17
|
+
email: string;
|
|
18
|
+
orgId?: string | null;
|
|
19
|
+
requestOrigin?: string;
|
|
20
|
+
kind?: "personal" | "service";
|
|
21
|
+
/** Roles allowed to issue, rechecked under the membership lock. */
|
|
22
|
+
roles?: readonly OrgRole[];
|
|
23
|
+
}, run: (tx: DbExec) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getDbExec } from "../db/client.js";
|
|
2
|
+
import { identityCredentialLockKey, readEmailRetiredAt, } from "../identity/retired-emails.js";
|
|
3
|
+
import { checkCredentialOrgMembership } from "./credential-membership.js";
|
|
4
|
+
export class McpCredentialIssuanceError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(
|
|
7
|
+
/**
|
|
8
|
+
* `not-member`: the owner lacks the live membership or role the credential
|
|
9
|
+
* needs, or holds an address an email change retired.
|
|
10
|
+
*/
|
|
11
|
+
reason, options) {
|
|
12
|
+
super(reason, options);
|
|
13
|
+
this.reason = reason;
|
|
14
|
+
this.name = "McpCredentialIssuanceError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function withMcpCredentialIssuance(input, run) {
|
|
18
|
+
const orgId = input.orgId?.trim() || null;
|
|
19
|
+
const email = input.email.trim().toLowerCase();
|
|
20
|
+
const human = input.kind !== "service";
|
|
21
|
+
const requiresMembership = orgId !== null && human;
|
|
22
|
+
if (requiresMembership) {
|
|
23
|
+
// Federation validation may update membership on another connection.
|
|
24
|
+
// Finish it before acquiring the local offboarding lock.
|
|
25
|
+
const membership = await checkCredentialOrgMembership({
|
|
26
|
+
orgId,
|
|
27
|
+
email,
|
|
28
|
+
requestOrigin: input.requestOrigin,
|
|
29
|
+
});
|
|
30
|
+
if (membership !== "member") {
|
|
31
|
+
throw new McpCredentialIssuanceError(membership);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const db = getDbExec();
|
|
35
|
+
if (!db.transaction)
|
|
36
|
+
throw new McpCredentialIssuanceError("unavailable");
|
|
37
|
+
try {
|
|
38
|
+
return await db.transaction(async (tx) => {
|
|
39
|
+
if (human) {
|
|
40
|
+
// Email rekey takes this lock for both addresses before its row locks.
|
|
41
|
+
await tx.execute({
|
|
42
|
+
sql: "SELECT pg_advisory_xact_lock(hashtextextended(?, 0::bigint))",
|
|
43
|
+
args: [identityCredentialLockKey(email)],
|
|
44
|
+
});
|
|
45
|
+
if ((await readEmailRetiredAt(tx, email)) !== null) {
|
|
46
|
+
const { rows } = await tx.execute({
|
|
47
|
+
sql: `SELECT 1 FROM "user" WHERE LOWER("email") = ? LIMIT 1`,
|
|
48
|
+
args: [email],
|
|
49
|
+
});
|
|
50
|
+
if (rows.length === 0) {
|
|
51
|
+
throw new McpCredentialIssuanceError("not-member");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (requiresMembership) {
|
|
56
|
+
const { rows } = await tx.execute({
|
|
57
|
+
sql: `SELECT role FROM org_members
|
|
58
|
+
WHERE org_id = ? AND LOWER(email) = ?
|
|
59
|
+
AND federation_removal_pending_at IS NULL
|
|
60
|
+
FOR UPDATE`,
|
|
61
|
+
args: [orgId, email],
|
|
62
|
+
});
|
|
63
|
+
if (rows.length === 0 ||
|
|
64
|
+
(input.roles &&
|
|
65
|
+
!input.roles.includes(String(rows[0].role)))) {
|
|
66
|
+
throw new McpCredentialIssuanceError("not-member");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return run(tx);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof McpCredentialIssuanceError)
|
|
74
|
+
throw error;
|
|
75
|
+
throw new McpCredentialIssuanceError("unavailable", { cause: error });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { StoredConnectTokenIdentity } from "./connect-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* `unavailable` means the check could not run (database or identity-authority
|
|
4
|
+
* error, or organization tables that don't exist yet). It is neither answer:
|
|
5
|
+
* callers must refuse the request with a retryable error, and must not revoke
|
|
6
|
+
* anything on it.
|
|
7
|
+
*/
|
|
8
|
+
export type CredentialOrgMembership = "member" | "not-member" | "unavailable";
|
|
9
|
+
export declare function checkCredentialOrgMembership(input: {
|
|
10
|
+
orgId: string;
|
|
11
|
+
email: string | undefined;
|
|
12
|
+
storedConnectToken?: StoredConnectTokenIdentity;
|
|
13
|
+
/** This app's public origin; federated orgs need it to reach the identity authority. */
|
|
14
|
+
requestOrigin?: string;
|
|
15
|
+
}): Promise<CredentialOrgMembership>;
|
|
16
|
+
/**
|
|
17
|
+
* `retired`: an email change moved this account off the credential's subject
|
|
18
|
+
* address after the credential was signed. `unavailable` means the same as for
|
|
19
|
+
* membership: refuse with a retryable error.
|
|
20
|
+
*/
|
|
21
|
+
export type CredentialEmailRetirement = "current" | "retired" | "unavailable";
|
|
22
|
+
export declare function checkCredentialEmailRetirement(input: {
|
|
23
|
+
email: string;
|
|
24
|
+
/** The credential's `iat`, in seconds; absent counts as signed before. */
|
|
25
|
+
issuedAt: number | undefined;
|
|
26
|
+
}): Promise<CredentialEmailRetirement>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live organization-membership check for long-lived MCP credentials.
|
|
3
|
+
*
|
|
4
|
+
* MCP OAuth access and refresh tokens, authorization codes, and connect tokens
|
|
5
|
+
* carry the organization they were issued for (a signed `org_id` claim or the
|
|
6
|
+
* stored org of a connect token). Removing a member does not touch those
|
|
7
|
+
* credentials, so the org they name is only an assertion from issuance time.
|
|
8
|
+
* Every use re-checks it here: two indexed queries (the membership row, then
|
|
9
|
+
* the organization row), plus a call to the identity authority when the org
|
|
10
|
+
* is linked. Nothing is cached, so a removal on any instance takes effect on
|
|
11
|
+
* the next request everywhere.
|
|
12
|
+
*/
|
|
13
|
+
import { getDbExec } from "../db/client.js";
|
|
14
|
+
import { readEmailRetiredAt } from "../identity/retired-emails.js";
|
|
15
|
+
import { isMissingOrganizationTableError, isOrgMember, } from "../org/membership.js";
|
|
16
|
+
import { implicitServiceOrgRole } from "../org/service-identity.js";
|
|
17
|
+
import { getRequestContext, runWithRequestContext, } from "../server/request-context.js";
|
|
18
|
+
export async function checkCredentialOrgMembership(input) {
|
|
19
|
+
const orgId = input.orgId.trim();
|
|
20
|
+
const email = input.email?.trim();
|
|
21
|
+
if (!orgId || !email)
|
|
22
|
+
return "not-member";
|
|
23
|
+
// A human OAuth subject can use a service-shaped address. Only the local
|
|
24
|
+
// record of an authenticated connect token proves it is a service identity.
|
|
25
|
+
const stored = input.storedConnectToken;
|
|
26
|
+
if (stored?.kind === "service" &&
|
|
27
|
+
stored.ownerEmail === email &&
|
|
28
|
+
stored.orgId === orgId &&
|
|
29
|
+
implicitServiceOrgRole({ email, orgId, requestOrgId: stored.orgId })) {
|
|
30
|
+
return "member";
|
|
31
|
+
}
|
|
32
|
+
const lookup = () => isOrgMember(orgId, email, { requireOrganizationMetadata: true });
|
|
33
|
+
const context = getRequestContext();
|
|
34
|
+
try {
|
|
35
|
+
const member = !input.requestOrigin || context?.requestOrigin
|
|
36
|
+
? await lookup()
|
|
37
|
+
: await runWithRequestContext({ ...context, requestOrigin: input.requestOrigin }, lookup);
|
|
38
|
+
return member ? "member" : "not-member";
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
// Missing organization tables mean a partial migration or a fresh
|
|
42
|
+
// database, not a removal. Refuse with a retryable error; revoking a
|
|
43
|
+
// refresh token on this could not be undone.
|
|
44
|
+
if (isMissingOrganizationTableError(error)) {
|
|
45
|
+
console.error("[mcp] Organization tables are missing; refusing the credential without revoking it.");
|
|
46
|
+
return "unavailable";
|
|
47
|
+
}
|
|
48
|
+
console.error("[mcp] Organization membership check failed; refusing the credential:", error);
|
|
49
|
+
return "unavailable";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function checkCredentialEmailRetirement(input) {
|
|
53
|
+
try {
|
|
54
|
+
const retiredAt = await readEmailRetiredAt(getDbExec(), input.email);
|
|
55
|
+
if (retiredAt === null)
|
|
56
|
+
return "current";
|
|
57
|
+
// `iat` truncates to whole seconds, so a credential signed in the same
|
|
58
|
+
// second as the rekey is refused.
|
|
59
|
+
return input.issuedAt !== undefined && input.issuedAt * 1000 > retiredAt
|
|
60
|
+
? "current"
|
|
61
|
+
: "retired";
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.error("[mcp] Email retirement check failed; refusing the credential:", error);
|
|
65
|
+
return "unavailable";
|
|
66
|
+
}
|
|
67
|
+
}
|