@agent-native/core 0.154.0 → 0.154.2

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 (54) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/changelog/2026-08-12-feature-flags-can-be-managed-across-apps-whose-workspace-rec.md +6 -0
  3. package/corpus/templates/analytics/changelog/2026-08-12-kept-feature-flag-details-readable-by-stacking-rollout-contr.md +6 -0
  4. package/corpus/templates/analytics/scripts/preview-dashboard-report.ts +16 -5
  5. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +98 -31
  6. package/corpus/templates/content/actions/resolve-content-landing.ts +162 -32
  7. package/corpus/templates/content/shared/content-landing.ts +5 -0
  8. package/corpus/templates/dispatch/changelog/2026-08-12-dispatch-now-appears-in-authorized-workspace-app-directories.md +6 -0
  9. package/corpus/templates/dispatch/server/lib/org-apps-directory.ts +3 -4
  10. package/corpus/templates/dispatch/server/plugins/org-apps-directory.ts +15 -3
  11. package/corpus/templates/slides/actions/patch-deck.ts +12 -3
  12. package/corpus/templates/slides/app/components/editor/EditorActionCluster.tsx +73 -1
  13. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +6 -1
  14. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -0
  15. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +15 -0
  16. package/corpus/templates/slides/app/context/DeckContext.tsx +19 -31
  17. package/corpus/templates/slides/app/hooks/use-navigation-state.ts +15 -16
  18. package/corpus/templates/slides/app/pages/DeckEditor.tsx +9 -0
  19. package/corpus/templates/slides/changelog/2026-08-13-clicking-a-slide-thumbnail-now-focuses-it-so-the-slide-shortc.md +6 -0
  20. package/corpus/templates/slides/changelog/2026-08-13-duplicating-or-restoring-a-slide-now-keeps-its-transition-ani.md +6 -0
  21. package/corpus/templates/slides/changelog/2026-08-13-opening-presentation-mode-from-the-agent-now-starts-on-the-re.md +6 -0
  22. package/corpus/templates/slides/changelog/2026-08-13-slide-transitions-can-be-set-from-a-visible-control-in-the-sl.md +6 -0
  23. package/dist/a2a/audience.d.ts +2 -0
  24. package/dist/a2a/audience.js +23 -0
  25. package/dist/a2a/client.d.ts +4 -0
  26. package/dist/a2a/client.js +32 -14
  27. package/dist/a2a/server.d.ts +9 -4
  28. package/dist/a2a/server.js +52 -25
  29. package/dist/a2a/types.d.ts +4 -4
  30. package/dist/a2a-claims.d.ts +1 -0
  31. package/dist/a2a-claims.js +3 -1
  32. package/dist/client/feature-flags/FeatureFlagsPanel.js +2 -2
  33. package/dist/collab/awareness.d.ts +2 -2
  34. package/dist/collab/routes.d.ts +1 -1
  35. package/dist/feature-flags/a2a-action-route.js +10 -1
  36. package/dist/feature-flags/actions/set-feature-flag.d.ts +2 -1
  37. package/dist/feature-flags/actions/set-feature-flag.js +9 -2
  38. package/dist/mcp/org-directory.d.ts +3 -0
  39. package/dist/mcp/org-directory.js +15 -5
  40. package/dist/notifications/routes.d.ts +1 -1
  41. package/dist/observability/routes.d.ts +6 -6
  42. package/dist/provider-api/actions/custom-provider-registration.d.ts +2 -2
  43. package/dist/resources/handlers.d.ts +1 -1
  44. package/dist/secrets/routes.d.ts +3 -3
  45. package/dist/server/action-routes.js +10 -2
  46. package/dist/server/agent-capabilities.js +0 -0
  47. package/dist/server/agent-chat/action-filters-a2a.d.ts +14 -13
  48. package/dist/server/agent-chat/action-filters-a2a.js +59 -23
  49. package/dist/server/agent-discovery.d.ts +6 -2
  50. package/dist/server/agent-discovery.js +16 -9
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/identity-sso-store.d.ts +5 -4
  53. package/dist/server/identity-sso-store.js +5 -4
  54. package/package.json +1 -1
@@ -1,12 +1,24 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
- import { IconLoader2, IconPlus, IconTextSize } from "@tabler/icons-react";
2
+ import {
3
+ IconLoader2,
4
+ IconPlus,
5
+ IconTextSize,
6
+ IconTransitionRight,
7
+ } from "@tabler/icons-react";
3
8
  import { useEffect, useRef, useState } from "react";
