@agent-native/core 0.205.0 → 0.206.0-nightly-20261009011246

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 (115) hide show
  1. package/dist/a2a/client.d.ts +2 -2
  2. package/dist/agent/engine/anthropic-engine.d.ts +1 -1
  3. package/dist/agent/engine/builder-engine.d.ts +1 -1
  4. package/dist/agent/engine/builder-engine.js +23 -8
  5. package/dist/agent/engine/error-telemetry.d.ts +5 -0
  6. package/dist/agent/engine/error-telemetry.js +41 -0
  7. package/dist/agent/engine/registry.d.ts +1 -6
  8. package/dist/agent/engine/registry.js +2 -31
  9. package/dist/agent/model-config.d.ts +26 -14
  10. package/dist/agent/model-config.js +131 -8
  11. package/dist/agent/model-version.d.ts +19 -0
  12. package/dist/agent/model-version.js +153 -0
  13. package/dist/agent/production-agent.d.ts +1 -0
  14. package/dist/agent/production-agent.js +24 -6
  15. package/dist/agent/provider-model-selection.js +6 -4
  16. package/dist/agent/run-manager.js +5 -11
  17. package/dist/agent/thread-data-builder.js +14 -6
  18. package/dist/authorization/check-action.js +8 -4
  19. package/dist/chat-threads/store.js +22 -9
  20. package/dist/client/analytics.d.ts +1 -0
  21. package/dist/client/analytics.js +10 -2
  22. package/dist/client/chat/runtime.js +18 -0
  23. package/dist/client/chat-model-groups.d.ts +3 -0
  24. package/dist/client/error-format.js +10 -7
  25. package/dist/client/onboarding/use-onboarding.d.ts +12 -0
  26. package/dist/client/onboarding/use-onboarding.js +491 -4
  27. package/dist/client/session-replay-privacy.d.ts +8 -0
  28. package/dist/client/session-replay-privacy.js +8 -0
  29. package/dist/client/session-replay.d.ts +1 -0
  30. package/dist/client/session-replay.js +18 -9
  31. package/dist/client/sse-event-processor.js +10 -6
  32. package/dist/client/use-chat-models.d.ts +5 -0
  33. package/dist/client/use-chat-models.js +51 -5
  34. package/dist/client/use-session.d.ts +8 -1
  35. package/dist/client/use-session.js +20 -1
  36. package/dist/collab/struct-routes.d.ts +1 -1
  37. package/dist/deploy/build.js +353 -3
  38. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  39. package/dist/jobs/backfill-run-history-ownership.d.ts +12 -0
  40. package/dist/jobs/backfill-run-history-ownership.js +87 -0
  41. package/dist/jobs/background-automation-runner.js +3 -9
  42. package/dist/jobs/remote-execution.js +3 -9
  43. package/dist/jobs/run-history-ownership.d.ts +5 -0
  44. package/dist/jobs/run-history-ownership.js +16 -0
  45. package/dist/jobs/run-now.js +2 -3
  46. package/dist/localization/agent-resources-messages.d.ts +3 -0
  47. package/dist/localization/agent-resources-messages.js +33 -0
  48. package/dist/mcp/actions/create-org-service-token.js +5 -7
  49. package/dist/mcp/build-server.d.ts +23 -18
  50. package/dist/mcp/build-server.js +220 -212
  51. package/dist/mcp/connect-route.d.ts +11 -2
  52. package/dist/mcp/connect-route.js +34 -24
  53. package/dist/mcp/connect-store.d.ts +9 -13
  54. package/dist/mcp/connect-store.js +11 -24
  55. package/dist/mcp/credential-membership.js +17 -6
  56. package/dist/mcp/oauth-route.d.ts +11 -1
  57. package/dist/mcp/oauth-route.js +28 -6
  58. package/dist/mcp/oauth-token.d.ts +12 -2
  59. package/dist/mcp/oauth-token.js +15 -4
  60. package/dist/mcp/server.js +9 -5
  61. package/dist/notifications/routes.d.ts +2 -2
  62. package/dist/observability/posthog-ai.d.ts +1 -0
  63. package/dist/observability/posthog-ai.js +20 -8
  64. package/dist/observability/routes.d.ts +6 -6
  65. package/dist/observability/traces.js +61 -39
  66. package/dist/org/app-roles.d.ts +8 -1
  67. package/dist/org/app-roles.js +29 -9
  68. package/dist/org/context.js +24 -8
  69. package/dist/org/index.d.ts +2 -2
  70. package/dist/org/workspace-app-identity.d.ts +1 -0
  71. package/dist/org/workspace-app-identity.js +25 -0
  72. package/dist/progress/routes.d.ts +1 -1
  73. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  74. package/dist/provider-api/actions/provider-api.d.ts +3 -3
  75. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  76. package/dist/scripts/agent-engines/list-agent-engines.js +1 -0
  77. package/dist/server/action-routes.js +8 -1
  78. package/dist/server/agent-chat-plugin.js +1 -0
  79. package/dist/server/agent-engine-default-model-route.d.ts +1 -1
  80. package/dist/server/auth.js +33 -17
  81. package/dist/server/bearer-credential-refusal.d.ts +23 -0
  82. package/dist/server/bearer-credential-refusal.js +67 -0
  83. package/dist/server/better-auth-instance.js +13 -6
  84. package/dist/server/builder-browser.d.ts +2 -0
  85. package/dist/server/builder-browser.js +16 -3
  86. package/dist/server/capture-error.d.ts +1 -0
  87. package/dist/server/capture-error.js +28 -4
  88. package/dist/server/complete-text.js +16 -3
  89. package/dist/server/core-routes-plugin.js +2 -0
  90. package/dist/server/dev-action-bridge.js +7 -1
  91. package/dist/server/google-oauth.js +6 -3
  92. package/dist/server/index.d.ts +1 -1
  93. package/dist/server/index.js +1 -1
  94. package/dist/server/realtime-token.d.ts +1 -1
  95. package/dist/server/recap-image-route.js +4 -0
  96. package/dist/server/request-context.d.ts +16 -0
  97. package/dist/server/request-context.js +17 -0
  98. package/dist/shared/agent-chat-context.d.ts +1 -0
  99. package/dist/shared/agent-chat-context.js +26 -0
  100. package/dist/shared/index.d.ts +1 -1
  101. package/dist/shared/index.js +1 -1
  102. package/dist/shared/reasoning-effort.js +10 -1
  103. package/dist/shared/ssr-session-bootstrap.d.ts +7 -0
  104. package/dist/shared/ssr-session-bootstrap.js +21 -0
  105. package/dist/sharing/access.js +86 -5
  106. package/dist/templates/builder-code-starter/server/routes/index.get.ts +2 -0
  107. package/dist/triggers/routes.js +2 -1
  108. package/docs/content/automation-connectors.mdx +5 -3
  109. package/docs/content/automations.mdx +78 -49
  110. package/docs/content/observability.mdx +16 -5
  111. package/docs/content/organizations-teams-permissions.mdx +44 -2
  112. package/docs/content/recurring-jobs.mdx +4 -8
  113. package/docs/content/template-forms.mdx +6 -0
  114. package/package.json +5 -4
  115. package/src/templates/builder-code-starter/server/routes/index.get.ts +2 -0
