@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.
Files changed (118) hide show
  1. package/README.md +22 -22
  2. package/dist/a2a/server.d.ts +2 -2
  3. package/dist/a2a/server.js +13 -2
  4. package/dist/agent/thread-data-builder.d.ts +4 -0
  5. package/dist/agent/thread-data-builder.js +8 -0
  6. package/dist/agent-sql/analysis.d.ts +31 -0
  7. package/dist/agent-sql/analysis.js +169 -0
  8. package/dist/agent-sql/index.d.ts +4 -0
  9. package/dist/agent-sql/index.js +4 -0
  10. package/dist/agent-sql/lexer.d.ts +37 -0
  11. package/dist/agent-sql/lexer.js +357 -0
  12. package/dist/agent-sql/postgres.d.ts +51 -0
  13. package/dist/agent-sql/postgres.js +197 -0
  14. package/dist/agent-sql/query.d.ts +30 -0
  15. package/dist/agent-sql/query.js +514 -0
  16. package/dist/catalog.json +24 -24
  17. package/dist/client/analytics/index.d.ts +1 -1
  18. package/dist/client/analytics/index.js +1 -1
  19. package/dist/client/analytics.d.ts +8 -0
  20. package/dist/client/analytics.js +17 -2
  21. package/dist/client/session-replay.js +3 -2
  22. package/dist/identity/offboard.js +31 -3
  23. package/dist/identity/rekey.d.ts +12 -1
  24. package/dist/identity/rekey.js +77 -6
  25. package/dist/identity/retired-emails.d.ts +23 -0
  26. package/dist/identity/retired-emails.js +55 -0
  27. package/dist/mcp/actions/create-org-service-token.js +20 -8
  28. package/dist/mcp/actions/service-token-access.d.ts +1 -0
  29. package/dist/mcp/actions/service-token-access.js +41 -24
  30. package/dist/mcp/build-server.d.ts +20 -6
  31. package/dist/mcp/build-server.js +131 -35
  32. package/dist/mcp/connect-route.d.ts +4 -3
  33. package/dist/mcp/connect-route.js +140 -82
  34. package/dist/mcp/connect-store.d.ts +17 -11
  35. package/dist/mcp/connect-store.js +88 -60
  36. package/dist/mcp/credential-issuance.d.ts +23 -0
  37. package/dist/mcp/credential-issuance.js +77 -0
  38. package/dist/mcp/credential-membership.d.ts +26 -0
  39. package/dist/mcp/credential-membership.js +67 -0
  40. package/dist/mcp/oauth-route.js +133 -45
  41. package/dist/mcp/oauth-store.d.ts +15 -5
  42. package/dist/mcp/oauth-store.js +119 -34
  43. package/dist/mcp/oauth-token.d.ts +4 -0
  44. package/dist/mcp/oauth-token.js +5 -0
  45. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  46. package/dist/mcp/server.js +14 -0
  47. package/dist/onboarding/default-steps.js +1 -1
  48. package/dist/org/handlers.d.ts +1 -1
  49. package/dist/org/index.d.ts +2 -2
  50. package/dist/org/membership.d.ts +3 -1
  51. package/dist/org/membership.js +5 -2
  52. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  53. package/dist/provider-api/actions/provider-api.d.ts +10 -10
  54. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  55. package/dist/resource-changes/store.d.ts +2 -0
  56. package/dist/resource-changes/store.js +4 -0
  57. package/dist/resources/handlers.d.ts +1 -1
  58. package/dist/scripts/db/exec.js +28 -101
  59. package/dist/scripts/db/patch.js +14 -34
  60. package/dist/scripts/db/postgres-client.d.ts +8 -1
  61. package/dist/scripts/db/postgres-client.js +20 -6
  62. package/dist/scripts/db/query.js +31 -33
  63. package/dist/scripts/db/safety.d.ts +47 -2
  64. package/dist/scripts/db/safety.js +383 -137
  65. package/dist/scripts/db/scoping.d.ts +1 -0
  66. package/dist/scripts/db/scoping.js +3 -2
  67. package/dist/search/index-store.d.ts +21 -0
  68. package/dist/search/index-store.js +47 -0
  69. package/dist/search/indexer.js +35 -32
  70. package/dist/search/registry.d.ts +6 -3
  71. package/dist/search/registry.js +30 -8
  72. package/dist/secrets/routes.d.ts +6 -6
  73. package/dist/server/agent-chat-plugin.js +18 -6
  74. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  75. package/dist/server/agent-engine-default-model-route.d.ts +1 -1
  76. package/dist/server/agent-run-context.js +9 -0
  77. package/dist/server/attribution.d.ts +2 -0
  78. package/dist/server/attribution.js +7 -1
  79. package/dist/server/auth.js +9 -0
  80. package/dist/server/credential-membership-unavailable.d.ts +13 -0
  81. package/dist/server/credential-membership-unavailable.js +22 -0
  82. package/dist/server/index.d.ts +1 -0
  83. package/dist/server/index.js +1 -0
  84. package/dist/server/onboarding-html.js +1 -1
  85. package/dist/server/realtime-token.d.ts +1 -1
  86. package/dist/server/recap-image-route.js +4 -0
  87. package/dist/server/release-schema.js +4 -0
  88. package/dist/shared/docs-url.d.ts +3 -1
  89. package/dist/shared/docs-url.js +6 -3
  90. package/dist/shared/environment-lanes.d.ts +8 -0
  91. package/dist/shared/environment-lanes.js +26 -0
  92. package/dist/templates/chat/.agents/skills/security/SKILL.md +9 -2
  93. package/dist/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
  94. package/dist/templates/chat/README.md +2 -2
  95. package/dist/templates/default/.agents/skills/security/SKILL.md +9 -2
  96. package/dist/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
  97. package/dist/templates/default/app/routes/home.tsx +5 -1
  98. package/dist/templates/headless/.agents/skills/security/SKILL.md +9 -2
  99. package/dist/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
  100. package/dist/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
  101. package/dist/templates/workspace-core/.agents/skills/storing-data/SKILL.md +12 -7
  102. package/dist/tracking/providers.js +13 -1
  103. package/docs/content/authentication.mdx +5 -1
  104. package/docs/content/durable-background-runs.mdx +17 -8
  105. package/docs/content/external-agents.mdx +3 -1
  106. package/docs/content/mcp-protocol.mdx +6 -2
  107. package/docs/content/security.mdx +1 -1
  108. package/package.json +5 -4
  109. package/src/templates/chat/.agents/skills/security/SKILL.md +9 -2
  110. package/src/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
  111. package/src/templates/chat/README.md +2 -2
  112. package/src/templates/default/.agents/skills/security/SKILL.md +9 -2
  113. package/src/templates/default/.agents/skills/storing-data/SKILL.md +12 -7
  114. package/src/templates/default/app/routes/home.tsx +5 -1
  115. package/src/templates/headless/.agents/skills/security/SKILL.md +9 -2
  116. package/src/templates/headless/.agents/skills/storing-data/SKILL.md +12 -7
  117. package/src/templates/workspace-core/.agents/skills/security/SKILL.md +9 -2
  118. 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 === "delete" ? "delete" : "separate";
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
- return db.transaction ? db.transaction(run) : run(db);
344
+ if (!db.transaction)
345
+ throw new Error("Interactive transactions are unavailable");
346
+ return db.transaction(run);
319
347
  }
