@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SESSION_REPLAY_IFRAME_ATTRIBUTE, SESSION_REPLAY_IFRAME_PROBE, SESSION_REPLAY_IFRAME_START, SESSION_REPLAY_IFRAME_STOP, } from "../session-replay-iframe-protocol.js";
|
|
2
|
+
import { resolveLaneEndpoint } from "../shared/environment-lanes.js";
|
|
2
3
|
import { loadOptionalPeer, OptionalPeerDependencyError, } from "../shared/optional-peer.js";
|
|
3
4
|
import { isSyntheticTrafficValue } from "../shared/test-traffic.js";
|
|
4
5
|
import { getOrCreateAnalyticsAnonymousId, getOrCreateAnalyticsSessionId, } from "./analytics-session.js";
|
|
@@ -535,13 +536,13 @@ function normalizeOptions(options) {
|
|
|
535
536
|
?.VITE_AGENT_NATIVE_ANALYTICS_PUBLIC_KEY;
|
|
536
537
|
if (!publicKey)
|
|
537
538
|
return null;
|
|
538
|
-
const endpoint = options.endpoint ||
|
|
539
|
+
const endpoint = resolveLaneEndpoint(options.endpoint ||
|
|
539
540
|
readFirstEnvString([
|
|
540
541
|
"VITE_AGENT_NATIVE_ANALYTICS_REPLAY_ENDPOINT",
|
|
541
542
|
"VITE_AGENT_NATIVE_SESSION_REPLAY_ENDPOINT",
|
|
542
543
|
"VITE_SESSION_REPLAY_INGEST_URL",
|
|
543
544
|
]) ||
|
|
544
|
-
defaultReplayEndpoint();
|
|
545
|
+
defaultReplayEndpoint(), typeof window !== "undefined" ? window.location.hostname : undefined);
|
|
545
546
|
const maxDurationMs = options.maxDurationMs ??
|
|
546
547
|
readFirstEnvNumber([
|
|
547
548
|
"VITE_AGENT_NATIVE_SESSION_REPLAY_MAX_DURATION_MS",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { ensureAuditTables } from "../audit/store.js";
|
|
3
3
|
import { resolveIdentityColumns, sessionUserColumn, } from "./rekey.js";
|
|
4
|
+
import { identityCredentialLockKey } from "./retired-emails.js";
|
|
4
5
|
const quote = (identifier) => `"${identifier.replaceAll('"', '""')}"`;
|
|
5
6
|
const ATTRIBUTION_COLUMNS = new Set([
|
|
6
7
|
"created_by",
|
|
@@ -13,11 +14,14 @@ const ATTRIBUTION_COLUMNS = new Set([
|
|
|
13
14
|
* What offboarding does to one registered column. App declarations state it;
|
|
14
15
|
* framework entries derive it from their mode. `separate` columns have a
|
|
15
16
|
* dedicated step in offboardMember (owner sweep, groups, OAuth, roles,
|
|
16
|
-
* memberships) instead of the generic loop.
|
|
17
|
+
* memberships) instead of the generic loop. An `owner_email` column with a
|
|
18
|
+
* non-transfer policy (credentials, grants) leaves the owner sweep for it.
|
|
17
19
|
*/
|
|
18
20
|
function offboardAction(entry) {
|
|
19
21
|
if (entry.column === "owner_email")
|
|
20
|
-
return entry.offboard
|
|
22
|
+
return entry.offboard && entry.offboard !== "transfer"
|
|
23
|
+
? entry.offboard
|
|
24
|
+
: "separate";
|
|
21
25
|
if (entry.offboard)
|
|
22
26
|
return entry.offboard;
|
|
23
27
|
if (entry.table === "user" ||
|
|
@@ -54,6 +58,12 @@ export async function offboardMember(db, email, options) {
|
|
|
54
58
|
await ensureAuditTables();
|
|
55
59
|
const run = async (tx) => {
|
|
56
60
|
const orgId = options.orgId?.trim() || null;
|
|
61
|
+
// Personal issuance has no membership row to wait on; this lock makes an
|
|
62
|
+
// in-flight issuer commit before the sweep or find its grant swept.
|
|
63
|
+
await tx.execute({
|
|
64
|
+
sql: "SELECT pg_advisory_xact_lock(hashtextextended(?, 0::bigint))",
|
|
65
|
+
args: [identityCredentialLockKey(oldEmail)],
|
|
66
|
+
});
|
|
57
67
|
const successor = await tx.execute({
|
|
58
68
|
sql: orgId
|
|
59
69
|
? `SELECT 1 FROM org_members
|
|
@@ -67,6 +77,13 @@ export async function offboardMember(db, email, options) {
|
|
|
67
77
|
throw new Error(orgId
|
|
68
78
|
? "Transfer target must be an active member of the organization"
|
|
69
79
|
: "Transfer target does not exist");
|
|
80
|
+
// An earlier issuer may also prepare its credential tables. Take the
|
|
81
|
+
// authoritative schema snapshot only after its membership fence releases.
|
|
82
|
+
await tx.execute({
|
|
83
|
+
sql: `SELECT id FROM org_members WHERE LOWER(email) = ?${orgId ? " AND org_id = ?" : ""}
|
|
84
|
+
ORDER BY org_id, id FOR UPDATE`,
|
|
85
|
+
args: orgId ? [oldEmail, orgId] : [oldEmail],
|
|
86
|
+
});
|
|
70
87
|
const schema = await tx.execute({
|
|
71
88
|
sql: `SELECT table_name, column_name FROM information_schema.columns
|
|
72
89
|
WHERE table_schema = 'public'
|
|
@@ -210,6 +227,15 @@ export async function offboardMember(db, email, options) {
|
|
|
210
227
|
args: [...match.args, ...scope.args],
|
|
211
228
|
});
|
|
212
229
|
}
|
|
230
|
+
else if (action === "revoke") {
|
|
231
|
+
if (!columns.has("revoked_at"))
|
|
232
|
+
throw new Error(`${entry.table}.revoked_at is missing; refusing an offboard that cannot revoke ${entry.table} credentials.`);
|
|
233
|
+
result = await tx.execute({
|
|
234
|
+
sql: `UPDATE ${quote(entry.table)} SET "revoked_at" = ?
|
|
235
|
+
WHERE LOWER(${column}) = ? AND "revoked_at" IS NULL${scope.sql}`,
|
|
236
|
+
args: [Date.now(), oldEmail, ...scope.args],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
213
239
|
else if (entry.mode === "owner" ||
|
|
214
240
|
entry.mode === "typed-scope" ||
|
|
215
241
|
entry.mode === "custom-scope" ||
|
|
@@ -315,5 +341,7 @@ export async function offboardMember(db, email, options) {
|
|
|
315
341
|
});
|
|
316
342
|
return counts;
|
|
317
343
|
};
|
|
318
|
-
|
|
344
|
+
if (!db.transaction)
|
|
345
|
+
throw new Error("Interactive transactions are unavailable");
|
|
346
|
+
return db.transaction(run);
|
|
319
347
|
}
|
package/dist/identity/rekey.d.ts
CHANGED
|
@@ -16,7 +16,12 @@ export type IdentityColumn = {
|
|
|
16
16
|
orgScope?: IdentityOrgScope;
|
|
17
17
|
};
|
|
18
18
|
export type IdentityEmailChange = "rekey" | "delete" | "retain";
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `revoke` sets `revoked_at` (epoch milliseconds) on rows not yet revoked. Use
|
|
21
|
+
* it for credential registries whose readers treat a missing row as "not
|
|
22
|
+
* revoked", where deleting the row would re-enable the credential.
|
|
23
|
+
*/
|
|
24
|
+
export type IdentityOffboard = "transfer" | "delete" | "revoke" | "retain";
|
|
20
25
|
export type IdentityOrgScope = {
|
|
21
26
|
column: string;
|
|
22
27
|
} | {
|
|
@@ -46,6 +51,12 @@ export type AppIdentityColumn = {
|
|
|
46
51
|
/** Why this policy is right for the data. Required so every declaration is a visible decision. */
|
|
47
52
|
reason: string;
|
|
48
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Framework identity columns. `owner_email` rows transfer to the successor on
|
|
56
|
+
* offboarding unless the entry says otherwise. Credentials say `delete` or
|
|
57
|
+
* `revoke`, because a transferred credential lets the removed member act as
|
|
58
|
+
* the successor. rekey.spec.ts pins that list.
|
|
59
|
+
*/
|
|
49
60
|
export declare const IDENTITY_REKEY_COLUMNS: readonly IdentityColumn[];
|
|
50
61
|
export declare const IDENTITY_REKEY_IGNORED_COLUMNS: Set<string>;
|
|
51
62
|
/**
|
package/dist/identity/rekey.js
CHANGED
|
@@ -3,6 +3,13 @@ import { jwtVerify } from "jose";
|
|
|
3
3
|
import { AGENT_AUDIT_LOG_CREATE_SQL } from "../audit/store.js";
|
|
4
4
|
import { decryptSecretValue, encryptSecretValue, isEncryptedSecretValue, } from "../secrets/crypto.js";
|
|
5
5
|
import { createTtlCache } from "../shared/ttl-cache.js";
|
|
6
|
+
import { IDENTITY_RETIRED_EMAILS_CREATE_SQL, identityCredentialLockKey, } from "./retired-emails.js";
|
|
7
|
+
/**
|
|
8
|
+
* Framework identity columns. `owner_email` rows transfer to the successor on
|
|
9
|
+
* offboarding unless the entry says otherwise. Credentials say `delete` or
|
|
10
|
+
* `revoke`, because a transferred credential lets the removed member act as
|
|
11
|
+
* the successor. rekey.spec.ts pins that list.
|
|
12
|
+
*/
|
|
6
13
|
export const IDENTITY_REKEY_COLUMNS = [
|
|
7
14
|
{ table: "user", column: "email" },
|
|
8
15
|
{ table: "invitation", column: "email" },
|
|
@@ -101,11 +108,28 @@ export const IDENTITY_REKEY_COLUMNS = [
|
|
|
101
108
|
{ table: "integration_remote_push_notifications", column: "owner_email" },
|
|
102
109
|
{ table: "integration_conversation_scopes", column: "owner_email" },
|
|
103
110
|
{ table: "integration_usage_budgets", column: "owner_email" },
|
|
104
|
-
{ table: "mcp_device_codes", column: "owner_email" },
|
|
105
|
-
|
|
111
|
+
{ table: "mcp_device_codes", column: "owner_email", offboard: "delete" },
|
|
112
|
+
// A missing connect-token row reads as "not revoked", so revoke, never delete.
|
|
113
|
+
{ table: "mcp_connect_tokens", column: "owner_email", offboard: "revoke" },
|
|
106
114
|
{ table: "mcp_connect_tokens", column: "created_by" },
|
|
107
|
-
{ table: "mcp_oauth_codes", column: "owner_email" },
|
|
108
|
-
{
|
|
115
|
+
{ table: "mcp_oauth_codes", column: "owner_email", offboard: "delete" },
|
|
116
|
+
{
|
|
117
|
+
table: "mcp_oauth_codes",
|
|
118
|
+
column: "issued_for_email",
|
|
119
|
+
emailChange: "retain",
|
|
120
|
+
offboard: "retain",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
table: "mcp_oauth_refresh_tokens",
|
|
124
|
+
column: "owner_email",
|
|
125
|
+
offboard: "revoke",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
table: "mcp_oauth_refresh_tokens",
|
|
129
|
+
column: "issued_for_email",
|
|
130
|
+
emailChange: "retain",
|
|
131
|
+
offboard: "retain",
|
|
132
|
+
},
|
|
109
133
|
{ table: "notifications", column: "owner", mode: "owner" },
|
|
110
134
|
{ table: "progress_runs", column: "owner", mode: "owner" },
|
|
111
135
|
{ table: "provider_corpus_jobs", column: "owner_email" },
|
|
@@ -167,6 +191,7 @@ export const IDENTITY_REKEY_IGNORED_COLUMNS = new Set([
|
|
|
167
191
|
"identity_rekeys.old_email",
|
|
168
192
|
"identity_rekeys.new_email",
|
|
169
193
|
"identity_rekeys.actor_email",
|
|
194
|
+
"identity_retired_emails.email",
|
|
170
195
|
// Actor kind enum ('user' | 'agent' | 'system'), not an address.
|
|
171
196
|
"context_directives.created_by",
|
|
172
197
|
"resources.created_by",
|
|
@@ -214,7 +239,9 @@ export function registerIdentityColumns(columns) {
|
|
|
214
239
|
if (!entry.reason?.trim())
|
|
215
240
|
throw new Error(`Identity column ${key} needs a reason.`);
|
|
216
241
|
if (entry.mode === "secret-scope" &&
|
|
217
|
-
(entry.emailChange === "delete" ||
|
|
242
|
+
(entry.emailChange === "delete" ||
|
|
243
|
+
entry.offboard === "transfer" ||
|
|
244
|
+
entry.offboard === "revoke"))
|
|
218
245
|
throw new Error(`Identity column ${key} is secret-scoped; it can only be rekeyed or retained on email change and deleted or retained on offboard.`);
|
|
219
246
|
if (core.has(key))
|
|
220
247
|
throw new Error(`Identity column ${key} is owned by the framework registry and cannot be redeclared by an app.`);
|
|
@@ -651,6 +678,27 @@ async function rekeyPromotedEvalDatasetKeys(db, oldEmail, newEmail, dryRun) {
|
|
|
651
678
|
await db.unsafe(`UPDATE agent_eval_datasets SET user_id = $1, idempotency_key = $2 WHERE id = $3`, [newEmail, update.nextKey, update.id]);
|
|
652
679
|
}
|
|
653
680
|
}
|
|
681
|
+
async function rekeyOAuthGrantOwners(db, oldEmail, newEmail, counts, dryRun) {
|
|
682
|
+
const handled = new Set();
|
|
683
|
+
for (const table of ["mcp_oauth_codes", "mcp_oauth_refresh_tokens"]) {
|
|
684
|
+
const available = await columns(db, table);
|
|
685
|
+
if (!available.has("owner_email") || !available.has("issued_for_email"))
|
|
686
|
+
continue;
|
|
687
|
+
const rows = await db.unsafe(`SELECT owner_email, issued_for_email FROM ${quote(table)} WHERE LOWER(owner_email) = LOWER($1) FOR UPDATE`, [oldEmail]);
|
|
688
|
+
counts[`${table}.owner_email`] = rows.length;
|
|
689
|
+
counts[`${table}.issued_for_email`] = rows.filter((row) => row.issued_for_email === row.owner_email).length;
|
|
690
|
+
handled.add(`${table}.owner_email`);
|
|
691
|
+
handled.add(`${table}.issued_for_email`);
|
|
692
|
+
if (dryRun || !rows.length)
|
|
693
|
+
continue;
|
|
694
|
+
// Invalid or legacy bindings must not become valid merely because the
|
|
695
|
+
// renamed owner happens to match the stored issuance address.
|
|
696
|
+
const updated = await db.unsafe(`UPDATE ${quote(table)} SET issued_for_email = CASE WHEN issued_for_email = owner_email THEN $2 ELSE NULL END, owner_email = $2 WHERE LOWER(owner_email) = LOWER($1) RETURNING owner_email`, [oldEmail, newEmail]);
|
|
697
|
+
if (updated.length !== rows.length)
|
|
698
|
+
throw new Error("OAuth grant ownership changed during email rekey.");
|
|
699
|
+
}
|
|
700
|
+
return handled;
|
|
701
|
+
}
|
|
654
702
|
/** Run inside the caller's PostgreSQL transaction. It deliberately refuses credential and derived-key stores it cannot safely rewrite. */
|
|
655
703
|
export async function rekeyIdentity(db, oldEmailInput, newEmailInput, options = {}) {
|
|
656
704
|
const oldEmail = oldEmailInput.trim().toLowerCase();
|
|
@@ -660,10 +708,24 @@ export async function rekeyIdentity(db, oldEmailInput, newEmailInput, options =
|
|
|
660
708
|
oldEmail.toLowerCase() === newEmail.toLowerCase()) {
|
|
661
709
|
throw new Error("Provide two different valid email addresses.");
|
|
662
710
|
}
|
|
711
|
+
// Credential issuance takes this lock for its owner before any row lock,
|
|
712
|
+
// including Personal issuance, which has no membership row to wait on.
|
|
713
|
+
for (const email of [oldEmail, newEmail].sort())
|
|
714
|
+
await db.unsafe(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0::bigint))`, [identityCredentialLockKey(email)]);
|
|
663
715
|
const identityColumns = await assertIdentityColumnsRegistered(db);
|
|
664
716
|
const userColumns = await columns(db, "user");
|
|
665
717
|
if (!userColumns.has("id") || !userColumns.has("email"))
|
|
666
718
|
throw new Error("Better Auth user table is unavailable; no identity data was changed.");
|
|
719
|
+
const membershipColumns = await columns(db, "org_members");
|
|
720
|
+
if (membershipColumns.has("email")) {
|
|
721
|
+
const order = ["org_id", "id"].filter((column) => membershipColumns.has(column));
|
|
722
|
+
if (!order.length)
|
|
723
|
+
order.push("email");
|
|
724
|
+
// Issuance and offboarding lock membership before credentials. Hold the
|
|
725
|
+
// same fence through every scan, including an initially empty grant store.
|
|
726
|
+
await db.unsafe(`SELECT ${quote(membershipColumns.has("id") ? "id" : "email")} FROM "org_members"
|
|
727
|
+
WHERE LOWER("email") = LOWER($1) ORDER BY ${order.map(quote).join(", ")} FOR UPDATE`, [oldEmail]);
|
|
728
|
+
}
|
|
667
729
|
const users = await db.unsafe(`SELECT "id" FROM "user" WHERE LOWER("email") = LOWER($1) FOR UPDATE`, [oldEmail]);
|
|
668
730
|
let userId;
|
|
669
731
|
if (options.accountAlreadyUpdated) {
|
|
@@ -683,8 +745,17 @@ export async function rekeyIdentity(db, oldEmailInput, newEmailInput, options =
|
|
|
683
745
|
const counts = {};
|
|
684
746
|
let oauthRevokedCount = 0;
|
|
685
747
|
counts["user.email"] = 1;
|
|
748
|
+
if (!options.dryRun) {
|
|
749
|
+
await db.unsafe(IDENTITY_RETIRED_EMAILS_CREATE_SQL);
|
|
750
|
+
await db.unsafe(`INSERT INTO identity_retired_emails (email, retired_at) VALUES ($1, $2)
|
|
751
|
+
ON CONFLICT (email) DO UPDATE
|
|
752
|
+
SET retired_at = GREATEST(identity_retired_emails.retired_at, EXCLUDED.retired_at)`, [oldEmail, Date.now()]);
|
|
753
|
+
}
|
|
754
|
+
const handledGrantOwners = await rekeyOAuthGrantOwners(db, oldEmail, newEmail, counts, options.dryRun === true);
|
|
686
755
|
for (const entry of identityColumns) {
|
|
687
|
-
if (entry.table === "user" ||
|
|
756
|
+
if (entry.table === "user" ||
|
|
757
|
+
entry.emailChange === "retain" ||
|
|
758
|
+
handledGrantOwners.has(`${entry.table}.${entry.column}`))
|
|
688
759
|
continue;
|
|
689
760
|
if (entry.mode === "unsupported-oauth") {
|
|
690
761
|
const oauthColumns = await columns(db, "oauth_tokens");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials this app signs (MCP OAuth access JWTs, connect tokens) are
|
|
3
|
+
* stateless, so an email rekey cannot recall the ones already issued for the
|
|
4
|
+
* old address. Rekey records the address here inside its own transaction.
|
|
5
|
+
* Admission then refuses credentials for it signed no later than that, and
|
|
6
|
+
* issuance refuses the address until an account holds it again.
|
|
7
|
+
*/
|
|
8
|
+
import type { DbExec } from "../db/client.js";
|
|
9
|
+
export declare const IDENTITY_RETIRED_EMAILS_CREATE_SQL = "CREATE TABLE IF NOT EXISTS identity_retired_emails (\n -- guard:allow-identity-column \u2014 the retired address itself; rekey and offboarding never rewrite it\n email TEXT PRIMARY KEY,\n retired_at BIGINT NOT NULL\n)";
|
|
10
|
+
export declare function ensureTable(): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Key for `pg_advisory_xact_lock(hashtextextended(key, 0))`. Rekey takes it
|
|
13
|
+
* for both addresses, offboarding for the removed member, and credential
|
|
14
|
+
* issuance for its owner, each before any row lock, so issuance cannot write
|
|
15
|
+
* a grant for an address mid-rekey or mid-offboard.
|
|
16
|
+
*/
|
|
17
|
+
export declare function identityCredentialLockKey(email: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Epoch milliseconds of the latest rekey away from `email`, or null when no
|
|
20
|
+
* rekey has. A database no rekey has touched has no table, which is the same
|
|
21
|
+
* answer.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readEmailRetiredAt(exec: DbExec, email: string): Promise<number | null>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ensureTableExists } from "../db/ddl-guard.js";
|
|
2
|
+
export const IDENTITY_RETIRED_EMAILS_CREATE_SQL = `CREATE TABLE IF NOT EXISTS identity_retired_emails (
|
|
3
|
+
-- guard:allow-identity-column — the retired address itself; rekey and offboarding never rewrite it
|
|
4
|
+
email TEXT PRIMARY KEY,
|
|
5
|
+
retired_at BIGINT NOT NULL
|
|
6
|
+
)`;
|
|
7
|
+
let _initPromise;
|
|
8
|
+
export async function ensureTable() {
|
|
9
|
+
if (!_initPromise) {
|
|
10
|
+
_initPromise = ensureTableExists("identity_retired_emails", IDENTITY_RETIRED_EMAILS_CREATE_SQL)
|
|
11
|
+
.then(() => undefined)
|
|
12
|
+
.catch((error) => {
|
|
13
|
+
_initPromise = undefined;
|
|
14
|
+
throw error;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return _initPromise;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Key for `pg_advisory_xact_lock(hashtextextended(key, 0))`. Rekey takes it
|
|
21
|
+
* for both addresses, offboarding for the removed member, and credential
|
|
22
|
+
* issuance for its owner, each before any row lock, so issuance cannot write
|
|
23
|
+
* a grant for an address mid-rekey or mid-offboard.
|
|
24
|
+
*/
|
|
25
|
+
export function identityCredentialLockKey(email) {
|
|
26
|
+
return `identity-credentials:${email.trim().toLowerCase()}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Epoch milliseconds of the latest rekey away from `email`, or null when no
|
|
30
|
+
* rekey has. A database no rekey has touched has no table, which is the same
|
|
31
|
+
* answer.
|
|
32
|
+
*/
|
|
33
|
+
export async function readEmailRetiredAt(exec, email) {
|
|
34
|
+
// Probe the catalog instead of catching a missing relation: inside an
|
|
35
|
+
// issuance transaction a failed statement would abort the transaction.
|
|
36
|
+
const probe = await exec.execute({
|
|
37
|
+
sql: `SELECT to_regclass('identity_retired_emails') IS NOT NULL AS present`,
|
|
38
|
+
args: [],
|
|
39
|
+
});
|
|
40
|
+
const present = probe.rows[0]?.present;
|
|
41
|
+
if (present === false)
|
|
42
|
+
return null;
|
|
43
|
+
if (present !== true)
|
|
44
|
+
throw new Error("Could not determine whether identity_retired_emails exists.");
|
|
45
|
+
const { rows } = await exec.execute({
|
|
46
|
+
sql: `SELECT retired_at FROM identity_retired_emails WHERE email = ?`,
|
|
47
|
+
args: [email.trim().toLowerCase()],
|
|
48
|
+
});
|
|
49
|
+
if (!rows.length)
|
|
50
|
+
return null;
|
|
51
|
+
const retiredAt = Number(rows[0].retired_at);
|
|
52
|
+
if (!Number.isSafeInteger(retiredAt) || retiredAt <= 0)
|
|
53
|
+
throw new Error("identity_retired_emails.retired_at is unreadable.");
|
|
54
|
+
return retiredAt;
|
|
55
|
+
}
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { defineAction } from "../../action.js";
|
|
20
20
|
import { getAppProductionUrl } from "../../server/app-url.js";
|
|
21
|
+
import { CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE } from "../../server/credential-membership-unavailable.js";
|
|
21
22
|
import { getRequestContext } from "../../server/request-context.js";
|
|
22
23
|
import { mintOrgServiceToken } from "../connect-route.js";
|
|
23
|
-
import {
|
|
24
|
+
import { McpCredentialIssuanceError } from "../credential-issuance.js";
|
|
25
|
+
import { requireServiceTokenCaller, SERVICE_TOKEN_MANAGE_FORBIDDEN_MESSAGE, ServiceTokenError, } from "./service-token-access.js";
|
|
24
26
|
export default defineAction({
|
|
25
27
|
description: "Create a named, org-scoped service token (for CI like PR Visual Recap's PLAN_RECAP_TOKEN). The token acts as a service principal owned by the organization, not a person. Org owner/admin only. The token value is returned ONCE and never stored — copy it immediately.",
|
|
26
28
|
schema: z.object({
|
|
@@ -48,13 +50,23 @@ export default defineAction({
|
|
|
48
50
|
if (!appUrl && !process.env.A2A_SECRET?.trim()) {
|
|
49
51
|
throw new ServiceTokenError("Could not determine the app URL needed to mint a token. Set APP_URL on the deployment.", 500);
|
|
50
52
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
let minted;
|
|
54
|
+
try {
|
|
55
|
+
minted = await mintOrgServiceToken({
|
|
56
|
+
serviceName: args.name,
|
|
57
|
+
orgId: caller.orgId,
|
|
58
|
+
createdBy: caller.email,
|
|
59
|
+
ttlDays: args.ttlDays,
|
|
60
|
+
appUrl,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (!(error instanceof McpCredentialIssuanceError))
|
|
65
|
+
throw error;
|
|
66
|
+
throw error.reason === "not-member"
|
|
67
|
+
? new ServiceTokenError(SERVICE_TOKEN_MANAGE_FORBIDDEN_MESSAGE, 403)
|
|
68
|
+
: new ServiceTokenError(CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE, 503);
|
|
69
|
+
}
|
|
58
70
|
return {
|
|
59
71
|
// The ONLY place the secret ever appears. Never stored, never logged.
|
|
60
72
|
token: minted.token,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OrgRole } from "../../org/types.js";
|
|
2
|
+
export declare const SERVICE_TOKEN_MANAGE_FORBIDDEN_MESSAGE = "Only org owners or admins can create or revoke service tokens.";
|
|
2
3
|
export declare class ServiceTokenError extends Error {
|
|
3
4
|
statusCode: number;
|
|
4
5
|
constructor(message: string, statusCode: number);
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* tokens or revoke others — the role lookup simply finds no membership.
|
|
13
13
|
*/
|
|
14
14
|
import { getDbExec } from "../../db/client.js";
|
|
15
|
+
import { isMissingOrganizationTableError } from "../../org/membership.js";
|
|
16
|
+
import { CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE } from "../../server/credential-membership-unavailable.js";
|
|
17
|
+
export const SERVICE_TOKEN_MANAGE_FORBIDDEN_MESSAGE = "Only org owners or admins can create or revoke service tokens.";
|
|
15
18
|
export class ServiceTokenError extends Error {
|
|
16
19
|
statusCode;
|
|
17
20
|
constructor(message, statusCode) {
|
|
@@ -20,37 +23,51 @@ export class ServiceTokenError extends Error {
|
|
|
20
23
|
this.statusCode = statusCode;
|
|
21
24
|
}
|
|
22
25
|
}
|
|
26
|
+
async function readOrgRole(orgId, email) {
|
|
27
|
+
const { rows } = await getDbExec().execute({
|
|
28
|
+
sql: `SELECT role FROM org_members
|
|
29
|
+
WHERE org_id = ? AND LOWER(email) = ?
|
|
30
|
+
AND federation_removal_pending_at IS NULL
|
|
31
|
+
LIMIT 1`,
|
|
32
|
+
args: [orgId, email.toLowerCase()],
|
|
33
|
+
});
|
|
34
|
+
const role = rows[0]?.role;
|
|
35
|
+
return role === "owner" || role === "admin" || role === "member"
|
|
36
|
+
? role
|
|
37
|
+
: null;
|
|
38
|
+
}
|
|
23
39
|
export async function getOrgRoleForEmail(orgId, email) {
|
|
24
40
|
try {
|
|
25
|
-
|
|
26
|
-
sql: `SELECT role FROM org_members
|
|
27
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
28
|
-
AND federation_removal_pending_at IS NULL
|
|
29
|
-
LIMIT 1`,
|
|
30
|
-
args: [orgId, email.toLowerCase()],
|
|
31
|
-
});
|
|
32
|
-
const role = rows[0]?.role;
|
|
33
|
-
return role === "owner" || role === "admin" || role === "member"
|
|
34
|
-
? role
|
|
35
|
-
: null;
|
|
41
|
+
return await readOrgRole(orgId, email);
|
|
36
42
|
}
|
|
37
43
|
catch {
|
|
38
44
|
// org tables not provisioned (template without orgs) → no membership.
|
|
39
45
|
return null;
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
|
-
async function
|
|
48
|
+
async function readOrgIds(email) {
|
|
49
|
+
const { rows } = await getDbExec().execute({
|
|
50
|
+
sql: `SELECT org_id FROM org_members
|
|
51
|
+
WHERE LOWER(email) = ?
|
|
52
|
+
AND federation_removal_pending_at IS NULL`,
|
|
53
|
+
args: [email.toLowerCase()],
|
|
54
|
+
});
|
|
55
|
+
return rows.map((r) => String(r.org_id)).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A template without orgs has no org tables, which is no membership. Any
|
|
59
|
+
* other failure is an outage, and answering it with a 400/403 would tell an
|
|
60
|
+
* admin they lost a role they still hold.
|
|
61
|
+
*/
|
|
62
|
+
async function lookupMembership(read, absent) {
|
|
43
63
|
try {
|
|
44
|
-
|
|
45
|
-
sql: `SELECT org_id FROM org_members
|
|
46
|
-
WHERE LOWER(email) = ?
|
|
47
|
-
AND federation_removal_pending_at IS NULL`,
|
|
48
|
-
args: [email.toLowerCase()],
|
|
49
|
-
});
|
|
50
|
-
return rows.map((r) => String(r.org_id)).filter(Boolean);
|
|
64
|
+
return await read();
|
|
51
65
|
}
|
|
52
|
-
catch {
|
|
53
|
-
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (isMissingOrganizationTableError(error))
|
|
68
|
+
return absent;
|
|
69
|
+
console.error("[service-tokens] Membership lookup failed:", error);
|
|
70
|
+
throw new ServiceTokenError(CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE, 503);
|
|
54
71
|
}
|
|
55
72
|
}
|
|
56
73
|
export async function requireServiceTokenCaller(params) {
|
|
@@ -60,7 +77,7 @@ export async function requireServiceTokenCaller(params) {
|
|
|
60
77
|
}
|
|
61
78
|
let orgId = params.orgId?.trim() || "";
|
|
62
79
|
if (!orgId) {
|
|
63
|
-
const memberOrgs = await
|
|
80
|
+
const memberOrgs = await lookupMembership(() => readOrgIds(email), []);
|
|
64
81
|
if (memberOrgs.length === 0) {
|
|
65
82
|
throw new ServiceTokenError("No active organization. Service tokens are org-scoped — join or create an organization first.", 400);
|
|
66
83
|
}
|
|
@@ -70,12 +87,12 @@ export async function requireServiceTokenCaller(params) {
|
|
|
70
87
|
}
|
|
71
88
|
orgId = memberOrgs[0];
|
|
72
89
|
}
|
|
73
|
-
const role = await
|
|
90
|
+
const role = await lookupMembership(() => readOrgRole(orgId, email), null);
|
|
74
91
|
if (!role) {
|
|
75
92
|
throw new ServiceTokenError("You are not a member of this organization.", 403);
|
|
76
93
|
}
|
|
77
94
|
if (params.level === "manage" && role === "member") {
|
|
78
|
-
throw new ServiceTokenError(
|
|
95
|
+
throw new ServiceTokenError(SERVICE_TOKEN_MANAGE_FORBIDDEN_MESSAGE, 403);
|
|
79
96
|
}
|
|
80
97
|
return { email, orgId, role };
|
|
81
98
|
}
|
|
@@ -103,6 +103,19 @@ export declare function conciseToolResultText(name: string, result: unknown, opt
|
|
|
103
103
|
export declare function createMCPServerForRequest(config: MCPConfig, identity: MCPCallerIdentity | undefined, requestMeta?: MCPRequestMeta): Promise<import("@modelcontextprotocol/server").Server>;
|
|
104
104
|
export declare function getAccessTokens(): string[];
|
|
105
105
|
export declare function getBearerToken(authHeader: string | undefined): string | undefined;
|
|
106
|
+
export type VerifyAuthResult = {
|
|
107
|
+
authed: boolean;
|
|
108
|
+
identity?: MCPCallerIdentity;
|
|
109
|
+
fullSurface?: boolean;
|
|
110
|
+
fullCatalog?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* The token verified, but its standing could not be checked: the
|
|
113
|
+
* connect-token revocation or org lookup, the retired-address check, or the
|
|
114
|
+
* membership check hit a database or identity-authority error. Answer with
|
|
115
|
+
* a retryable error, not an auth challenge: signing in again would not help.
|
|
116
|
+
*/
|
|
117
|
+
unavailable?: true;
|
|
118
|
+
};
|
|
106
119
|
/**
|
|
107
120
|
* Verify the inbound auth header. Returns:
|
|
108
121
|
* - { authed: true, identity } when verified — `identity` is derived from
|
|
@@ -124,15 +137,16 @@ export declare function getBearerToken(authHeader: string | undefined): string |
|
|
|
124
137
|
* is consulted ONLY on the static-token / dev-open path (never to influence
|
|
125
138
|
* verified JWT identity), so the install flow runs tools as the configured
|
|
126
139
|
* owner instead of an unscoped anonymous caller.
|
|
140
|
+
*
|
|
141
|
+
* A credential this app issued that names an organization is admitted only
|
|
142
|
+
* while its subject is still a member, and none is admitted for an address an
|
|
143
|
+
* email change retired after signing (see `admitIssuedCredential`).
|
|
127
144
|
*/
|
|
128
145
|
export declare function verifyAuth(authHeader: string | undefined, ownerEmailHeader?: string, options?: {
|
|
129
146
|
allowDevOpen?: boolean;
|
|
130
147
|
resourceUrl?: string | string[];
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
fullSurface?: boolean;
|
|
135
|
-
fullCatalog?: boolean;
|
|
136
|
-
}>;
|
|
148
|
+
/** This app's public origin; federated orgs need it for the membership check. */
|
|
149
|
+
requestOrigin?: string;
|
|
150
|
+
}): Promise<VerifyAuthResult>;
|
|
137
151
|
export declare function resolveOrgIdFromDomain(orgDomain: string | undefined): Promise<string | undefined>;
|
|
138
152
|
export declare function resolveMcpIdentityOrgId(identity: MCPCallerIdentity | undefined): Promise<string | undefined>;
|