4
9
 
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from "@/components/ui/dropdown-menu";
5
16
  import {
6
17
  Tooltip,
7
18
  TooltipContent,
8
19
  TooltipTrigger,
9
20
  } from "@/components/ui/tooltip";
21
+ import type { Slide } from "@/context/DeckContext";
10
22
  import { useAgentGenerating } from "@/hooks/use-agent-generating";
11
23
  import { cn } from "@/lib/utils";
12
24
 
@@ -19,6 +31,15 @@ const IDLE_CLASS =
19
31
  const ACTIVE_CLASS = "bg-accent text-foreground";
20
32
  const DIVIDER_CLASS = "mx-1 h-4 w-px shrink-0 bg-border";
21
33
 
34
+ type SlideTransition = NonNullable<Slide["transition"]>;
35
+
36
+ const TRANSITIONS: { value: SlideTransition; labelKey: string }[] = [
37
+ { value: "instant", labelKey: "editorToolbar.transition_instant" },
38
+ { value: "fade", labelKey: "editorToolbar.transition_fade" },
39
+ { value: "slide", labelKey: "editorToolbar.transition_slide" },
40
+ { value: "zoom", labelKey: "editorToolbar.transition_zoom" },
41
+ ];
42
+
22
43
  /**
23
44
  * Add slide, undo, redo, and add-text-box — the actions that stay put
24
45
  * regardless of what is selected. Rendered at the head of the contextual
@@ -36,6 +57,8 @@ export function EditorActionCluster({
36
57
  onDuplicateCurrentSlide,
37
58
  textBoxMode,
38
59
  onToggleTextBoxMode,
60
+ slideTransition,
61
+ onChangeSlideTransition,
39
62
  className,
40
63
  }: {
41
64
  deckId: string;
@@ -49,12 +72,19 @@ export function EditorActionCluster({
49
72
  onDuplicateCurrentSlide?: () => void;
50
73
  textBoxMode?: boolean;
51
74
  onToggleTextBoxMode?: () => void;
75
+ slideTransition?: Slide["transition"];
76
+ onChangeSlideTransition?: (transition: SlideTransition) => void;
52
77
  className?: string;
53
78
  }) {
54
79
  const t = useT();
55
80
  const { generating, submit: agentSubmit } = useAgentGenerating();
56
81
  const [addSlideOpen, setAddSlideOpen] = useState(false);
57
82
  const addSlideRef = useRef<HTMLButtonElement>(null);
83
+ // "none" is a legacy alias the presentation view already treats as instant.
84
+ const activeTransition: SlideTransition =
85
+ !slideTransition || slideTransition === "none"
86
+ ? "instant"
87
+ : slideTransition;
58
88
 
59
89
  useEffect(() => {
60
90
  if (!generating) onAddSlideGeneratingChange?.(false);
@@ -125,6 +155,48 @@ export function EditorActionCluster({
125
155
  </Tooltip>
126
156
  </>
127
157
  )}
158
+
159
+ {onChangeSlideTransition && currentSlideId && (
160
+ <>
161
+ <div className={DIVIDER_CLASS} />
162
+ <DropdownMenu>
163
+ <Tooltip>
164
+ <TooltipTrigger asChild>
165
+ <DropdownMenuTrigger asChild>
166
+ <button
167
+ type="button"
168
+ aria-label={t("editorToolbar.transition")}
169
+ className={cn(
170
+ BUTTON_CLASS,
171
+ activeTransition === "instant"
172
+ ? IDLE_CLASS
173
+ : ACTIVE_CLASS,
174
+ )}
175
+ >
176
+ <IconTransitionRight className="size-4" />
177
+ </button>
178
+ </DropdownMenuTrigger>
179
+ </TooltipTrigger>
180
+ <TooltipContent>{t("editorToolbar.transition")}</TooltipContent>
181
+ </Tooltip>
182
+ <DropdownMenuContent align="start" className="w-40">
183
+ {TRANSITIONS.map((transition) => (
184
+ <DropdownMenuItem
185
+ key={transition.value}
186
+ onSelect={() => onChangeSlideTransition(transition.value)}
187
+ className={
188
+ activeTransition === transition.value
189
+ ? "bg-accent text-accent-foreground"
190
+ : undefined
191
+ }
192
+ >
193
+ {t(transition.labelKey)}
194
+ </DropdownMenuItem>
195
+ ))}
196
+ </DropdownMenuContent>
197
+ </DropdownMenu>
198
+ </>
199
+ )}
128
200
  </div>
129
201
  );
130
202
  }
@@ -198,7 +198,12 @@ function SortableSlideThumb({
198
198
  type="button"
199
199
  {...(readOnly ? {} : attributes)}
200
200
  {...(readOnly ? {} : listeners)}
201
- onClick={onSelect}
201
+ onClick={(event) => {
202
+ // Safari does not focus a button on click, and the slide copy/paste
203
+ // and delete shortcuts are scoped to a focused thumbnail.
204
+ event.currentTarget.focus();
205
+ onSelect();
206
+ }}
202
207
  onFocus={onSelect}
203
208
  aria-label={t("editorSidebar.selectSlide", { number: index + 1 })}
204
209
  aria-current={isActive ? "true" : undefined}
@@ -121,6 +121,9 @@ interface EditorToolbarProps {
121
121
  textBoxMode?: boolean;
122
122
  /** Toggle the add-text-box tool */