@@ -16,7 +16,12 @@ export type IdentityColumn = {
16
16
  orgScope?: IdentityOrgScope;
17
17
  };
18
18
  export type IdentityEmailChange = "rekey" | "delete" | "retain";
19
- export type IdentityOffboard = "transfer" | "delete" | "retain";
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
  /**
@@ -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
- { table: "mcp_connect_tokens", column: "owner_email" },
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
- { table: "mcp_oauth_refresh_tokens", column: "owner_email" },
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" || entry.offboard === "transfer"))
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" || entry.emailChange === "retain")
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 { requireServiceTokenCaller, ServiceTokenError, } from "./service-token-access.js";
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
- const minted = await mintOrgServiceToken({
52
- serviceName: args.name,
53
- orgId: caller.orgId,
54
- createdBy: caller.email,
55
- ttlDays: args.ttlDays,
56
- appUrl,
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
- const { rows } = await getDbExec().execute({
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 getOrgIdsForEmail(email) {
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
- const { rows } = await getDbExec().execute({
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
- return [];
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 getOrgIdsForEmail(email);
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 getOrgRoleForEmail(orgId, email);
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("Only org owners or admins can create or revoke service tokens.", 403);
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
- }): Promise<{
132
- authed: boolean;
133
- identity?: MCPCallerIdentity;
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>;