@@ -6,6 +6,7 @@ import { isActionContractError, isActionExposedToExternalAgents, isActionHiddenF
6
6
  import { describeToolResultImages, extractAgentImagesFromActionResult, } from "../agent/tool-result-images.js";
7
7
  import { getAppConfig } from "../app-config/store.js";
8
8
  import { isMcpActionResult } from "../mcp-client/app-result.js";
9
+ import { implicitServiceOrgRole } from "../org/service-identity.js";
9
10
  import { assertServicePrincipalMayCall, assertServicePrincipalMayRun, recordServicePrincipalDenial, ServicePrincipalRefusedError, } from "../org/service-principal-guard.js";
10
11
  import { isActionGranted } from "../org/service-principal-policy.js";
11
12
  import { writeActionChangeMarker } from "../server/action-change-marker-write.js";
@@ -21,7 +22,7 @@ import { consumeMcpApprovalGrant, createMcpApprovalGrant, } from "./approval-sto
21
22
  import { getBuiltinCrossAppTools } from "./builtin-tools.js";
22
23
  import { MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, } from "./connect-store.js";
23
24
  import { MCP_APP_REQUEST_ORIGIN_CSP_SOURCE } from "./embed-app.js";
24
- import { hasMcpOAuthScope, parseMcpOAuthOrgIdClaim, verifyMcpOAuthAccessToken, } from "./oauth-token.js";
25
+ import { MCP_OAUTH_TOKEN_TYPE, hasMcpOAuthScope, parseMcpOAuthOrgIdClaim, verifyMcpOAuthAccessToken, } from "./oauth-token.js";
25
26
  import { mcpToolInputSchema } from "./tool-input-schema.js";
26
27
  const PRESERVE_MCP_OBJECT_RESULT = Symbol("preserveMcpObjectResult");
27
28
  // A GET action is a query, so its result is the payload the model asked for
@@ -32,6 +33,18 @@ function returnsQueryPayload(entry) {
32
33
  return (entry.readOnly === true ||
33
34
  (entry.http !== false && entry.http?.method === "GET"));
34
35
  }
36
+ function verifiedServiceIdentityForRequest(identity, requestOrgId) {
37
+ const userEmail = identity?.userEmail?.trim();
38
+ const orgId = identity?.orgId;
39
+ if (identity?.identityAssurance !== "service" ||
40
+ !userEmail ||
41
+ typeof orgId !== "string" ||
42
+ orgId !== requestOrgId ||
43
+ !implicitServiceOrgRole({ email: userEmail, orgId, requestOrgId })) {
44
+ return undefined;
45
+ }
46
+ return { userEmail, orgId };
47
+ }
35
48
  function hasVerifiedMcpUserIdentity(identity) {
36
49
  return (identity?.identityAssurance === "user" &&
37
50
  Boolean(identity.userEmail?.trim()));
@@ -1628,9 +1641,11 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
1628
1641
  const scopeVisibleActions = Object.fromEntries(Object.entries(actions).filter(([, entry]) => isActionVisibleForOAuthScope(entry, effectiveIdentity?.oauthScopes)));
1629
1642
  const orgIdPromise = resolveMcpIdentityOrgId(effectiveIdentity);
1630
1643
  const orgId = await orgIdPromise;
1644
+ const verifiedServiceIdentity = verifiedServiceIdentityForRequest(effectiveIdentity, orgId);
1631
1645
  const visibleActions = await runWithRequestContext({
1632
1646
  userEmail: effectiveIdentity?.userEmail,
1633
1647
  orgId,
1648
+ ...(verifiedServiceIdentity ? { verifiedServiceIdentity } : {}),
1634
1649
  ...(effectiveIdentity?.orgId === null
1635
1650
  ? { orgScope: "personal" }
1636
1651
  : {}),
@@ -1754,6 +1769,7 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
1754
1769
  return runWithRequestContext({
1755
1770
  userEmail: effectiveIdentity?.userEmail,
1756
1771
  orgId,
1772
+ ...(verifiedServiceIdentity ? { verifiedServiceIdentity } : {}),
1757
1773
  ...(effectiveIdentity?.orgId === null
1758
1774
  ? { orgScope: "personal" }
1759
1775
  : {}),
@@ -2584,9 +2600,6 @@ async function verifyA2AJwtForMcp(token, resourceUrl) {
2584
2600
  if (globalSecret) {
2585
2601
  const payload = await verifyWithSecret(globalSecret);
2586
2602
  if (payload) {
2587
- const tokenScope = typeof payload.scope === "string" ? payload.scope : undefined;
2588
- const firstPartyMcp = payload.agent_native_first_party_mcp === true;
2589
- const locallyIssuedConnectToken = tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp;
2590
2603
  if (orgDomain) {
2591
2604
  let organization;
2592
2605
  try {
@@ -2597,19 +2610,18 @@ async function verifyA2AJwtForMcp(token, resourceUrl) {
2597
2610
  catch (error) {
2598
2611
  throw new McpIdentityVerificationUnavailableError(error);
2599
2612
  }
2600
- // Locally issued connect identities are admitted below only after
2601
- // their active JTI row confirms the stored owner and organization.
2602
- if (organization?.secret.trim() === globalSecret &&
2603
- !locallyIssuedConnectToken) {
2613
+ if (organization?.secret.trim() === globalSecret) {
2604
2614
  return organizationPrincipalClaims(payload, organization);
2605
2615
  }
2606
2616
  }
2617
+ const firstPartyMcp = payload.agent_native_first_party_mcp === true;
2607
2618
  const hasOrganizationClaim = Object.prototype.hasOwnProperty.call(payload, "org_id") &&
2608
2619
  payload.org_id !== null;
2609
2620
  const hasOrganizationDomainClaim = Object.prototype.hasOwnProperty.call(payload, "org_domain") &&
2610
2621
  payload.org_domain !== null;
2611
- const unclaimedFirstPartyToken = firstPartyMcp && !hasOrganizationClaim && !hasOrganizationDomainClaim;
2612
- if (locallyIssuedConnectToken || unclaimedFirstPartyToken) {
2622
+ if (firstPartyMcp &&
2623
+ !hasOrganizationClaim &&
2624
+ !hasOrganizationDomainClaim) {
2613
2625
  return payload;
2614
2626
  }
2615
2627
  let verifiedOrganization;
@@ -2660,22 +2672,6 @@ function mcpAudienceList(resource) {
2660
2672
  }
2661
2673
  return out;
2662
2674
  }
2663
- /**
2664
- * Null while the connect token is active. A revoked or jti-less token is
2665
- * refused, and unreadable revocation state answers a retryable 503.
2666
- */
2667
- async function refuseInactiveConnectToken(jti) {
2668
- if (!jti)
2669
- return { authed: false };
2670
- const { isJtiRevoked } = await import("./connect-store.js");
2671
- try {
2672
- return (await isJtiRevoked(jti)) ? { authed: false } : null;
2673
- }
2674
- catch (error) {
2675
- console.error("[mcp] Connect-token revocation check failed; refusing the token:", error);
2676
- return { authed: false, unavailable: true };
2677
- }
2678
- }
2679
2675
  /**
2680
2676
  * Records a connect token's use once the request is admitted, so a refused
2681
2677
  * call (revoked, removed member, membership check unavailable) never moves
@@ -2693,48 +2689,131 @@ async function markConnectTokenUsed(jti) {
2693
2689
  }
2694
2690
  }
2695
2691
  /**
2696
- * `connectJti` is set only for connect tokens. Any other token without an
2697
- * `org_id` claim is `unclaimed`, and its org is resolved later from
2698
- * `org_domain` or the caller's email.
2692
+ * Connect tokens minted before Connect used the MCP OAuth format: an
2693
+ * A2A-shaped JWT with `scope: "mcp-connect"` and no audience, signed with this
2694
+ * deployment's A2A_SECRET. They are this app's own credentials, not cross-app
2695
+ * assertions, so organization secrets and organization-principal rules never
2696
+ * apply to them: their identity is their stored row.
2699
2697
  */
2700
- async function resolveConnectTokenOrgId(connectJti, claimedOrgId) {
2701
- let stored;
2702
- if (connectJti) {
2703
- const { lookupConnectTokenOrg } = await import("./connect-store.js");
2704
- stored = await lookupConnectTokenOrg(connectJti);
2705
- if (stored.status === "unavailable")
2706
- return stored;
2698
+ async function verifyLegacyConnectToken(token, claims) {
2699
+ const secret = readDeployCredentialEnv("A2A_SECRET")?.trim();
2700
+ if (!secret || claims.aud !== undefined)
2701
+ return "unverified";
2702
+ const jose = await import("jose");
2703
+ let payload;
2704
+ try {
2705
+ payload = (await jose.jwtVerify(token, new TextEncoder().encode(secret)))
2706
+ .payload;
2707
2707
  }
2708
- if (claimedOrgId !== undefined) {
2709
- return {
2710
- status: "claimed",
2711
- orgId: claimedOrgId,
2712
- ...(stored?.status === "found" ? { storedConnectToken: stored } : {}),
2713
- };
2708
+ catch {
2709
+ // coercion-ok: a bad signature or an expired token is unverified, which verifyAuth refuses unless it is a configured static token.
2710
+ return "unverified";
2711
+ }
2712
+ const orgIdClaim = parseMcpOAuthOrgIdClaim(payload);
2713
+ if (typeof payload.sub !== "string" || !payload.sub || !orgIdClaim) {
2714
+ return "unverified";
2714
2715
  }
2715
- return stored ?? { status: "unclaimed" };
2716
+ return {
2717
+ userEmail: payload.sub,
2718
+ orgId: orgIdClaim.orgId,
2719
+ orgDomain: typeof payload.org_domain === "string" ? payload.org_domain : undefined,
2720
+ connect: { jti: typeof payload.jti === "string" ? payload.jti : undefined },
2721
+ ...(payload.catalog_scope === "full"
2722
+ ? { catalogScope: "full" }
2723
+ : {}),
2724
+ issuedAt: typeof payload.iat === "number" ? payload.iat : undefined,
2725
+ };
2716
2726
  }
2717
- function orgIdFromConnectTokenResolution(resolution) {
2718
- if (resolution.status === "claimed" || resolution.status === "found") {
2719
- return resolution.orgId;
2727
+ /**
2728
+ * Classifies a bearer token by the credential it declares itself to be, then
2729
+ * verifies it as that. A token claiming to be one this app issued never
2730
+ * reaches the A2A checks, even when it fails to verify: that is
2731
+ * `unverified`. Null means it makes no such claim.
2732
+ */
2733
+ async function verifyIssuedMcpCredential(token, resourceUrl) {
2734
+ const jose = await import("jose");
2735
+ let claims;
2736
+ try {
2737
+ claims = jose.decodeJwt(token);
2738
+ }
2739
+ catch {
2740
+ // coercion-ok: a token that is not a JWT is not an issued credential; the static-token check still runs.
2741
+ return null;
2742
+ }
2743
+ if (claims.scope === MCP_CONNECT_SCOPE &&
2744
+ claims.agent_native_first_party_mcp !== true) {
2745
+ return verifyLegacyConnectToken(token, claims);
2720
2746
  }
2721
- // A first-party cross-app token with no row here was not issued for any org
2722
- // this app knows. Its `org_domain` claim must not grant that domain's org.
2723
- if (resolution.status === "missing")
2747
+ if (claims.typ !== MCP_OAUTH_TOKEN_TYPE)
2724
2748
  return null;
2725
- return undefined;
2749
+ const oauth = await verifyMcpOAuthAccessToken(token, resourceUrl);
2750
+ if (!oauth)
2751
+ return "unverified";
2752
+ return {
2753
+ userEmail: oauth.userEmail,
2754
+ orgId: oauth.orgId,
2755
+ orgDomain: oauth.orgDomain,
2756
+ ...(oauth.clientId === MCP_CONNECT_OAUTH_CLIENT_ID
2757
+ ? { connect: { jti: oauth.jti } }
2758
+ : {}),
2759
+ oauthScopes: oauth.scopes,
2760
+ oauthClientId: oauth.clientId,
2761
+ ...(oauth.catalogScope ? { catalogScope: oauth.catalogScope } : {}),
2762
+ issuedAt: oauth.issuedAt,
2763
+ };
2726
2764
  }
2727
- function matchesStoredConnectTokenIdentity(resolution, input) {
2728
- const stored = resolution.status === "found"
2729
- ? resolution
2730
- : resolution.status === "claimed"
2731
- ? resolution.storedConnectToken
2732
- : undefined;
2733
- if (!stored || !input.ownerEmail?.trim())
2734
- return false;
2735
- return (stored.ownerEmail.trim().toLowerCase() ===
2736
- input.ownerEmail.trim().toLowerCase() &&
2737
- (input.orgId === undefined || stored.orgId === input.orgId));
2765
+ /**
2766
+ * The one admission path for MCP credentials this app issued. A connect token
2767
+ * is admitted only while its stored row exists, is unrevoked, and names the
2768
+ * token's subject and org claim; the row also supplies the org of a token
2769
+ * without a claim and marks service identities. Every credential then passes
2770
+ * `admitIssuedCredential`.
2771
+ */
2772
+ async function admitIssuedMcpCredential(credential, requestOrigin) {
2773
+ let stored;
2774
+ if (credential.connect) {
2775
+ if (!credential.connect.jti)
2776
+ return { authed: false, refusal: "invalid" };
2777
+ const { lookupConnectTokenOrg } = await import("./connect-store.js");
2778
+ const lookup = await lookupConnectTokenOrg(credential.connect.jti);
2779
+ if (lookup.status === "unavailable") {
2780
+ return { authed: false, unavailable: true };
2781
+ }
2782
+ if (lookup.status === "revoked") {
2783
+ return { authed: false, refusal: "revoked" };
2784
+ }
2785
+ if (lookup.status === "missing") {
2786
+ return { authed: false, refusal: "unknown-connect-token" };
2787
+ }
2788
+ if (lookup.ownerEmail.trim().toLowerCase() !==
2789
+ credential.userEmail.trim().toLowerCase() ||
2790
+ (credential.orgId !== undefined && lookup.orgId !== credential.orgId)) {
2791
+ return { authed: false, refusal: "identity-mismatch" };
2792
+ }
2793
+ stored = lookup;
2794
+ }
2795
+ const orgId = credential.orgId !== undefined ? credential.orgId : stored?.orgId;
2796
+ const admitted = await admitIssuedCredential({
2797
+ authed: true,
2798
+ identity: {
2799
+ userEmail: credential.userEmail,
2800
+ identityAssurance: stored?.kind === "service" ? "service" : "user",
2801
+ ...(orgId !== undefined ? { orgId } : {}),
2802
+ orgDomain: credential.orgDomain,
2803
+ ...(credential.oauthScopes
2804
+ ? { oauthScopes: credential.oauthScopes }
2805
+ : {}),
2806
+ ...(credential.oauthClientId
2807
+ ? { oauthClientId: credential.oauthClientId }
2808
+ : {}),
2809
+ },
2810
+ fullSurface: true,
2811
+ fullCatalog: credential.catalogScope === "full",
2812
+ }, requestOrigin, stored, credential.issuedAt);
2813
+ if (admitted.authed && credential.connect) {
2814
+ await markConnectTokenUsed(credential.connect.jti);
2815
+ }
2816
+ return admitted;
2738
2817
  }
2739
2818
  /**
2740
2819
  * Credentials this app issues (MCP OAuth access tokens and connect tokens)
@@ -2746,14 +2825,9 @@ function matchesStoredConnectTokenIdentity(resolution, input) {
2746
2825
  *
2747
2826
  * They also carry the subject's address, which an email change retires; a
2748
2827
  * credential signed for it before the change is refused, Personal or not.
2749
- *
2750
- * Shared-secret A2A org claims must resolve against local metadata before they
2751
- * become request scope. A sibling app's user roster is not this app's roster;
2752
- * locally issued connect credentials instead use their stored JTI owner and
2753
- * organization.
2754
2828
  */
2755
- async function admitIssuedCredential(result, requestOrigin, orgResolution, issuedAt) {
2756
- const admitted = await checkIssuedCredential(result, requestOrigin, orgResolution, issuedAt);
2829
+ async function admitIssuedCredential(result, requestOrigin, storedConnectToken, issuedAt) {
2830
+ const admitted = await checkIssuedCredential(result, requestOrigin, storedConnectToken, issuedAt);
2757
2831
  return admitted.authed ? admitServicePrincipal(admitted) : admitted;
2758
2832
  }
2759
2833
  /**
@@ -2785,10 +2859,10 @@ async function admitServicePrincipal(result) {
2785
2859
  }
2786
2860
  return error.statusCode === 503
2787
2861
  ? { authed: false, unavailable: true }
2788
- : { authed: false };
2862
+ : { authed: false, refusal: "service-principal-inactive" };
2789
2863
  }
2790
2864
  }
2791
- async function checkIssuedCredential(result, requestOrigin, orgResolution, issuedAt) {
2865
+ async function checkIssuedCredential(result, requestOrigin, storedConnectToken, issuedAt) {
2792
2866
  const { checkCredentialEmailRetirement, checkCredentialOrgMembership } = await import("./credential-membership.js");
2793
2867
  if (result.identity.userEmail) {
2794
2868
  const retirement = await checkCredentialEmailRetirement({
@@ -2798,7 +2872,7 @@ async function checkIssuedCredential(result, requestOrigin, orgResolution, issue
2798
2872
  if (retirement !== "current")
2799
2873
  return retirement === "unavailable"
2800
2874
  ? { authed: false, unavailable: true }
2801
- : { authed: false };
2875
+ : { authed: false, refusal: "email-retired" };
2802
2876
  }
2803
2877
  const orgId = result.identity.orgId;
2804
2878
  if (typeof orgId !== "string" || !orgId)
@@ -2807,90 +2881,54 @@ async function checkIssuedCredential(result, requestOrigin, orgResolution, issue
2807
2881
  orgId,
2808
2882
  email: result.identity.userEmail,
2809
2883
  requestOrigin,
2810
- ...(orgResolution.status === "found"
2811
- ? { storedConnectToken: orgResolution }
2812
- : orgResolution.status === "claimed" && orgResolution.storedConnectToken
2813
- ? { storedConnectToken: orgResolution.storedConnectToken }
2814
- : {}),
2884
+ ...(storedConnectToken ? { storedConnectToken } : {}),
2815
2885
  });
2816
2886
  if (membership === "member")
2817
2887
  return result;
2818
2888
  return membership === "unavailable"
2819
2889
  ? { authed: false, unavailable: true }
2820
- : { authed: false };
2890
+ : { authed: false, refusal: "not-member" };
2821
2891
  }
2822
2892
  /**
2823
2893
  * Verify the inbound auth header. Returns:
2824
- * - { authed: true, identity } when verified. A deployment-secret JWT may
2894
+ * - { authed: true, identity } when verified. A credential this app issued
2895
+ * (an MCP OAuth access token or a connect token) supplies its subject and
2896
+ * org, checked against live membership. A deployment-secret A2A JWT may
2825
2897
  * supply its asserted user (`sub`), while an org-secret JWT supplies only
2826
- * verified organization scope. Legacy connect tokens recover stored org
2827
- * scope. Static-token auth gets identity from `AGENT_NATIVE_OWNER_EMAIL`
2828
- * or `X-Agent-Native-Owner-Email` (the `agent-native mcp install` flow).
2829
- * `identity` is undefined only for true dev-open with no owner hint.
2830
- * - { authed: false } on rejection.
2898
+ * verified organization scope. Static-token auth gets identity from
2899
+ * `AGENT_NATIVE_OWNER_EMAIL` or `X-Agent-Native-Owner-Email` (the
2900
+ * `agent-native mcp install` flow). `identity` is undefined only for true
2901
+ * dev-open with no owner hint.
2902
+ * - { authed: false, refusal? } on rejection; `refusal` is set whenever a
2903
+ * presented token was refused.
2831
2904
  *
2832
- * A deployment-secret A2A JWT can supply its trusted `sub` (caller email).
2833
- * An org-secret JWT never supplies a user identity; both paths bind
2834
- * `org_domain` to local organization metadata. Legacy connect tokens can use
2835
- * a stored-org fallback. The MCP endpoint wraps tool runs in
2836
- * `runWithRequestContext({ userEmail, orgId })`. Without that wrap, the
2837
- * MCP endpoint loses tenant identity and downstream `accessFilter` /
2838
- * `resolveCredential` calls fall back to platform-wide defaults.
2905
+ * Credentials this app issued are recognized and verified before any A2A
2906
+ * rule runs, so a change to cross-app trust cannot reclassify them. Both A2A
2907
+ * paths bind `org_domain` to local organization metadata. The MCP endpoint
2908
+ * wraps tool runs in `runWithRequestContext({ userEmail, orgId })`. Without
2909
+ * that wrap, the MCP endpoint loses tenant identity and downstream
2910
+ * `accessFilter` / `resolveCredential` calls fall back to platform-wide
2911
+ * defaults.
2839
2912
  *
2840
2913
  * `ownerEmailHeader` is the forwarded `X-Agent-Native-Owner-Email` value; it
2841
2914
  * is consulted ONLY on the static-token / dev-open path (never to influence
2842
2915
  * verified JWT identity), so the install flow runs tools as the configured
2843
2916
  * owner instead of an unscoped anonymous caller.
2844
- *
2845
- * A credential this app issued that names an organization is admitted only
2846
- * while its subject is still a member, and none is admitted for an address an
2847
- * email change retired after signing (see `admitIssuedCredential`).
2848
2917
  */
2849
2918
  export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2850
2919
  const accessTokens = getAccessTokens();
2851
2920
  const hasA2ASecret = !!readDeployCredentialEnv("A2A_SECRET")?.trim();
2852
2921
  const token = getBearerToken(authHeader);
2853
2922
  if (token) {
2854
- const oauthIdentity = await verifyMcpOAuthAccessToken(token, options.resourceUrl);
2855
- if (oauthIdentity) {
2856
- if (oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID) {
2857
- const refused = await refuseInactiveConnectToken(oauthIdentity.jti);
2858
- if (refused)
2859
- return refused;
2860
- }
2861
- const orgResolution = await resolveConnectTokenOrgId(oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID
2862
- ? oauthIdentity.jti
2863
- : undefined, oauthIdentity.orgId);
2864
- if (orgResolution.status === "unavailable") {
2865
- return { authed: false, unavailable: true };
2866
- }
2867
- if (oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID &&
2868
- !matchesStoredConnectTokenIdentity(orgResolution, {
2869
- ownerEmail: oauthIdentity.userEmail,
2870
- orgId: oauthIdentity.orgId,
2871
- })) {
2872
- return { authed: false };
2873
- }
2874
- const orgId = orgIdFromConnectTokenResolution(orgResolution);
2875
- const admitted = await admitIssuedCredential({
2876
- authed: true,
2877
- identity: {
2878
- userEmail: oauthIdentity.userEmail,
2879
- identityAssurance: "user",
2880
- ...(orgId !== undefined ? { orgId } : {}),
2881
- orgDomain: oauthIdentity.orgDomain,
2882
- oauthScopes: oauthIdentity.scopes,
2883
- oauthClientId: oauthIdentity.clientId,
2884
- },
2885
- fullSurface: true,
2886
- fullCatalog: oauthIdentity.catalogScope === "full",
2887
- }, options.requestOrigin, orgResolution, oauthIdentity.issuedAt);
2888
- if (admitted.authed &&
2889
- oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID) {
2890
- await markConnectTokenUsed(oauthIdentity.jti);
2891
- }
2892
- return admitted;
2923
+ const issued = await verifyIssuedMcpCredential(token, options.resourceUrl);
2924
+ if (issued === "unverified") {
2925
+ return ((await matchStaticAccessToken(token, accessTokens, ownerEmailHeader)) ?? {
2926
+ authed: false,
2927
+ refusal: "invalid",
2928
+ });
2893
2929
  }
2930
+ if (issued)
2931
+ return admitIssuedMcpCredential(issued, options.requestOrigin);
2894
2932
  }
2895
2933
  if (accessTokens.length === 0 && !hasA2ASecret && !token) {
2896
2934
  if (options.allowDevOpen === false) {
@@ -2915,58 +2953,33 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2915
2953
  throw error;
2916
2954
  }
2917
2955
  if (payload) {
2956
+ // Connect tokens this app minted never reach here, so a connect-scoped
2957
+ // token is a sibling app's per-call first-party MCP token.
2918
2958
  const tokenScope = typeof payload.scope === "string" ? payload.scope : undefined;
2919
2959
  if (tokenScope && tokenScope !== MCP_CONNECT_SCOPE) {
2920
- return { authed: false };
2921
- }
2922
- // Connect-minted tokens (scope === "mcp-connect") carry a random `jti`
2923
- // and are individually revocable. Only these tokens hit the revoke
2924
- // store — ordinary A2A delegation JWTs skip the DB lookup entirely so
2925
- // the hot path is unchanged.
2926
- if (tokenScope === MCP_CONNECT_SCOPE) {
2927
- const refused = await refuseInactiveConnectToken(payload.jti);
2928
- if (refused)
2929
- return refused;
2960
+ return { authed: false, refusal: "invalid" };
2930
2961
  }
2931
2962
  const orgIdClaim = parseMcpOAuthOrgIdClaim(payload);
2932
2963
  if (!orgIdClaim)
2933
- return { authed: false };
2964
+ return { authed: false, refusal: "invalid" };
2934
2965
  const firstPartyMcp = payload.agent_native_first_party_mcp === true;
2935
- const orgResolution = await resolveConnectTokenOrgId(tokenScope === MCP_CONNECT_SCOPE &&
2936
- (!firstPartyMcp || orgIdClaim.orgId === undefined)
2937
- ? payload.jti
2938
- : undefined, orgIdClaim.orgId);
2939
- if (orgResolution.status === "unavailable") {
2940
- return { authed: false, unavailable: true };
2941
- }
2942
- if (tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp) {
2943
- if (!matchesStoredConnectTokenIdentity(orgResolution, {
2944
- ownerEmail: typeof payload.sub === "string" ? payload.sub : undefined,
2945
- orgId: orgIdClaim.orgId,
2946
- })) {
2947
- return { authed: false };
2948
- }
2949
- }
2950
- const orgId = orgIdFromConnectTokenResolution(orgResolution);
2951
- const storedConnectToken = orgResolution.status === "found"
2952
- ? orgResolution
2953
- : orgResolution.status === "claimed"
2954
- ? orgResolution.storedConnectToken
2966
+ // A first-party token without an org claim was not issued for any org
2967
+ // this app knows. Its `org_domain` or caller email must not grant one.
2968
+ const orgId = orgIdClaim.orgId !== undefined
2969
+ ? orgIdClaim.orgId
2970
+ : tokenScope === MCP_CONNECT_SCOPE
2971
+ ? null
2955
2972
  : undefined;
2956
- const verified = {
2973
+ return admitServicePrincipal({
2957
2974
  authed: true,
2958
2975
  identity: {
2959
2976
  userEmail: typeof payload.sub === "string" ? payload.sub : undefined,
2960
- ...(tokenScope === MCP_CONNECT_SCOPE &&
2961
- !firstPartyMcp &&
2962
- storedConnectToken?.kind === "service"
2963
- ? { identityAssurance: "service" }
2964
- : typeof payload.sub === "string"
2965
- ? { identityAssurance: "user" }
2966
- : typeof payload.org_id === "string" ||
2967
- typeof payload.org_domain === "string"
2968
- ? { identityAssurance: "organization" }
2969
- : {}),
2977
+ ...(typeof payload.sub === "string"
2978
+ ? { identityAssurance: "user" }
2979
+ : typeof payload.org_id === "string" ||
2980
+ typeof payload.org_domain === "string"
2981
+ ? { identityAssurance: "organization" }
2982
+ : {}),
2970
2983
  ...(orgId !== undefined ? { orgId } : {}),
2971
2984
  orgDomain: typeof payload.org_domain === "string"
2972
2985
  ? payload.org_domain
@@ -2975,45 +2988,40 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2975
2988
  },
2976
2989
  fullSurface: true,
2977
2990
  fullCatalog: payload.catalog_scope === "full",
2978
- };
2979
- // First-party MCP tokens share the connect scope but are minted by a
2980
- // sibling app per call, so they are cross-app A2A tokens.
2981
- const admitted = tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp
2982
- ? await admitIssuedCredential(verified, options.requestOrigin, orgResolution, typeof payload.iat === "number" ? payload.iat : undefined)
2983
- : await admitServicePrincipal(verified);
2984
- if (admitted.authed && tokenScope === MCP_CONNECT_SCOPE) {
2985
- await markConnectTokenUsed(payload.jti);
2986
- }
2987
- return admitted;
2988
- }
2989
- if (accessTokens.length === 0) {
2990
- // A supplied bearer that failed JWT verification must not fall through to
2991
- // dev-open auth or reuse a forwarded owner-email hint.
2992
- return { authed: false };
2993
- }
2994
- // Try ACCESS_TOKEN / ACCESS_TOKENS exact match. Static tokens carry no
2995
- // per-caller claims, so derive identity from the forwarded owner-email
2996
- // hint (install flow) — otherwise tools would run unscoped. Compare in
2997
- // constant time (matching the rest of this subsystem's secret-comparison
2998
- // discipline); node:crypto is imported dynamically because this module is
2999
- // bundled into the serverless function and avoids static Node-only imports.
3000
- if (accessTokens.length > 0) {
3001
- const { timingSafeEqual } = await import("node:crypto");
3002
- const candidate = Buffer.from(token, "utf8");
3003
- const matched = accessTokens.some((configured) => {
3004
- const expected = Buffer.from(configured, "utf8");
3005
- return (expected.length === candidate.length &&
3006
- timingSafeEqual(expected, candidate));
3007
2991
  });
3008
- if (matched) {
3009
- return {
3010
- authed: true,
3011
- identity: deriveStaticTokenIdentity(ownerEmailHeader),
3012
- fullSurface: true,
3013
- };
3014
- }
3015
2992
  }
3016
- return { authed: false };
2993
+ // A supplied bearer that failed JWT verification must not fall through to
2994
+ // dev-open auth or reuse a forwarded owner-email hint.
2995
+ return ((await matchStaticAccessToken(token, accessTokens, ownerEmailHeader)) ?? {
2996
+ authed: false,
2997
+ refusal: "invalid",
2998
+ });
2999
+ }
3000
+ /**
3001
+ * ACCESS_TOKEN / ACCESS_TOKENS exact match. Static tokens carry no per-caller
3002
+ * claims, so identity comes from the forwarded owner-email hint (install flow)
3003
+ * — otherwise tools would run unscoped. Compared in constant time, matching
3004
+ * the rest of this subsystem's secret-comparison discipline; node:crypto is
3005
+ * imported dynamically because this module is bundled into the serverless
3006
+ * function and avoids static Node-only imports.
3007
+ */
3008
+ async function matchStaticAccessToken(token, accessTokens, ownerEmailHeader) {
3009
+ if (accessTokens.length === 0)
3010
+ return null;
3011
+ const { timingSafeEqual } = await import("node:crypto");
3012
+ const candidate = Buffer.from(token, "utf8");
3013
+ const matched = accessTokens.some((configured) => {
3014
+ const expected = Buffer.from(configured, "utf8");
3015
+ return (expected.length === candidate.length &&
3016
+ timingSafeEqual(expected, candidate));
3017
+ });
3018
+ if (!matched)
3019
+ return null;
3020
+ return {
3021
+ authed: true,
3022
+ identity: deriveStaticTokenIdentity(ownerEmailHeader),
3023
+ fullSurface: true,
3024
+ };
3017
3025
  }
3018
3026
  export async function resolveOrgIdFromDomain(orgDomain) {
3019
3027
  if (!orgDomain)
@@ -4,6 +4,14 @@ export interface McpConnectRouteOptions {
4
4
  appName?: string;
5
5
  serverName?: string;
6
6
  }
7
+ /**
8
+ * Neither the request nor configuration names this app's public URL, so no
9
+ * audience `verifyAuth` would accept can be resolved. Guessing one would mint a
10
+ * token the app then refuses.
11
+ */
12
+ export declare class OrgServiceTokenAppUrlError extends Error {
13
+ constructor();
14
+ }
7
15
  /**
8
16
  * Mint an ORG SERVICE token: a connect-scoped, revocable bearer whose subject
9
17
  * is the synthetic service identity `svc-<name>@service.<orgId>` instead of a
@@ -14,7 +22,7 @@ export interface McpConnectRouteOptions {
14
22
  *
15
23
  * The token value is returned exactly once and never persisted — only the
16
24
  * random `jti` is stored, so the standard revocation path
17
- * (`isJtiRevoked` in `verifyAuth`) applies to service tokens identically.
25
+ * (`lookupConnectTokenOrg` in `verifyAuth`) applies to service tokens identically.
18
26
  *
19
27
  * The `create-org-service-token` action gates on org owner/admin before
20
28
  * calling this. Offboarding can remove that admin before the mint, so the
@@ -27,7 +35,8 @@ export declare function mintOrgServiceToken(params: {
27
35
  /** The human minting the token — stored for audit, never used as identity. */
28
36
  createdBy: string;
29
37
  ttlDays?: number;
30
- appUrl: string;
38
+ /** The caller's request origin, when there is a request. */
39
+ appUrl: string | undefined;
31
40
  }): Promise<{
32
41
  token: string;
33
42
  jti: string;