@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
@@ -1985,38 +1985,117 @@ function mcpAudienceList(resource) {
1985
1985
  }
1986
1986
  return out;
1987
1987
  }
1988
- async function isConnectTokenAllowed(jti) {
1988
+ /**
1989
+ * Null while the connect token is active. A revoked or jti-less token is
1990
+ * refused, and unreadable revocation state answers a retryable 503.
1991
+ */
1992
+ async function refuseInactiveConnectToken(jti) {
1989
1993
  if (!jti)
1990
- return false;
1994
+ return { authed: false };
1995
+ const { isJtiRevoked } = await import("./connect-store.js");
1991
1996
  try {
1992
- const { isJtiRevoked, touchTokenUsed } = await import("./connect-store.js");
1993
- if (await isJtiRevoked(jti))
1994
- return false;
1997
+ return (await isJtiRevoked(jti)) ? { authed: false } : null;
1998
+ }
1999
+ catch (error) {
2000
+ console.error("[mcp] Connect-token revocation check failed; refusing the token:", error);
2001
+ return { authed: false, unavailable: true };
2002
+ }
2003
+ }
2004
+ /**
2005
+ * Records a connect token's use once the request is admitted, so a refused
2006
+ * call (revoked, removed member, membership check unavailable) never moves
2007
+ * `last_used_at`.
2008
+ */
2009
+ async function markConnectTokenUsed(jti) {
2010
+ if (!jti)
2011
+ return;
2012
+ try {
2013
+ const { touchTokenUsed } = await import("./connect-store.js");
1995
2014
  void touchTokenUsed(jti);
1996
2015
  }
1997
2016
  catch {
1998
- // Store import / lookup failed — fail open. Signature verification already
1999
- // passed; this only gates explicit revokes.
2017
+ // coercion-ok: last_used_at is informational; failing to record it must not refuse an admitted request.
2000
2018
  }
2001
- return true;
2002
2019
  }
2003
- async function resolveConnectTokenOrgId(jti, claimedOrgId) {
2020
+ /**
2021
+ * `connectJti` is set only for connect tokens. Any other token without an
2022
+ * `org_id` claim is `unclaimed`, and its org is resolved later from
2023
+ * `org_domain` or the caller's email.
2024
+ */
2025
+ async function resolveConnectTokenOrgId(connectJti, claimedOrgId) {
2026
+ let stored;
2027
+ if (connectJti) {
2028
+ const { lookupConnectTokenOrg } = await import("./connect-store.js");
2029
+ stored = await lookupConnectTokenOrg(connectJti);
2030
+ if (stored.status === "unavailable")
2031
+ return stored;
2032
+ }
2004
2033
  if (claimedOrgId !== undefined) {
2005
- return { status: "claimed", orgId: claimedOrgId };
2034
+ return {
2035
+ status: "claimed",
2036
+ orgId: claimedOrgId,
2037
+ ...(stored?.status === "found" ? { storedConnectToken: stored } : {}),
2038
+ };
2006
2039
  }
2007
- if (!jti)
2008
- return { status: "missing" };
2009
- const { lookupConnectTokenOrg } = await import("./connect-store.js");
2010
- return lookupConnectTokenOrg(jti);
2040
+ return stored ?? { status: "unclaimed" };
2011
2041
  }
2012
2042
  function orgIdFromConnectTokenResolution(resolution) {
2013
- if (resolution.status === "claimed")
2014
- return resolution.orgId;
2015
- if (resolution.status === "found") {
2043
+ if (resolution.status === "claimed" || resolution.status === "found") {
2016
2044
  return resolution.orgId;
2017
2045
  }
2046
+ // A connect token with no row here was not issued for any org this app
2047
+ // knows. Its `org_domain` claim must not grant that domain's org, so it
2048
+ // runs Personal.
2049
+ if (resolution.status === "missing")
2050
+ return null;
2018
2051
  return undefined;
2019
2052
  }
2053
+ /**
2054
+ * Credentials this app issues (MCP OAuth access tokens and connect tokens)
2055
+ * carry the organization chosen when they were issued: the signed `org_id`
2056
+ * claim, or the stored org of a connect token. Membership can end after
2057
+ * issuance, so that org is admitted only while the subject is still a member
2058
+ * here. The action-route bearer path reuses verifyAuth and gets the same
2059
+ * check.
2060
+ *
2061
+ * They also carry the subject's address, which an email change retires; a
2062
+ * credential signed for it before the change is refused, Personal or not.
2063
+ *
2064
+ * Cross-app A2A JWTs, first-party MCP tokens included, are not checked: their
2065
+ * `org_id`, like `org_domain`, is the signing app's assertion, and the caller
2066
+ * may have no membership row in this app's database.
2067
+ */
2068
+ async function admitIssuedCredential(result, requestOrigin, orgResolution, issuedAt) {
2069
+ const { checkCredentialEmailRetirement, checkCredentialOrgMembership } = await import("./credential-membership.js");
2070
+ if (result.identity.userEmail) {
2071
+ const retirement = await checkCredentialEmailRetirement({
2072
+ email: result.identity.userEmail,
2073
+ issuedAt,
2074
+ });
2075
+ if (retirement !== "current")
2076
+ return retirement === "unavailable"
2077
+ ? { authed: false, unavailable: true }
2078
+ : { authed: false };
2079
+ }
2080
+ const orgId = result.identity.orgId;
2081
+ if (typeof orgId !== "string" || !orgId)
2082
+ return result;
2083
+ const membership = await checkCredentialOrgMembership({
2084
+ orgId,
2085
+ email: result.identity.userEmail,
2086
+ requestOrigin,
2087
+ ...(orgResolution.status === "found"
2088
+ ? { storedConnectToken: orgResolution }
2089
+ : orgResolution.status === "claimed" && orgResolution.storedConnectToken
2090
+ ? { storedConnectToken: orgResolution.storedConnectToken }
2091
+ : {}),
2092
+ });
2093
+ if (membership === "member")
2094
+ return result;
2095
+ return membership === "unavailable"
2096
+ ? { authed: false, unavailable: true }
2097
+ : { authed: false };
2098
+ }
2020
2099
  /**
2021
2100
  * Verify the inbound auth header. Returns:
2022
2101
  * - { authed: true, identity } when verified — `identity` is derived from
@@ -2038,6 +2117,10 @@ function orgIdFromConnectTokenResolution(resolution) {
2038
2117
  * is consulted ONLY on the static-token / dev-open path (never to influence
2039
2118
  * verified JWT identity), so the install flow runs tools as the configured
2040
2119
  * owner instead of an unscoped anonymous caller.
2120
+ *
2121
+ * A credential this app issued that names an organization is admitted only
2122
+ * while its subject is still a member, and none is admitted for an address an
2123
+ * email change retired after signing (see `admitIssuedCredential`).
2041
2124
  */
2042
2125
  export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2043
2126
  const accessTokens = getAccessTokens();
@@ -2046,18 +2129,19 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2046
2129
  if (token) {
2047
2130
  const oauthIdentity = await verifyMcpOAuthAccessToken(token, options.resourceUrl);
2048
2131
  if (oauthIdentity) {
2049
- if (oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID &&
2050
- !(await isConnectTokenAllowed(oauthIdentity.jti))) {
2051
- return { authed: false };
2132
+ if (oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID) {
2133
+ const refused = await refuseInactiveConnectToken(oauthIdentity.jti);
2134
+ if (refused)
2135
+ return refused;
2052
2136
  }
2053
2137
  const orgResolution = await resolveConnectTokenOrgId(oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID
2054
2138
  ? oauthIdentity.jti
2055
2139
  : undefined, oauthIdentity.orgId);
2056
2140
  if (orgResolution.status === "unavailable") {
2057
- return { authed: false };
2141
+ return { authed: false, unavailable: true };
2058
2142
  }
2059
2143
  const orgId = orgIdFromConnectTokenResolution(orgResolution);
2060
- return {
2144
+ const admitted = await admitIssuedCredential({
2061
2145
  authed: true,
2062
2146
  identity: {
2063
2147
  userEmail: oauthIdentity.userEmail,
@@ -2068,7 +2152,12 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2068
2152
  },
2069
2153
  fullSurface: true,
2070
2154
  fullCatalog: oauthIdentity.catalogScope === "full",
2071
- };
2155
+ }, options.requestOrigin, orgResolution, oauthIdentity.issuedAt);
2156
+ if (admitted.authed &&
2157
+ oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID) {
2158
+ await markConnectTokenUsed(oauthIdentity.jti);
2159
+ }
2160
+ return admitted;
2072
2161
  }
2073
2162
  }
2074
2163
  if (accessTokens.length === 0 && !hasA2ASecret && !token) {
@@ -2092,25 +2181,25 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2092
2181
  // Connect-minted tokens (scope === "mcp-connect") carry a random `jti`
2093
2182
  // and are individually revocable. Only these tokens hit the revoke
2094
2183
  // store — ordinary A2A delegation JWTs skip the DB lookup entirely so
2095
- // the hot path is unchanged. The signature was already
2096
- // cryptographically verified, so failing open here only widens the
2097
- // explicit-revoke gate, never the trust boundary.
2184
+ // the hot path is unchanged.
2098
2185
  if (tokenScope === MCP_CONNECT_SCOPE) {
2099
- if (!(await isConnectTokenAllowed(payload.jti))) {
2100
- return { authed: false };
2101
- }
2186
+ const refused = await refuseInactiveConnectToken(payload.jti);
2187
+ if (refused)
2188
+ return refused;
2102
2189
  }
2103
2190
  const orgIdClaim = parseMcpOAuthOrgIdClaim(payload);
2104
2191
  if (!orgIdClaim)
2105
2192
  return { authed: false };
2106
- const orgResolution = await resolveConnectTokenOrgId(tokenScope === MCP_CONNECT_SCOPE
2193
+ const firstPartyMcp = payload.agent_native_first_party_mcp === true;
2194
+ const orgResolution = await resolveConnectTokenOrgId(tokenScope === MCP_CONNECT_SCOPE &&
2195
+ (!firstPartyMcp || orgIdClaim.orgId === undefined)
2107
2196
  ? payload.jti
2108
2197
  : undefined, orgIdClaim.orgId);
2109
2198
  if (orgResolution.status === "unavailable") {
2110
- return { authed: false };
2199
+ return { authed: false, unavailable: true };
2111
2200
  }
2112
2201
  const orgId = orgIdFromConnectTokenResolution(orgResolution);
2113
- return {
2202
+ const verified = {
2114
2203
  authed: true,
2115
2204
  identity: {
2116
2205
  userEmail: typeof payload.sub === "string" ? payload.sub : undefined,
@@ -2118,13 +2207,20 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2118
2207
  orgDomain: typeof payload.org_domain === "string"
2119
2208
  ? payload.org_domain
2120
2209
  : undefined,
2121
- ...(payload.agent_native_first_party_mcp === true
2122
- ? { firstPartyMcp: true }
2123
- : {}),
2210
+ ...(firstPartyMcp ? { firstPartyMcp: true } : {}),
2124
2211
  },
2125
2212
  fullSurface: true,
2126
2213
  fullCatalog: payload.catalog_scope === "full",
2127
2214
  };
2215
+ // First-party MCP tokens share the connect scope but are minted by a
2216
+ // sibling app per call, so they are cross-app A2A tokens.
2217
+ const admitted = tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp
2218
+ ? await admitIssuedCredential(verified, options.requestOrigin, orgResolution, typeof payload.iat === "number" ? payload.iat : undefined)
2219
+ : verified;
2220
+ if (admitted.authed && tokenScope === MCP_CONNECT_SCOPE) {
2221
+ await markConnectTokenUsed(payload.jti);
2222
+ }
2223
+ return admitted;
2128
2224
  }
2129
2225
  if (accessTokens.length === 0 && !hasA2ASecret) {
2130
2226
  if (options.allowDevOpen === false) {
@@ -16,9 +16,10 @@ export interface McpConnectRouteOptions {
16
16
  * random `jti` is stored, so the standard revocation path
17
17
  * (`isJtiRevoked` in `verifyAuth`) applies to service tokens identically.
18
18
  *
19
- * Authorization is the CALLER'S responsibility: this function does not check
20
- * org membership/role. The `create-org-service-token` action gates on org
21
- * owner/admin before calling it.
19
+ * The `create-org-service-token` action gates on org owner/admin before
20
+ * calling this. Offboarding can remove that admin before the mint, so the
21
+ * creator's role is rechecked under the membership lock offboarding takes,
22
+ * and the token is recorded in the same transaction.
22
23
  */
23
24
  export declare function mintOrgServiceToken(params: {
24
25
  serviceName: string;
@@ -7,9 +7,12 @@ import { resolveLocaleFromRequest } from "../localization/server.js";
7
7
  import { localeDirection, normalizeLocaleCode, } from "../localization/shared.js";
8
8
  import { getOrgDomain } from "../org/context.js";
9
9
  import { getSession, getConfiguredLoginHtml, isLoopbackRequest, } from "../server/auth.js";
10
+ import { CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE } from "../server/credential-membership-unavailable.js";
11
+ import { readDeployCredentialEnv } from "../server/credential-provider.js";
10
12
  import { readBody } from "../server/h3-helpers.js";
11
13
  import { MCP_CONNECT_MCP_URL_TEMPLATE, getMcpConnectGuides, getMcpStaticTokenFallback, interpolateMcpConnectTemplate, resolveMcpConnectGuideId, } from "../shared/mcp-connect-content.js";
12
- import { recordMintedToken, listTokens, revokeToken, normalizeServiceName, serviceIdentityEmail, createDeviceCode, getDeviceCode, getDeviceCodeByUserCode, approveDeviceCode, consumeDeviceCode, claimDeviceCodeForMint, finishDeviceCodeMint, releaseDeviceCodeMint, expireDeviceCode, MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, DEFAULT_TOKEN_TTL_DAYS, MIN_TOKEN_TTL_DAYS, MAX_TOKEN_TTL_DAYS, DEVICE_CODE_TTL_MS, } from "./connect-store.js";
14
+ import { ensureConnectTables, recordMintedToken, listTokens, revokeToken, normalizeServiceName, serviceIdentityEmail, createDeviceCode, getDeviceCode, getDeviceCodeByUserCode, approveDeviceCode, consumeDeviceCode, claimDeviceCodeForMint, finishDeviceCodeMint, expireDeviceCode, MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, DEFAULT_TOKEN_TTL_DAYS, MIN_TOKEN_TTL_DAYS, MAX_TOKEN_TTL_DAYS, DEVICE_CODE_TTL_MS, } from "./connect-store.js";
15
+ import { McpCredentialIssuanceError, withMcpCredentialIssuance, } from "./credential-issuance.js";
13
16
  import { MCP_OAUTH_DEFAULT_SCOPE, signMcpOAuthAccessToken, } from "./oauth-token.js";
14
17
  import { resolveMcpOrgChoices } from "./org-choice.js";
15
18
  import { MCP_PUBLIC_ROUTE_PREFIX } from "./route-paths.js";
@@ -21,6 +24,22 @@ function json(body, status = 200) {
21
24
  headers: { "Content-Type": "application/json" },
22
25
  });
23
26
  }
27
+ function issuanceErrorResponse(err) {
28
+ if (err.reason === "not-member") {
29
+ return json({ error: "Choose an organization you belong to." }, 403);
30
+ }
31
+ const response = json({ error: CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE }, 503);
32
+ response.headers.set("Retry-After", "5");
33
+ return response;
34
+ }
35
+ async function prepareConnectIssuance() {
36
+ try {
37
+ await ensureConnectTables();
38
+ }
39
+ catch (error) {
40
+ throw new McpCredentialIssuanceError("unavailable", { cause: error });
41
+ }
42
+ }
24
43
  function html(body, status = 200) {
25
44
  return new Response(body, {
26
45
  status,
@@ -91,7 +110,7 @@ function canUseDevOpenConnect(event) {
91
110
  // by spoofing `Host: localhost`.
92
111
  return (isLoopbackRequest(event) &&
93
112
  isLoopbackOrigin(deriveOrigin(event)) &&
94
- !process.env.A2A_SECRET?.trim() &&
113
+ !readDeployCredentialEnv("A2A_SECRET")?.trim() &&
95
114
  !process.env.ACCESS_TOKEN?.trim() &&
96
115
  !process.env.ACCESS_TOKENS?.trim());
97
116
  }
@@ -125,26 +144,33 @@ function clampTtlDays(input) {
125
144
  */
126
145
  async function mintConnectToken(params) {
127
146
  const orgDomain = await resolveOrgDomain(params.orgId);
128
- const jti = randomUUID();
129
- const token = await signConnectToken({
130
- ownerEmail: params.email,
147
+ await prepareConnectIssuance();
148
+ return withMcpCredentialIssuance({
149
+ email: params.email,
131
150
  orgId: params.orgId,
132
- orgDomain,
133
- appUrl: params.appUrl,
134
- expiresIn: `${params.ttlDays}d`,
135
- jti,
136
- ...(params.catalogScope === "full" ? { catalogScope: "full" } : {}),
137
- });
138
- await recordMintedToken({
139
- jti,
140
- ownerEmail: params.email,
141
- orgId: params.orgId ?? null,
142
- label: params.label,
151
+ requestOrigin: params.requestOrigin,
152
+ }, async (tx) => {
153
+ const jti = randomUUID();
154
+ const token = await signConnectToken({
155
+ ownerEmail: params.email,
156
+ orgId: params.orgId,
157
+ orgDomain,
158
+ appUrl: params.appUrl,
159
+ expiresIn: `${params.ttlDays}d`,
160
+ jti,
161
+ ...(params.catalogScope === "full" ? { catalogScope: "full" } : {}),
162
+ });
163
+ await recordMintedToken({
164
+ jti,
165
+ ownerEmail: params.email,
166
+ orgId: params.orgId ?? null,
167
+ label: params.label,
168
+ }, tx);
169
+ return { token, jti };
143
170
  });
144
- return { token, jti };
145
171
  }
146
172
  async function signConnectToken(params) {
147
- if (process.env.A2A_SECRET?.trim()) {
173
+ if (readDeployCredentialEnv("A2A_SECRET")?.trim()) {
148
174
  return signA2AToken(params.ownerEmail, params.orgDomain, undefined, {
149
175
  preferGlobalSecret: true,
150
176
  expiresIn: params.expiresIn,
@@ -183,35 +209,44 @@ async function signConnectToken(params) {
183
209
  * random `jti` is stored, so the standard revocation path
184
210
  * (`isJtiRevoked` in `verifyAuth`) applies to service tokens identically.
185
211
  *
186
- * Authorization is the CALLER'S responsibility: this function does not check
187
- * org membership/role. The `create-org-service-token` action gates on org
188
- * owner/admin before calling it.
212
+ * The `create-org-service-token` action gates on org owner/admin before
213
+ * calling this. Offboarding can remove that admin before the mint, so the
214
+ * creator's role is rechecked under the membership lock offboarding takes,
215
+ * and the token is recorded in the same transaction.
189
216
  */
190
217
  export async function mintOrgServiceToken(params) {
191
218
  const serviceName = normalizeServiceName(params.serviceName);
192
219
  const serviceEmail = serviceIdentityEmail(serviceName, params.orgId);
193
220
  const orgDomain = await resolveOrgDomain(params.orgId);
194
221
  const ttlDays = clampTtlDays(params.ttlDays ?? DEFAULT_TOKEN_TTL_DAYS);
195
- const jti = randomUUID();
196
- const token = await signConnectToken({
197
- ownerEmail: serviceEmail,
222
+ await prepareConnectIssuance();
223
+ return withMcpCredentialIssuance({
224
+ email: params.createdBy,
198
225
  orgId: params.orgId,
199
- orgDomain,
200
- appUrl: params.appUrl,
201
- expiresIn: `${ttlDays}d`,
202
- jti,
203
- includeOrgIdClaim: true,
204
- });
205
- const id = await recordMintedToken({
206
- jti,
207
- ownerEmail: serviceEmail,
208
- orgId: params.orgId,
209
- label: `Service token: ${serviceName}`,
210
- kind: "service",
211
- serviceName,
212
- createdBy: params.createdBy,
226
+ requestOrigin: params.appUrl,
227
+ roles: ["owner", "admin"],
228
+ }, async (tx) => {
229
+ const jti = randomUUID();
230
+ const token = await signConnectToken({
231
+ ownerEmail: serviceEmail,
232
+ orgId: params.orgId,
233
+ orgDomain,
234
+ appUrl: params.appUrl,
235
+ expiresIn: `${ttlDays}d`,
236
+ jti,
237
+ includeOrgIdClaim: true,
238
+ });
239
+ const id = await recordMintedToken({
240
+ jti,
241
+ ownerEmail: serviceEmail,
242
+ orgId: params.orgId,
243
+ label: `Service token: ${serviceName}`,
244
+ kind: "service",
245
+ serviceName,
246
+ createdBy: params.createdBy,
247
+ }, tx);
248
+ return { token, jti, id, serviceName, serviceEmail, ttlDays };
213
249
  });
214
- return { token, jti, id, serviceName, serviceEmail, ttlDays };
215
250
  }
216
251
  function mcpResultPayload(appUrl, options, auth) {
217
252
  const mcpUrl = mcpResourceUrl(appUrl);
@@ -1124,7 +1159,8 @@ export async function handleMcpConnect(event, subpath, options = {}) {
1124
1159
  const session = await getSession(event);
1125
1160
  if (!session?.email)
1126
1161
  return json({ error: "Unauthorized" }, 401);
1127
- if (!process.env.A2A_SECRET?.trim() && canUseDevOpenConnect(event)) {
1162
+ if (!readDeployCredentialEnv("A2A_SECRET")?.trim() &&
1163
+ canUseDevOpenConnect(event)) {
1128
1164
  return json(mcpResultPayload(appUrl, options, { ownerEmail: session.email }));
1129
1165
  }
1130
1166
  const body = ((await readBody(event).catch(() => ({}))) ?? {});
@@ -1143,11 +1179,14 @@ export async function handleMcpConnect(event, subpath, options = {}) {
1143
1179
  label,
1144
1180
  ttlDays,
1145
1181
  appUrl,
1182
+ requestOrigin: origin,
1146
1183
  ...(catalogScope ? { catalogScope } : {}),
1147
1184
  });
1148
1185
  return json(mcpResultPayload(appUrl, options, { token }));
1149
1186
  }
1150
- catch {
1187
+ catch (err) {
1188
+ if (err instanceof McpCredentialIssuanceError)
1189
+ return issuanceErrorResponse(err);
1151
1190
  return json({ error: "Failed to mint token." }, 500);
1152
1191
  }
1153
1192
  }
@@ -1207,7 +1246,20 @@ export async function handleMcpConnect(event, subpath, options = {}) {
1207
1246
  return json({ error: "Choose an organization you belong to." }, 403);
1208
1247
  }
1209
1248
  const selectedOrgId = requestedOrgId ?? defaultOrganizationId ?? null;
1210
- const result = await approveDeviceCode(userCode, session.email, selectedOrgId);
1249
+ let result;
1250
+ try {
1251
+ await prepareConnectIssuance();
1252
+ result = await withMcpCredentialIssuance({
1253
+ email: session.email,
1254
+ orgId: selectedOrgId,
1255
+ requestOrigin: origin,
1256
+ }, (tx) => approveDeviceCode(userCode, session.email, selectedOrgId, tx));
1257
+ }
1258
+ catch (err) {
1259
+ if (err instanceof McpCredentialIssuanceError)
1260
+ return issuanceErrorResponse(err);
1261
+ return json({ error: "Failed to mint token." }, 500);
1262
+ }
1211
1263
  if (result === "not_found") {
1212
1264
  return json({ error: "Unknown device code." }, 404);
1213
1265
  }
@@ -1242,35 +1294,43 @@ export async function handleMcpConnect(event, subpath, options = {}) {
1242
1294
  !row.ownerEmail) {
1243
1295
  return json({ status: "pending" });
1244
1296
  }
1245
- if (!process.env.A2A_SECRET?.trim() && canUseDevOpenConnect(event)) {
1246
- const consumed = await consumeDeviceCode(deviceCode, `dev-open-${randomUUID()}`);
1247
- if (!consumed) {
1248
- const fresh = await getDeviceCode(deviceCode);
1249
- if (fresh?.status === "consumed")
1250
- return json({ status: "consumed" });
1251
- return json({ status: "pending" });
1252
- }
1253
- return json({
1254
- status: "approved",
1255
- ...mcpResultPayload(appUrl, options, {
1256
- ownerEmail: row.ownerEmail,
1257
- catalogScope: row.catalogScope,
1258
- }),
1259
- });
1260
- }
1261
1297
  try {
1262
- const jti = randomUUID();
1263
- const claimed = await claimDeviceCodeForMint(deviceCode, jti);
1264
- if (!claimed) {
1265
- const fresh = await getDeviceCode(deviceCode);
1266
- if (fresh?.status === "consumed")
1267
- return json({ status: "consumed" });
1268
- return json({ status: "pending" });
1269
- }
1270
- let token;
1271
- try {
1272
- const orgDomain = await resolveOrgDomain(claimed.orgId ?? undefined);
1273
- token = await signConnectToken({
1298
+ const devOpen = !readDeployCredentialEnv("A2A_SECRET")?.trim() &&
1299
+ canUseDevOpenConnect(event);
1300
+ const orgDomain = await resolveOrgDomain(row.orgId ?? undefined);
1301
+ await prepareConnectIssuance();
1302
+ return await withMcpCredentialIssuance({
1303
+ email: row.ownerEmail,
1304
+ orgId: row.orgId,
1305
+ requestOrigin: origin,
1306
+ }, async (tx) => {
1307
+ const jti = randomUUID();
1308
+ const claimed = devOpen
1309
+ ? await consumeDeviceCode(deviceCode, jti, tx)
1310
+ : await claimDeviceCodeForMint(deviceCode, jti, tx);
1311
+ if (!claimed) {
1312
+ const fresh = await getDeviceCode(deviceCode, tx);
1313
+ if (fresh?.status === "consumed")
1314
+ return json({ status: "consumed" });
1315
+ if (fresh?.status === "expired" ||
1316
+ (fresh?.expiresAt != null && fresh.expiresAt < Date.now()))
1317
+ return json({ status: "expired" });
1318
+ return json({ status: "pending" });
1319
+ }
1320
+ if (claimed.ownerEmail !== row.ownerEmail ||
1321
+ claimed.orgId !== row.orgId) {
1322
+ throw new McpCredentialIssuanceError("not-member");
1323
+ }
1324
+ if (devOpen) {
1325
+ return json({
1326
+ status: "approved",
1327
+ ...mcpResultPayload(appUrl, options, {
1328
+ ownerEmail: row.ownerEmail,
1329
+ catalogScope: claimed.catalogScope,
1330
+ }),
1331
+ });
1332
+ }
1333
+ const token = await signConnectToken({
1274
1334
  ownerEmail: claimed.ownerEmail,
1275
1335
  orgId: claimed.orgId,
1276
1336
  orgDomain,
@@ -1286,21 +1346,19 @@ export async function handleMcpConnect(event, subpath, options = {}) {
1286
1346
  ownerEmail: claimed.ownerEmail,
1287
1347
  orgId: claimed.orgId,
1288
1348
  label: "Device connection",
1289
- });
1290
- if (!(await finishDeviceCodeMint(deviceCode, jti))) {
1291
- return json({ status: "pending" });
1349
+ }, tx);
1350
+ if (!(await finishDeviceCodeMint(deviceCode, jti, tx))) {
1351
+ throw new McpCredentialIssuanceError("unavailable");
1292
1352
  }
1293
- }
1294
- catch (err) {
1295
- await releaseDeviceCodeMint(deviceCode, jti);
1296
- throw err;
1297
- }
1298
- return json({
1299
- status: "approved",
1300
- ...mcpResultPayload(appUrl, options, { token }),
1353
+ return json({
1354
+ status: "approved",
1355
+ ...mcpResultPayload(appUrl, options, { token }),
1356
+ });
1301
1357
  });
1302
1358
  }
1303
- catch {
1359
+ catch (err) {
1360
+ if (err instanceof McpCredentialIssuanceError)
1361
+ return issuanceErrorResponse(err);
1304
1362
  return json({ status: "error", error: "Failed to mint token." }, 500);
1305
1363
  }
1306
1364
  }
@@ -16,6 +16,7 @@
16
16
  * like an absent authorization. `CREATE TABLE IF NOT EXISTS` only — strictly
17
17
  * additive, never DROP / ALTER (shared prod DB rule).
18
18
  */
19
+ import { type DbExec } from "../db/client.js";
19
20
  export declare const MCP_CONNECT_SCOPE = "mcp-connect";
20
21
  export declare const MCP_CONNECT_OAUTH_CLIENT_ID = "agent-native-connect";
21
22
  export declare const DEVICE_CODE_TTL_MS: number;
@@ -25,6 +26,7 @@ export declare const MAX_TOKEN_TTL_DAYS = 365;
25
26
  export declare const DEVICE_START_MAX = 20;
26
27
  export declare const DEVICE_START_WINDOW_MS = 60000;
27
28
  export declare function ensureTable(): Promise<void>;
29
+ export declare function ensureConnectTables(): Promise<void>;
28
30
  export interface MintedTokenRow {
29
31
  id: string;
30
32
  jti: string;
@@ -53,18 +55,22 @@ export declare function recordMintedToken(params: {
53
55
  kind?: "personal" | "service";
54
56
  serviceName?: string | null;
55
57
  createdBy?: string | null;
56
- }): Promise<string>;
58
+ }, exec?: DbExec): Promise<string>;
57
59
  /**
58
60
  * Returns true when the given `jti` corresponds to a token that has been
59
61
  * revoked. Fails OPEN on a store/DB error: a transient Neon WS drop must not
60
62
  * lock every connected agent out. Signature verification is unaffected — this
61
63
  * is only the post-verify revoke check (see `verifyAuth` in build-server.ts).
62
64
  */
65
+ /**
66
+ * Throws when the revocation state can't be read. Answering "not revoked"
67
+ * instead would let a transient read failure admit a revoked token.
68
+ */
63
69
  export declare function isJtiRevoked(jti: string): Promise<boolean>;
64
- export type ConnectTokenOrgLookup = {
70
+ export type StoredConnectTokenIdentity = Pick<MintedTokenRow, "kind" | "ownerEmail" | "orgId">;
71
+ export type ConnectTokenOrgLookup = ({
65
72
  status: "found";
66
- orgId: string | null;
67
- } | {
73
+ } & StoredConnectTokenIdentity) | {
68
74
  status: "missing";
69
75
  } | {
70
76
  status: "unavailable";
@@ -105,11 +111,11 @@ export interface DeviceCodeRow {
105
111
  consumedAt: number | null;
106
112
  }
107
113
  export declare function createDeviceCode(catalogScope?: "full" | null): Promise<DeviceCodeRow>;
108
- export declare function getDeviceCode(deviceCode: string): Promise<DeviceCodeRow | null>;
109
- export declare function getDeviceCodeByUserCode(userCode: string): Promise<DeviceCodeRow | null>;
110
- export declare function approveDeviceCode(userCode: string, ownerEmail: string, orgId: string | null): Promise<DeviceCodeRow | "not_found" | "expired" | "already">;
111
- export declare function consumeDeviceCode(deviceCode: string, tokenJti: string): Promise<DeviceCodeRow | null>;
112
- export declare function claimDeviceCodeForMint(deviceCode: string, tokenJti: string): Promise<DeviceCodeRow | null>;
113
- export declare function finishDeviceCodeMint(deviceCode: string, tokenJti: string): Promise<boolean>;
114
- export declare function releaseDeviceCodeMint(deviceCode: string, tokenJti: string): Promise<void>;
114
+ export declare function getDeviceCode(deviceCode: string, exec?: DbExec): Promise<DeviceCodeRow | null>;
115
+ export declare function getDeviceCodeByUserCode(userCode: string, exec?: DbExec): Promise<DeviceCodeRow | null>;
116
+ export declare function approveDeviceCode(userCode: string, ownerEmail: string, orgId: string | null, exec?: DbExec): Promise<DeviceCodeRow | "not_found" | "expired" | "already">;
117
+ export declare function consumeDeviceCode(deviceCode: string, tokenJti: string, exec?: DbExec): Promise<DeviceCodeRow | null>;
118
+ export declare function claimDeviceCodeForMint(deviceCode: string, tokenJti: string, exec?: DbExec): Promise<DeviceCodeRow | null>;
119
+ export declare function finishDeviceCodeMint(deviceCode: string, tokenJti: string, exec?: DbExec): Promise<boolean>;
120
+ export declare function releaseDeviceCodeMint(deviceCode: string, tokenJti: string, exec?: DbExec): Promise<void>;
115
121
  export declare function expireDeviceCode(deviceCode: string): Promise<void>;