123
123
  onToggleTextBoxMode?: () => void;
124
+ onChangeSlideTransition?: (
125
+ transition: NonNullable<Slide["transition"]>,
126
+ ) => void;
124
127
  /** Duplicate the current deck */
125
128
  onDuplicateDeck?: () => void;
126
129
  /** Export the deck as PDF */
@@ -176,6 +179,7 @@ export default function EditorToolbar({
176
179
  onTogglePinMode,
177
180
  textBoxMode,
178
181
  onToggleTextBoxMode,
182
+ onChangeSlideTransition,
179
183
  onDuplicateDeck,
180
184
  onExportPdf,
181
185
  onExportPptx,
@@ -567,6 +571,8 @@ export default function EditorToolbar({
567
571
  onDuplicateCurrentSlide={onDuplicateCurrentSlide}
568
572
  textBoxMode={textBoxMode}
569
573
  onToggleTextBoxMode={onToggleTextBoxMode}
574
+ slideTransition={currentSlide?.transition}
575
+ onChangeSlideTransition={onChangeSlideTransition}
570
576
  />
571
577
  )}
572
578
 
@@ -176,6 +176,21 @@ export function NewDeckReferenceStep({
176
176
  const chooseSource = (
177
177
  kind: NonNullable<NewDeckReferenceSelection["referenceSource"]>["kind"],
178
178
  ) => {
179
+ const isAlreadySelected =
180
+ selectedSource?.kind === kind ||
181
+ (kind === "google-docs" && importedReference?.source === "google-slides");
182
+ if (isAlreadySelected) {
183
+ setSelectedSource(null);
184
+ if (importedReference) {
185
+ // The import also set the reference deck. Leaving that id behind would
186
+ // submit a deck the UI no longer shows as selected.
187
+ setSelectedReferenceDeckId((current) =>
188
+ current === importedReference.id ? null : current,
189
+ );
190
+ setImportedReference(null);
191
+ }
192
+ return;
193
+ }
179
194
  setSelectedSource({ kind, value: "" });
180
195
  if (kind === "website" || kind === "figma") {
181
196
  setSelectedDesignSystemId(null);
@@ -40,12 +40,10 @@ type GranularOp =
40
40
  op: "add-slide";
41
41
  slideId: string;
42
42
  afterSlideId?: string;
43
- fields: {
44
- content: string;
45
- notes?: string;
46
- layout?: string;
47
- background?: string;
48
- };
43
+ /** Everything but `id` and the transient `imageLoading` flag. A slide
44
+ * copied or restored through this op keeps its transition, animations,
45
+ * and image/Excalidraw data instead of silently losing them on reload. */
46
+ fields: Omit<Partial<Slide>, "id" | "imageLoading"> & { content: string };
49
47
  }
50
48
  | {
51
49
  op: "patch-deck-fields";
@@ -57,6 +55,16 @@ type GranularOp =
57
55
  | { op: "full-replace"; deck: Deck };
58
56
 
59
57
  export type PatchDeckOp = Exclude<GranularOp, { op: "full-replace" }>;
58
+
59
+ /** Slide payload for an `add-slide` op. `imageLoading` is transient UI state
60
+ * and must not persist; everything else on the slide has to survive the round
61
+ * trip or a duplicated/restored slide comes back missing fields. */
62
+ function addSlideFields(
63
+ slide: Slide,
64
+ ): Extract<GranularOp, { op: "add-slide" }>["fields"] {
65
+ const { id: _id, imageLoading: _imageLoading, ...fields } = slide;
66
+ return fields;
67
+ }
60
68
  export type DeckReloadStatus = "loaded" | "failed" | "stale";
61
69
  export interface UpdateSlideOptions {
62
70
  persistence?: "debounced" | "immediate";
@@ -683,13 +691,11 @@ export function applyOpToDeck(deck: Deck, op: PatchDeckOp): Deck {
683
691
  case "add-slide": {
684
692
  if (deck.slides.some((s) => s.id === op.slideId)) return deck; // idempotent
685
693
  const newSlide: Slide = {
694
+ ...op.fields,
686
695
  id: op.slideId,
687
696
  content: op.fields.content,
688
697
  notes: op.fields.notes ?? "",
689
698
  layout: (op.fields.layout as SlideLayout) ?? "content",
690
- ...(op.fields.background !== undefined
691
- ? { background: op.fields.background }
692
- : {}),
693
699
  };
694
700
  const slides = [...deck.slides];
695
701
  const afterIdx = op.afterSlideId
@@ -822,14 +828,7 @@ export function deriveInverseOp(
822
828
  op: "add-slide",
823
829
  slideId: prior.id,
824
830
  afterSlideId,
825
- fields: {
826
- content: prior.content,
827
- notes: prior.notes,
828
- layout: prior.layout,
829
- ...(prior.background !== undefined
830
- ? { background: prior.background }
831
- : {}),
832
- },
831
+ fields: addSlideFields(prior),
833
832
  },
834
833
  {
835
834
  op: "reorder-slides",
@@ -2127,12 +2126,7 @@ export function DeckProvider({ children }: { children: ReactNode }) {
2127
2126
  op: "add-slide",
2128
2127
  slideId: newSlide.id,
2129
2128
  afterSlideId,
2130
- fields: {
2131
- content: newSlide.content,
2132
- notes: newSlide.notes,
2133
- layout: newSlide.layout,
2134
- background: newSlide.background,
2135
- },
2129
+ fields: addSlideFields(newSlide),
2136
2130
  };
2137
2131
  enqueueDeckOp(deckId, op);
2138
2132
  if (before) recordUndo(before, op, { label: "Add slide" });
@@ -2240,17 +2234,11 @@ export function DeckProvider({ children }: { children: ReactNode }) {
2240
2234
  // Granular add-slide op — inserts the copy after the original. Build it
2241
2235
  // from the current deck before scheduling the React state update; the
2242
2236
  // functional updater runs later and cannot be used to produce the op.
2243
- const { id: newSlideId, ...rest } = copiedSlide;
2244
2237
  const op: PatchDeckOp = {
2245
2238
  op: "add-slide",
2246
- slideId: newSlideId,
2239
+ slideId: copiedSlide.id,
2247
2240
  afterSlideId: slideId,
2248
- fields: {
2249
- content: rest.content,
2250
- notes: rest.notes,
2251
- layout: rest.layout,
2252
- background: rest.background,
2253
- },
2241
+ fields: addSlideFields(copiedSlide),
2254
2242
  };
2255
2243
  enqueueDeckOp(deckId, op);
2256
2244
  recordUndo(before, op, { label: "Duplicate slide" });
@@ -156,22 +156,21 @@ export function useNavigationState() {
156
156
  path = `/deck/${cmd.deckId}`;
157
157
  if (cmd.view === "present") {
158
158
  path += "/present";
159
- } else {
160
- const internalSlideIndex =
161
- typeof cmd.slideNumber === "number" &&
162
- Number.isFinite(cmd.slideNumber) &&
163
- cmd.slideNumber >= 1
164
- ? cmd.slideNumber - 1
165
- : cmd.slideIndex;
166
- if (
167
- typeof internalSlideIndex === "number" &&
168
- Number.isFinite(internalSlideIndex) &&
169
- internalSlideIndex >= 0
170
- ) {
171
- // Convert the internal zero-based value back to the 1-based
172
- // ?slide=N URL param the editor reads.
173
- path += `?slide=${internalSlideIndex + 1}`;
174
- }
159
+ }
160
+ const internalSlideIndex =
161
+ typeof cmd.slideNumber === "number" &&
162
+ Number.isFinite(cmd.slideNumber) &&
163
+ cmd.slideNumber >= 1
164
+ ? cmd.slideNumber - 1
165
+ : cmd.slideIndex;
166
+ if (
167
+ typeof internalSlideIndex === "number" &&
168
+ Number.isFinite(internalSlideIndex) &&
169
+ internalSlideIndex >= 0
170
+ ) {
171
+ // Convert the internal zero-based value back to the 1-based
172
+ // ?slide=N URL param both the editor and presentation view read.
173
+ path += `?slide=${internalSlideIndex + 1}`;
175
174
  }
176
175
  }
177
176
 
@@ -1194,6 +1194,11 @@ export default function DeckEditor() {
1194
1194
  onDuplicateCurrentSlide={
1195
1195
  currentSlide ? () => duplicateSlide(id, currentSlide.id) : undefined
1196
1196
  }
1197
+ onChangeSlideTransition={
1198
+ canEdit && currentSlide
1199
+ ? (transition) => updateSlide(id, currentSlide.id, { transition })
1200
+ : undefined
1201
+ }
1197
1202
  />
1198
1203
 
1199
1204
  {/* Full-width host for the slide's contextual style toolbar: it spans the
@@ -1312,6 +1317,10 @@ export default function DeckEditor() {
1312
1317
  }
1313
1318
  textBoxMode={textBoxMode}
1314
1319
  onToggleTextBoxMode={toggleTextBoxMode}
1320
+ slideTransition={currentSlide.transition}
1321
+ onChangeSlideTransition={(transition) =>
1322
+ updateSlide(id, currentSlide.id, { transition })
1323
+ }
1315
1324
  />
1316
1325
  ) : undefined
1317
1326
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-13
4
+ ---
5
+
6
+ Clicking a slide thumbnail now focuses it, so the slide copy, paste, and delete shortcuts work after a plain click
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-13
4
+ ---
5
+
6
+ Duplicating or undoing the deletion of a slide now keeps its transition, animations, and image data after reload
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-13
4
+ ---
5
+
6
+ Opening presentation mode from the agent now starts on the requested slide instead of the first one
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-08-13
4
+ ---
5
+
6
+ Slide transitions can be set from a visible control in the slide toolbar instead of being agent-only
@@ -0,0 +1,2 @@
1
+ /** Canonical receiver identifier used by both A2A token issuers and verifiers. */
2
+ export declare function canonicalA2AAudience(rawUrl: string, receiverBasePath?: string): string;
@@ -0,0 +1,23 @@
1
+ function normalizePathname(pathname, stripEndpoint) {
2
+ const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
3
+ const withoutTrailingSlashes = normalized.replace(/\/+$/, "");
4
+ const withoutEndpoint = stripEndpoint
5
+ ? withoutTrailingSlashes.endsWith("/_agent-native/a2a")
6
+ ? withoutTrailingSlashes.slice(0, -"/_agent-native/a2a".length)
7
+ : withoutTrailingSlashes.endsWith("/a2a")
8
+ ? withoutTrailingSlashes.slice(0, -"/a2a".length)
9
+ : withoutTrailingSlashes
10
+ : withoutTrailingSlashes;
11
+ return withoutEndpoint === "" ? "/" : withoutEndpoint;
12
+ }
13
+ /** Canonical receiver identifier used by both A2A token issuers and verifiers. */
14
+ export function canonicalA2AAudience(rawUrl, receiverBasePath) {
15
+ try {
16
+ const url = new URL(rawUrl);
17
+ const pathname = normalizePathname(receiverBasePath ?? url.pathname, receiverBasePath === undefined);
18
+ return pathname === "/" ? url.origin : `${url.origin}${pathname}`;
19
+ }
20
+ catch {
21
+ return rawUrl.replace(/\/+$/, "");
22
+ }
23
+ }
@@ -62,6 +62,10 @@ export declare class A2AClient {
62
62
  * Agent-native apps use /_agent-native/a2a, external agents may use /a2a.
63
63
  */
64
64
  resolveEndpoint(): Promise<void>;
65
+ /** Resolve the card-advertised endpoint without sending an RPC request. */
66
+ resolveEndpointUrl(timeoutMs?: number): Promise<string>;
67
+ /** Replace caller credentials without discarding resolved endpoint fallbacks. */
68
+ setAuthentication(apiKey?: string, fallbackApiKeys?: string[]): void;
65
69
  private headers;
66
70
  private markApiKeySucceeded;
67
71
  private rpc;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as jose from "jose";
3
3
  import { ssrfSafeFetch } from "../extensions/url-safety.js";
4
+ import { canonicalA2AAudience } from "./audience.js";
4
5
  /**
5
6
  * A workspace serves every app from one gateway on loopback, so sibling A2A
6
7
  * targets are private addresses by construction and the SSRF guard cannot tell
@@ -185,6 +186,19 @@ export class A2AClient {
185
186
  }
186
187
  }
187
188
  }
189
+ /** Resolve the card-advertised endpoint without sending an RPC request. */
190
+ async resolveEndpointUrl(timeoutMs) {
191
+ await this.ensureEndpointCandidates(timeoutMs);
192
+ const endpoint = this.endpointCandidates[0];
193
+ if (!endpoint)
194
+ throw new Error("No A2A endpoint candidates available");
195
+ return endpoint;
196
+ }
197
+ /** Replace caller credentials without discarding resolved endpoint fallbacks. */
198
+ setAuthentication(apiKey, fallbackApiKeys = []) {
199
+ this.apiKey = apiKey;
200
+ this.apiKeyAttempts = uniqueAuthTokens([apiKey, ...fallbackApiKeys]);
201
+ }
188
202
  headers(apiKey = this.apiKey) {
189
203
  const h = { "Content-Type": "application/json" };
190
204
  if (apiKey) {
@@ -823,14 +837,27 @@ export async function callAction(url, action, input = {}, opts) {
823
837
  if (!input || typeof input !== "object" || Array.isArray(input)) {
824
838
  throw new Error("A2A action input must be an object");
825
839
  }
826
- const apiKeyAttempts = await buildA2AApiKeyAttempts(opts, normalizeA2AAudience(url));
827
- const fallbackApiKeys = apiKeyAttempts
840
+ const discoveryAudience = normalizeA2AAudience(url);
841
+ const discoveryApiKeyAttempts = await buildA2AApiKeyAttempts(opts, discoveryAudience);
842
+ const discoveryFallbackApiKeys = discoveryApiKeyAttempts
828
843
  .slice(1)
829
844
  .filter((token) => token !== undefined);
830
- const client = new A2AClient(url, apiKeyAttempts[0], {
831
- fallbackApiKeys,
845
+ const client = new A2AClient(url, discoveryApiKeyAttempts[0], {
846
+ fallbackApiKeys: discoveryFallbackApiKeys,
832
847
  requestTimeoutMs: opts?.requestTimeoutMs,
833
848
  });
849
+ const endpointUrl = await client.resolveEndpointUrl(opts?.requestTimeoutMs);
850
+ const invocationAudience = normalizeA2AAudience(endpointUrl);
851
+ if (invocationAudience !== discoveryAudience) {
852
+ const invocationApiKeyAttempts = await buildA2AApiKeyAttempts(opts, invocationAudience);
853
+ const combinedApiKeyAttempts = uniqueAuthTokens([
854
+ ...invocationApiKeyAttempts,
855
+ ...discoveryApiKeyAttempts,
856
+ ]);
857
+ client.setAuthentication(combinedApiKeyAttempts[0], combinedApiKeyAttempts
858
+ .slice(1)
859
+ .filter((token) => token !== undefined));
860
+ }
834
861
  return client.invokeAction(actionName, input, {
835
862
  metadata: sanitizeA2ACorrelationMetadata(opts?.correlation),
836
863
  });
@@ -876,16 +903,7 @@ async function buildA2AApiKeyAttempts(opts, audience) {
876
903
  function normalizeA2AAudience(url) {
877
904
  const explicit = splitExplicitA2AEndpoint(url.replace(/\/$/, ""));
878
905
  const base = (explicit?.baseUrl ?? url).replace(/\/$/, "");
879
- // Receivers derive their expected audience from APP_URL, which carries no
880
- // app path. In a workspace every app is mounted under one gateway origin, so
881
- // signing the path-qualified URL yields an audience the receiver can never
882
- // match. The origin is the identifier both sides agree on.
883
- try {
884
- return new URL(base).origin;
885
- }
886
- catch {
887
- return base;
888
- }
906
+ return canonicalA2AAudience(base);
889
907
  }
890
908
  function isA2AAuthRejection(err) {
891
909
  const message = err instanceof Error ? err.message : String(err ?? "");
@@ -1,4 +1,4 @@
1
- import type { A2AConfig } from "./types.js";
1
+ import type { A2AConfig, AgentSkill } from "./types.js";
2
2
  /**
3
3
  * Result of verifying an inbound A2A JWT. `email` is the caller identity from
4
4
  * the token's `sub` claim (null when verification fails), `orgDomain` mirrors
@@ -21,9 +21,14 @@ export interface A2ATokenPayload {
21
21
  * Exported so workspaces can accept A2A callers on the HTTP action route with
22
22
  * the same routine — including org-level fallback secrets — instead of
23
23
  * reimplementing a partial verifier. Pass the H3 `event` to enable org-domain →
24
- * org-secret lookup and audience derivation; it is optional.
24
+ * org-secret lookup and audience derivation; it is optional. A custom mounted
25
+ * JSON-RPC route also passes its prefix so endpoint-bound tokens verify against
26
+ * the same audience the client derived from the advertised URL.
25
27
  */
26
- export declare function verifyA2AToken(token: string, event?: any): Promise<A2ATokenPayload>;
28
+ export declare function verifyA2AToken(token: string, event?: any, audienceOptions?: {
29
+ routePrefix?: string;
30
+ allowBaseAudience?: boolean;
31
+ }): Promise<A2ATokenPayload>;
27
32
  /**
28
33
  * Mount A2A protocol endpoints on an H3/Nitro app.
29
34
  *
@@ -35,4 +40,4 @@ export declare function verifyA2AToken(token: string, event?: any): Promise<A2AT
35
40
  * cryptographic identity verification for cross-app A2A calls.
36
41
  */
37
42
  export declare function mountA2A(nitroApp: any, config: A2AConfig, routePrefix?: string): void;
38
- export declare function filterPublicAgentCardSkills(config: A2AConfig): import("./types.js").AgentSkill[];
43
+ export declare function filterPublicAgentCardSkills(config: A2AConfig): AgentSkill[];
@@ -6,6 +6,7 @@ import { getH3App } from "../server/framework-request-handler.js";
6
6
  import { readBody } from "../server/h3-helpers.js";
7
7
  import { isSameOriginRequest } from "../server/request-origin.js";
8
8
  import { generateAgentCard } from "./agent-card.js";
9
+ import { canonicalA2AAudience } from "./audience.js";
9
10
  import { hasConfiguredA2ASecret, isA2AProductionRuntime, } from "./auth-policy.js";
10
11
  import { handleJsonRpcH3, processA2ATaskFromQueue } from "./handlers.js";
11
12
  import { claimA2AApproval, getA2AApprovalForOwner, settleA2AApproval, } from "./task-store.js";
@@ -39,25 +40,46 @@ function addSecretCandidate(candidates, secret) {
39
40
  * service must not verify here). Only tokens without an `aud` claim (minted
40
41
  * before the audience claim shipped) skip the audience check.
41
42
  */
42
- function expectedJwtAudience(event) {
43
+ function expectedJwtAudience(event, options) {
43
44
  const fromEnv = process.env.APP_URL ||
44
45
  process.env.URL ||
45
46
  process.env.DEPLOY_URL ||
46
47
  process.env.BETTER_AUTH_URL;
47
- if (fromEnv)
48
- return String(fromEnv).replace(/\/$/, "");
48
+ const receiverBasePath = process.env.APP_BASE_PATH;
49
+ if (fromEnv) {
50
+ return audienceForRoute(canonicalA2AAudience(String(fromEnv), receiverBasePath), options);
51
+ }
49
52
  // Best-effort: derive from the inbound request host. This is forgeable
50
53
  // (Host-header attack), but only useful as a hint when env-derived URL
51
54
  // is unset; the rest of the JWT verification still uses the secret.
52
55
  try {
53
56
  const proto = getRequestHeader(event, "x-forwarded-proto") || "https";
54
57
  const host = getRequestHeader(event, "host");
55
- if (host)
56
- return `${proto}://${host}`;
58
+ if (host) {
59
+ return audienceForRoute(canonicalA2AAudience(`${proto}://${host}`, receiverBasePath), options);
60
+ }
61
+ // coercion-ok: undefined makes audience-bearing token verification fail closed.
57
62
  }
58
63
  catch { }
59
64
  return undefined;
60
65
  }
66
+ function audienceForRoute(baseAudience, options) {
67
+ const trimmedPrefix = options?.routePrefix?.replace(/^\/+|\/+$/g, "");
68
+ if (!trimmedPrefix || trimmedPrefix === "_agent-native")
69
+ return baseAudience;
70
+ const normalizedPrefix = `/${trimmedPrefix}`;
71
+ const routeAudience = `${baseAudience.replace(/\/+$/, "")}${normalizedPrefix}`;
72
+ return options?.allowBaseAudience
73
+ ? [routeAudience, baseAudience]
74
+ : routeAudience;
75
+ }
76
+ function tokenHasAudienceClaim(token) {
77
+ return typeof jose.decodeJwt(token).aud !== "undefined";
78
+ }
79
+ function isDirectReadSkill(skill) {
80
+ return (skill.readOnly === true ||
81
+ (skill.readOnly === undefined && skill.publicAgent?.readOnly === true));
82
+ }
61
83
  /**
62
84
  * Verify an inbound A2A bearer token (HS256) exactly as the
63
85
  * `/_agent-native/a2a` endpoint does: it peeks at the unverified `org_domain`
@@ -71,9 +93,11 @@ function expectedJwtAudience(event) {
71
93
  * Exported so workspaces can accept A2A callers on the HTTP action route with
72
94
  * the same routine — including org-level fallback secrets — instead of
73
95
  * reimplementing a partial verifier. Pass the H3 `event` to enable org-domain →
74
- * org-secret lookup and audience derivation; it is optional.
96
+ * org-secret lookup and audience derivation; it is optional. A custom mounted
97
+ * JSON-RPC route also passes its prefix so endpoint-bound tokens verify against
98
+ * the same audience the client derived from the advertised URL.
75
99
  */
76
- export async function verifyA2AToken(token, event) {
100
+ export async function verifyA2AToken(token, event, audienceOptions) {
77
101
  // Step 1: Peek at JWT claims WITHOUT verification to get org_domain.
78
102
  // This is safe because we only use org_domain to look up the secret,
79
103
  // then verify the full JWT with that secret. If someone forges a JWT
@@ -129,7 +153,7 @@ export async function verifyA2AToken(token, event) {
129
153
  // whose `aud` targets ANOTHER service verify against a shared secret. A
130
154
  // token that self-declares an audience must be checked against ours, so
131
155
  // when we have nothing to check it against we reject rather than skip.
132
- const aud = expectedJwtAudience(event);
156
+ const aud = expectedJwtAudience(event, audienceOptions);
133
157
  if (!aud)
134
158
  return { email: null, orgDomain: null };
135
159
  verifyOptions.audience = aud;
@@ -187,19 +211,26 @@ export function mountA2A(nitroApp, config, routePrefix = "/_agent-native") {
187
211
  const host = getRequestHeader(event, "host") ?? "localhost";
188
212
  const baseUrl = `${protocol}://${host}`;
189
213
  // The anonymous card may only advertise actions safe to disclose
190
- // publicly (`requiresAuth !== true`), but `actions/invoke` only ever
191
- // runs the opposite set (`requiresAuth === true`). Those are disjoint,
192
- // so an unauthenticated card said "no directly callable actions" about
193
- // an app whose actions a verified sibling can call — and callers fell
194
- // back to open-ended delegation. Show a verified caller what it can
195
- // actually invoke; anonymous fetches keep the public list unchanged.
214
+ // publicly (`requiresAuth !== true`). A verified caller instead sees the
215
+ // authenticated surface: schemas for direct read invocation plus concise
216
+ // message-only capabilities for writes owned by the receiving agent.
217
+ // Anonymous fetches keep the public list unchanged.
196
218
  let skills = filterPublicAgentCardSkills(config);
197
219
  if (config.authenticatedSkills?.length) {
198
220
  const bearer = extractBearerToken(getRequestHeader(event, "authorization"));
199
221
  if (bearer) {
200
- const payload = await verifyA2AToken(bearer, event);
201
- if (payload.email)
202
- skills = config.authenticatedSkills;
222
+ const payload = await verifyA2AToken(bearer, event, {
223
+ routePrefix,
224
+ // Capability discovery may begin from either the app URL or an
225
+ // already-advertised endpoint URL. Both identify this receiver;
226
+ // direct POST invocation below remains endpoint-bound.
227
+ allowBaseAudience: true,
228
+ });
229
+ if (payload.email) {
230
+ skills = tokenHasAudienceClaim(bearer)
231
+ ? config.authenticatedSkills
232
+ : config.authenticatedSkills.filter((skill) => !isDirectReadSkill(skill));
233
+ }
203
234
  }
204
235
  }
205
236
  return generateAgentCard({ ...config, skills }, baseUrl, `${routePrefix}/a2a`);
@@ -372,17 +403,13 @@ export function mountA2A(nitroApp, config, routePrefix = "/_agent-native") {
372
403
  const hasApiKey = !!(config.apiKeyEnv && process.env[config.apiKeyEnv]);
373
404
  // Try JWT verification first (org-level or global A2A_SECRET-based identity)
374
405
  if (bearerToken) {
375
- const tokenPayload = await verifyA2AToken(bearerToken, event);
406
+ const tokenPayload = await verifyA2AToken(bearerToken, event, {
407
+ routePrefix,
408
+ });
376
409
  verifiedCallerEmail = tokenPayload.email;
377
410
  verifiedOrgDomain = tokenPayload.orgDomain;
378
411
  if (verifiedCallerEmail) {
379
- try {
380
- verifiedAudienceBound =
381
- typeof jose.decodeJwt(bearerToken).aud !== "undefined";
382
- }
383
- catch {
384
- verifiedAudienceBound = false;
385
- }
412
+ verifiedAudienceBound = tokenHasAudienceClaim(bearerToken);
386
413
  }
387
414
  bearerTokenRejectedByJwt = !verifiedCallerEmail;
388
415
  }