@stigmer/react 3.1.12 → 3.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/AgentDetailView.d.ts +11 -1
- package/agent/AgentDetailView.d.ts.map +1 -1
- package/agent/AgentDetailView.js +17 -4
- package/agent/AgentDetailView.js.map +1 -1
- package/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/package.json +4 -4
- package/sharing/ShareAgentDialog.d.ts +11 -1
- package/sharing/ShareAgentDialog.d.ts.map +1 -1
- package/sharing/ShareAgentDialog.js +20 -11
- package/sharing/ShareAgentDialog.js.map +1 -1
- package/sharing/index.d.ts +2 -0
- package/sharing/index.d.ts.map +1 -1
- package/sharing/index.js +1 -0
- package/sharing/index.js.map +1 -1
- package/sharing/useAgentShare.d.ts +8 -2
- package/sharing/useAgentShare.d.ts.map +1 -1
- package/sharing/useAgentShare.js +23 -13
- package/sharing/useAgentShare.js.map +1 -1
- package/sharing/useCreateExternalShareLink.d.ts +90 -0
- package/sharing/useCreateExternalShareLink.d.ts.map +1 -0
- package/sharing/useCreateExternalShareLink.js +65 -0
- package/sharing/useCreateExternalShareLink.js.map +1 -0
- package/sharing/useSaveAgentShare.d.ts +7 -1
- package/sharing/useSaveAgentShare.d.ts.map +1 -1
- package/sharing/useSaveAgentShare.js +12 -5
- package/sharing/useSaveAgentShare.js.map +1 -1
- package/src/agent/AgentDetailView.tsx +28 -2
- package/src/index.ts +3 -0
- package/src/sharing/ShareAgentDialog.tsx +43 -11
- package/src/sharing/__tests__/ShareAgentDialog.test.tsx +48 -0
- package/src/sharing/__tests__/useAgentShare.test.tsx +61 -0
- package/src/sharing/__tests__/useCreateExternalShareLink.test.tsx +194 -0
- package/src/sharing/__tests__/useSaveAgentShare.test.tsx +48 -0
- package/src/sharing/index.ts +5 -0
- package/src/sharing/useAgentShare.ts +26 -12
- package/src/sharing/useCreateExternalShareLink.tsx +141 -0
- package/src/sharing/useSaveAgentShare.ts +12 -4
|
@@ -65,6 +65,16 @@ export interface ShareAgentDialogProps {
|
|
|
65
65
|
* the agent data hook's `refetch`.
|
|
66
66
|
*/
|
|
67
67
|
readonly onSharingChanged?: () => void;
|
|
68
|
+
/**
|
|
69
|
+
* The org that owns (and pays for) the share. Defaults to the agent's
|
|
70
|
+
* own org — the owner's channel. Pass the viewer's org to manage a
|
|
71
|
+
* **cross-org share** of another org's marketplace-public agent
|
|
72
|
+
* (decision 013): the share, its billing, its credentials, and its
|
|
73
|
+
* hosted URL all belong to this org, while the agent stays live in its
|
|
74
|
+
* own. Cross-org shares are public-audience only, so the audience
|
|
75
|
+
* selector is hidden in that mode.
|
|
76
|
+
*/
|
|
77
|
+
readonly shareOrg?: string;
|
|
68
78
|
/**
|
|
69
79
|
* When `false`, renders as an in-flow open dialog instead of a
|
|
70
80
|
* top-layer modal — no `showModal()`, no backdrop, no focus trap.
|
|
@@ -106,6 +116,7 @@ export function ShareAgentDialog({
|
|
|
106
116
|
agent,
|
|
107
117
|
buildShareUrl,
|
|
108
118
|
onSharingChanged,
|
|
119
|
+
shareOrg,
|
|
109
120
|
modal = true,
|
|
110
121
|
}: ShareAgentDialogProps) {
|
|
111
122
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
@@ -150,6 +161,7 @@ export function ShareAgentDialog({
|
|
|
150
161
|
agent={agent}
|
|
151
162
|
buildShareUrl={buildShareUrl}
|
|
152
163
|
onSharingChanged={onSharingChanged}
|
|
164
|
+
shareOrg={shareOrg}
|
|
153
165
|
onClose={handleClose}
|
|
154
166
|
/>
|
|
155
167
|
)}
|
|
@@ -171,14 +183,18 @@ function ShareAgentDialogBody({
|
|
|
171
183
|
agent,
|
|
172
184
|
buildShareUrl,
|
|
173
185
|
onSharingChanged,
|
|
186
|
+
shareOrg,
|
|
174
187
|
onClose,
|
|
175
188
|
}: {
|
|
176
189
|
readonly agent: Agent;
|
|
177
190
|
readonly buildShareUrl?: (org: string, slug: string) => string;
|
|
178
191
|
readonly onSharingChanged?: () => void;
|
|
192
|
+
readonly shareOrg?: string;
|
|
179
193
|
readonly onClose: () => void;
|
|
180
194
|
}) {
|
|
181
|
-
const { share, isLoading, error, refetch } = useAgentShare(agent);
|
|
195
|
+
const { share, isLoading, error, refetch } = useAgentShare(agent, shareOrg);
|
|
196
|
+
const isCrossOrg =
|
|
197
|
+
!!shareOrg && shareOrg !== (agent.metadata?.org ?? "");
|
|
182
198
|
|
|
183
199
|
return (
|
|
184
200
|
<div className="flex flex-col">
|
|
@@ -192,7 +208,11 @@ function ShareAgentDialogBody({
|
|
|
192
208
|
Share
|
|
193
209
|
</h2>
|
|
194
210
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
195
|
-
{agent
|
|
211
|
+
{/* Cross-org: qualify the agent so it's clear whose blueprint
|
|
212
|
+
this channel serves — the URL and billing are still yours. */}
|
|
213
|
+
{isCrossOrg
|
|
214
|
+
? `${agent.metadata?.org}/${agent.metadata?.slug}`
|
|
215
|
+
: agent.metadata?.name || agent.metadata?.slug}
|
|
196
216
|
</p>
|
|
197
217
|
</div>
|
|
198
218
|
<button
|
|
@@ -244,6 +264,7 @@ function ShareAgentDialogBody({
|
|
|
244
264
|
initialShare={share}
|
|
245
265
|
buildShareUrl={buildShareUrl}
|
|
246
266
|
onSharingChanged={onSharingChanged}
|
|
267
|
+
shareOrg={shareOrg}
|
|
247
268
|
/>
|
|
248
269
|
)}
|
|
249
270
|
|
|
@@ -298,11 +319,13 @@ function ShareAgentForm({
|
|
|
298
319
|
initialShare,
|
|
299
320
|
buildShareUrl,
|
|
300
321
|
onSharingChanged,
|
|
322
|
+
shareOrg,
|
|
301
323
|
}: {
|
|
302
324
|
readonly agent: Agent;
|
|
303
325
|
readonly initialShare: AgentShare | null;
|
|
304
326
|
readonly buildShareUrl?: (org: string, slug: string) => string;
|
|
305
327
|
readonly onSharingChanged?: () => void;
|
|
328
|
+
readonly shareOrg?: string;
|
|
306
329
|
}) {
|
|
307
330
|
const agentName = agent.metadata?.name || (agent.metadata?.slug ?? "");
|
|
308
331
|
|
|
@@ -315,17 +338,24 @@ function ShareAgentForm({
|
|
|
315
338
|
);
|
|
316
339
|
const [activeTab, setActiveTab] = useState("link");
|
|
317
340
|
|
|
318
|
-
const { save, isPending } = useSaveAgentShare(agent);
|
|
341
|
+
const { save, isPending } = useSaveAgentShare(agent, shareOrg);
|
|
319
342
|
const { rotateShareLink, isPending: isRotating } = useRotateShareLink(
|
|
320
343
|
share?.metadata?.id ?? null,
|
|
321
344
|
);
|
|
322
345
|
|
|
323
346
|
// The share's own org/slug form the hosted URL. Before the first save
|
|
324
|
-
// the agent's stand in — exactly the identity
|
|
325
|
-
// on create (D2: share slug defaults to the
|
|
326
|
-
|
|
347
|
+
// the sharing org + the agent's slug stand in — exactly the identity
|
|
348
|
+
// the server will assign on create (D2: share slug defaults to the
|
|
349
|
+
// agent's; the org is the channel owner's, which for a cross-org share
|
|
350
|
+
// is the viewer's org, not the agent's — decision 013).
|
|
351
|
+
const org =
|
|
352
|
+
share?.metadata?.org || shareOrg || (agent.metadata?.org ?? "");
|
|
327
353
|
const slug = share?.metadata?.slug || (agent.metadata?.slug ?? "");
|
|
328
354
|
const linkToken = share?.status?.shareLinkToken ?? "";
|
|
355
|
+
// Cross-org shares are public-audience only (decision 013 D3) — the
|
|
356
|
+
// audience selector disappears rather than offering a choice the
|
|
357
|
+
// server would refuse.
|
|
358
|
+
const isCrossOrg = org !== (agent.metadata?.org ?? "");
|
|
329
359
|
|
|
330
360
|
// Single commit path: apply the complete draft, adopt the server's
|
|
331
361
|
// returned share as the new baseline, notify the host.
|
|
@@ -431,11 +461,13 @@ function ShareAgentForm({
|
|
|
431
461
|
aria-labelledby="share-enabled-label"
|
|
432
462
|
/>
|
|
433
463
|
</div>
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
464
|
+
{!isCrossOrg && (
|
|
465
|
+
<AudienceSelector
|
|
466
|
+
audience={draft.audience}
|
|
467
|
+
onChange={handleAudienceChange}
|
|
468
|
+
disabled={isPending}
|
|
469
|
+
/>
|
|
470
|
+
)}
|
|
439
471
|
<ToolReadinessHint agent={agent} draft={draft} />
|
|
440
472
|
</div>
|
|
441
473
|
|
|
@@ -464,6 +464,54 @@ describe("ShareAgentDialog", () => {
|
|
|
464
464
|
});
|
|
465
465
|
});
|
|
466
466
|
|
|
467
|
+
describe("cross-org mode (shareOrg — decision 013)", () => {
|
|
468
|
+
it("qualifies the agent in the header and hides the audience selector", async () => {
|
|
469
|
+
// No share yet in the consumer org: the owner's share exists but
|
|
470
|
+
// belongs to acme, so the consumer's dialog starts never-shared.
|
|
471
|
+
const client = createMockStigmer({
|
|
472
|
+
getByAgent: withShare(makeShare({ enabled: true })),
|
|
473
|
+
});
|
|
474
|
+
await renderOpenDialog(client, { shareOrg: "consumer-org" });
|
|
475
|
+
|
|
476
|
+
// The header names whose blueprint this channel serves.
|
|
477
|
+
expect(screen.getByText("acme/support-agent")).toBeTruthy();
|
|
478
|
+
// Cross-org shares are public-audience only — no choice to offer.
|
|
479
|
+
expect(screen.queryByRole("radiogroup", { hidden: true })).toBeNull();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("builds the link, billing line, and create identity from the sharing org", async () => {
|
|
483
|
+
const apply = vi.fn().mockResolvedValue({});
|
|
484
|
+
const client = createMockStigmer({ apply });
|
|
485
|
+
await renderOpenDialog(client, { shareOrg: "consumer-org" });
|
|
486
|
+
|
|
487
|
+
// The hosted URL lives in the sharing org's namespace even before
|
|
488
|
+
// the first save.
|
|
489
|
+
expect(
|
|
490
|
+
screen.getByText("https://app.example.com/chat/consumer-org/support-agent"),
|
|
491
|
+
).toBeTruthy();
|
|
492
|
+
// Who-pays names the sharing org, not the agent's.
|
|
493
|
+
expect(screen.getByText("consumer-org")).toBeTruthy();
|
|
494
|
+
|
|
495
|
+
fireEvent.click(screen.getByRole("switch", { hidden: true }));
|
|
496
|
+
|
|
497
|
+
await waitFor(() => expect(apply).toHaveBeenCalledTimes(1));
|
|
498
|
+
const input = apply.mock.calls[0][0] as AgentShareInput;
|
|
499
|
+
expect(input.org).toBe("consumer-org");
|
|
500
|
+
expect(input.agentRef).toEqual({ org: "acme", slug: "support-agent" });
|
|
501
|
+
expect(input.audience).toBe(AgentShareAudience.public);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("same-org dialogs are unchanged when shareOrg equals the agent's org", async () => {
|
|
505
|
+
const client = createMockStigmer({
|
|
506
|
+
getByAgent: withShare(makeShare({ enabled: true })),
|
|
507
|
+
});
|
|
508
|
+
await renderOpenDialog(client, { shareOrg: "acme" });
|
|
509
|
+
|
|
510
|
+
expect(screen.getByText("Support Agent")).toBeTruthy();
|
|
511
|
+
expect(screen.getByRole("radiogroup", { hidden: true })).toBeTruthy();
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
467
515
|
it("enabling applies the complete spec and notifies the host", async () => {
|
|
468
516
|
const apply = vi.fn().mockResolvedValue({});
|
|
469
517
|
const onSharingChanged = vi.fn();
|
|
@@ -132,6 +132,67 @@ describe("useAgentShare", () => {
|
|
|
132
132
|
expect(getByAgent).not.toHaveBeenCalled();
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
describe("cross-org share resolution (shareOrg — decision 013)", () => {
|
|
136
|
+
it("scopes the canonical pick to the sharing org's channel", async () => {
|
|
137
|
+
// The same agent shared in two orgs: the owner's share AND another
|
|
138
|
+
// org's external share. Each org's dialog must resolve its own row.
|
|
139
|
+
const ownerShare = makeShare("support-agent");
|
|
140
|
+
const externalShare = {
|
|
141
|
+
metadata: { id: "ash_ext", org: "consumer-org", slug: "support-agent" },
|
|
142
|
+
spec: { enabled: true },
|
|
143
|
+
};
|
|
144
|
+
const getByAgent = vi
|
|
145
|
+
.fn()
|
|
146
|
+
.mockResolvedValue({ totalCount: 2, items: [ownerShare, externalShare] });
|
|
147
|
+
const client = createMockStigmer({ getByAgent });
|
|
148
|
+
|
|
149
|
+
const { result } = renderHook(
|
|
150
|
+
() => useAgentShare(AGENT, "consumer-org"),
|
|
151
|
+
{ wrapper: wrapper(client) },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
155
|
+
expect(result.current.share).toBe(externalShare);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("resolves null when only OTHER orgs' shares exist — never edits a foreign channel", async () => {
|
|
159
|
+
const ownerShare = makeShare("support-agent");
|
|
160
|
+
const getByAgent = vi
|
|
161
|
+
.fn()
|
|
162
|
+
.mockResolvedValue({ totalCount: 1, items: [ownerShare] });
|
|
163
|
+
const client = createMockStigmer({ getByAgent });
|
|
164
|
+
|
|
165
|
+
const { result } = renderHook(
|
|
166
|
+
() => useAgentShare(AGENT, "consumer-org"),
|
|
167
|
+
{ wrapper: wrapper(client) },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
171
|
+
// Without the org filter this would fall back to the owner's share
|
|
172
|
+
// and a save would overwrite the wrong org's configuration.
|
|
173
|
+
expect(result.current.share).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("defaults shareOrg to the agent's own org (Phase A behavior unchanged)", async () => {
|
|
177
|
+
const ownerShare = makeShare("support-agent");
|
|
178
|
+
const externalShare = {
|
|
179
|
+
metadata: { id: "ash_ext", org: "consumer-org", slug: "support-agent" },
|
|
180
|
+
spec: { enabled: true },
|
|
181
|
+
};
|
|
182
|
+
const getByAgent = vi
|
|
183
|
+
.fn()
|
|
184
|
+
.mockResolvedValue({ totalCount: 2, items: [externalShare, ownerShare] });
|
|
185
|
+
const client = createMockStigmer({ getByAgent });
|
|
186
|
+
|
|
187
|
+
const { result } = renderHook(() => useAgentShare(AGENT), {
|
|
188
|
+
wrapper: wrapper(client),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
192
|
+
expect(result.current.share).toBe(ownerShare);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
135
196
|
it("exposes fetch failures as errors", async () => {
|
|
136
197
|
const getByAgent = vi
|
|
137
198
|
.fn()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
2
|
+
import { renderHook, waitFor, cleanup } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
5
|
+
import { StigmerContext } from "../../context";
|
|
6
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
7
|
+
import { useCreateExternalShareLink } from "../useCreateExternalShareLink";
|
|
8
|
+
|
|
9
|
+
// happy-dom does not implement the native dialog show/close methods.
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
HTMLDialogElement.prototype.showModal = function showModal() {
|
|
12
|
+
this.open = true;
|
|
13
|
+
};
|
|
14
|
+
HTMLDialogElement.prototype.close = function close() {
|
|
15
|
+
this.open = false;
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(cleanup);
|
|
20
|
+
|
|
21
|
+
function createMockStigmer(overrides: {
|
|
22
|
+
isAuthorized?: boolean;
|
|
23
|
+
checkMyPermission?: (...args: unknown[]) => Promise<unknown>;
|
|
24
|
+
} = {}) {
|
|
25
|
+
return {
|
|
26
|
+
iamPolicy: {
|
|
27
|
+
checkMyPermission:
|
|
28
|
+
overrides.checkMyPermission ??
|
|
29
|
+
vi.fn().mockResolvedValue({
|
|
30
|
+
isAuthorized: overrides.isAuthorized ?? true,
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
agentShare: {
|
|
34
|
+
getByAgent: vi.fn().mockResolvedValue({ totalCount: 0, items: [] }),
|
|
35
|
+
apply: vi.fn().mockResolvedValue({}),
|
|
36
|
+
rotateShareLink: vi.fn().mockResolvedValue({}),
|
|
37
|
+
},
|
|
38
|
+
billing: { getOrCreateBillingAccount: vi.fn().mockResolvedValue(null) },
|
|
39
|
+
} as never;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function wrapper(client: unknown) {
|
|
43
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
44
|
+
return (
|
|
45
|
+
<FetchCacheContext.Provider value={null}>
|
|
46
|
+
<StigmerContext.Provider value={client as never}>
|
|
47
|
+
{children}
|
|
48
|
+
</StigmerContext.Provider>
|
|
49
|
+
</FetchCacheContext.Provider>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeAgent(visibility: ApiResourceVisibility) {
|
|
55
|
+
return {
|
|
56
|
+
metadata: {
|
|
57
|
+
id: "agt_1",
|
|
58
|
+
org: "provider-org",
|
|
59
|
+
slug: "public-helper",
|
|
60
|
+
name: "Public Helper",
|
|
61
|
+
visibility,
|
|
62
|
+
},
|
|
63
|
+
spec: {},
|
|
64
|
+
} as never;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PUBLIC_AGENT = makeAgent(ApiResourceVisibility.visibility_public);
|
|
68
|
+
const PRIVATE_AGENT = makeAgent(ApiResourceVisibility.visibility_private);
|
|
69
|
+
|
|
70
|
+
describe("useCreateExternalShareLink", () => {
|
|
71
|
+
it("offers the action on another org's public agent when the viewer holds the org bar", async () => {
|
|
72
|
+
const { result } = renderHook(
|
|
73
|
+
() =>
|
|
74
|
+
useCreateExternalShareLink({
|
|
75
|
+
agent: PUBLIC_AGENT,
|
|
76
|
+
viewerOrg: "consumer-org",
|
|
77
|
+
}),
|
|
78
|
+
{ wrapper: wrapper(createMockStigmer({ isAuthorized: true })) },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await waitFor(() => expect(result.current.action).not.toBeNull());
|
|
82
|
+
expect(result.current.action?.id).toBe("create-share-link");
|
|
83
|
+
expect(result.current.action?.label).toBe("Create share link");
|
|
84
|
+
expect(result.current.action?.group).toBe("sharing");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("checks can_create_agent_share on the VIEWER's org (id = slug)", async () => {
|
|
88
|
+
const client = createMockStigmer({ isAuthorized: true });
|
|
89
|
+
renderHook(
|
|
90
|
+
() =>
|
|
91
|
+
useCreateExternalShareLink({
|
|
92
|
+
agent: PUBLIC_AGENT,
|
|
93
|
+
viewerOrg: "consumer-org",
|
|
94
|
+
}),
|
|
95
|
+
{ wrapper: wrapper(client) },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const check = (
|
|
99
|
+
client as { iamPolicy: { checkMyPermission: ReturnType<typeof vi.fn> } }
|
|
100
|
+
).iamPolicy.checkMyPermission;
|
|
101
|
+
await waitFor(() => expect(check).toHaveBeenCalled());
|
|
102
|
+
const input = check.mock.calls[0][0] as {
|
|
103
|
+
resource?: { kind: string; id: string };
|
|
104
|
+
relation: string;
|
|
105
|
+
};
|
|
106
|
+
expect(input.relation).toBe("can_create_agent_share");
|
|
107
|
+
expect(input.resource?.kind).toBe("organization");
|
|
108
|
+
expect(input.resource?.id).toBe("consumer-org");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("stays null on the viewer's OWN agents — the same-org Share entry covers those", () => {
|
|
112
|
+
const checkMyPermission = vi.fn();
|
|
113
|
+
const { result } = renderHook(
|
|
114
|
+
() =>
|
|
115
|
+
useCreateExternalShareLink({
|
|
116
|
+
agent: PUBLIC_AGENT,
|
|
117
|
+
viewerOrg: "provider-org",
|
|
118
|
+
}),
|
|
119
|
+
{ wrapper: wrapper(createMockStigmer({ checkMyPermission })) },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(result.current.action).toBeNull();
|
|
123
|
+
expect(result.current.dialog).toBeNull();
|
|
124
|
+
// Not applicable structurally: no permission round-trip is spent.
|
|
125
|
+
expect(checkMyPermission).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("stays null on a non-public agent — visibility is the origin org's consent", () => {
|
|
129
|
+
const checkMyPermission = vi.fn();
|
|
130
|
+
const { result } = renderHook(
|
|
131
|
+
() =>
|
|
132
|
+
useCreateExternalShareLink({
|
|
133
|
+
agent: PRIVATE_AGENT,
|
|
134
|
+
viewerOrg: "consumer-org",
|
|
135
|
+
}),
|
|
136
|
+
{ wrapper: wrapper(createMockStigmer({ checkMyPermission })) },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(result.current.action).toBeNull();
|
|
140
|
+
expect(checkMyPermission).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("stays null while the agent is loading or the viewer org is unknown", () => {
|
|
144
|
+
const client = createMockStigmer();
|
|
145
|
+
const { result: loading } = renderHook(
|
|
146
|
+
() => useCreateExternalShareLink({ agent: null, viewerOrg: "consumer-org" }),
|
|
147
|
+
{ wrapper: wrapper(client) },
|
|
148
|
+
);
|
|
149
|
+
const { result: orgless } = renderHook(
|
|
150
|
+
() => useCreateExternalShareLink({ agent: PUBLIC_AGENT, viewerOrg: "" }),
|
|
151
|
+
{ wrapper: wrapper(client) },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(loading.current.action).toBeNull();
|
|
155
|
+
expect(orgless.current.action).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("hides the action when the viewer lacks the org-side permission", async () => {
|
|
159
|
+
const client = createMockStigmer({ isAuthorized: false });
|
|
160
|
+
const { result } = renderHook(
|
|
161
|
+
() =>
|
|
162
|
+
useCreateExternalShareLink({
|
|
163
|
+
agent: PUBLIC_AGENT,
|
|
164
|
+
viewerOrg: "consumer-org",
|
|
165
|
+
}),
|
|
166
|
+
{ wrapper: wrapper(client) },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await waitFor(() =>
|
|
170
|
+
expect(
|
|
171
|
+
(client as { iamPolicy: { checkMyPermission: ReturnType<typeof vi.fn> } })
|
|
172
|
+
.iamPolicy.checkMyPermission,
|
|
173
|
+
).toHaveBeenCalled(),
|
|
174
|
+
);
|
|
175
|
+
await waitFor(() => expect(result.current.action).toBeNull());
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("opens the cross-org dialog via the action", async () => {
|
|
179
|
+
const { result } = renderHook(
|
|
180
|
+
() =>
|
|
181
|
+
useCreateExternalShareLink({
|
|
182
|
+
agent: PUBLIC_AGENT,
|
|
183
|
+
viewerOrg: "consumer-org",
|
|
184
|
+
}),
|
|
185
|
+
{ wrapper: wrapper(createMockStigmer({ isAuthorized: true })) },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await waitFor(() => expect(result.current.action).not.toBeNull());
|
|
189
|
+
expect(result.current.isOpen).toBe(false);
|
|
190
|
+
|
|
191
|
+
result.current.action?.onAction();
|
|
192
|
+
await waitFor(() => expect(result.current.isOpen).toBe(true));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -163,6 +163,54 @@ describe("useSaveAgentShare", () => {
|
|
|
163
163
|
expect(second.audience).toBe(AgentShareAudience.org);
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
describe("cross-org create identity (shareOrg — decision 013)", () => {
|
|
167
|
+
it("a first save lands the share in the sharing org, agent_ref stays the agent's", async () => {
|
|
168
|
+
const apply = vi.fn().mockResolvedValue({});
|
|
169
|
+
const client = createMockStigmer({ apply });
|
|
170
|
+
|
|
171
|
+
const { result } = renderHook(
|
|
172
|
+
() => useSaveAgentShare(AGENT, "consumer-org"),
|
|
173
|
+
{ wrapper: wrapper(client) },
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await act(() => result.current.save(FULL_DRAFT, null));
|
|
177
|
+
|
|
178
|
+
const input = apply.mock.calls[0][0] as AgentShareInput;
|
|
179
|
+
// The share is the sharing org's resource (its URL, billing, and
|
|
180
|
+
// credentials), while agent_ref keeps pointing at the provider's
|
|
181
|
+
// blueprint — the whole point of a cross-org share.
|
|
182
|
+
expect(input.org).toBe("consumer-org");
|
|
183
|
+
expect(input.slug).toBe("support-agent");
|
|
184
|
+
expect(input.agentRef).toEqual({ org: "acme", slug: "support-agent" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("editing an existing cross-org share keeps ITS identity, not the hook argument's", async () => {
|
|
188
|
+
const apply = vi.fn().mockResolvedValue({});
|
|
189
|
+
const client = createMockStigmer({ apply });
|
|
190
|
+
const externalShare = {
|
|
191
|
+
metadata: {
|
|
192
|
+
id: "ash_ext",
|
|
193
|
+
org: "consumer-org",
|
|
194
|
+
slug: "renamed-channel",
|
|
195
|
+
name: "Renamed Channel",
|
|
196
|
+
},
|
|
197
|
+
spec: { enabled: true },
|
|
198
|
+
} as AgentShare;
|
|
199
|
+
|
|
200
|
+
const { result } = renderHook(
|
|
201
|
+
() => useSaveAgentShare(AGENT, "consumer-org"),
|
|
202
|
+
{ wrapper: wrapper(client) },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await act(() => result.current.save(FULL_DRAFT, externalShare));
|
|
206
|
+
|
|
207
|
+
const input = apply.mock.calls[0][0] as AgentShareInput;
|
|
208
|
+
expect(input.org).toBe("consumer-org");
|
|
209
|
+
expect(input.slug).toBe("renamed-channel");
|
|
210
|
+
expect(input.agentRef).toEqual({ org: "acme", slug: "support-agent" });
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
166
214
|
it("is a stable no-op when the agent is null", async () => {
|
|
167
215
|
const apply = vi.fn();
|
|
168
216
|
const client = createMockStigmer({ apply });
|
package/src/sharing/index.ts
CHANGED
|
@@ -25,6 +25,11 @@ export type {
|
|
|
25
25
|
UseShareAgentArgs,
|
|
26
26
|
UseShareAgentReturn,
|
|
27
27
|
} from "./useShareAgent.js";
|
|
28
|
+
export { useCreateExternalShareLink } from "./useCreateExternalShareLink.js";
|
|
29
|
+
export type {
|
|
30
|
+
UseCreateExternalShareLinkArgs,
|
|
31
|
+
UseCreateExternalShareLinkReturn,
|
|
32
|
+
} from "./useCreateExternalShareLink.js";
|
|
28
33
|
export { useShareToolReadiness } from "./useShareToolReadiness.js";
|
|
29
34
|
export type { ShareToolReadiness } from "./useShareToolReadiness.js";
|
|
30
35
|
// Origin validation moved to @stigmer/sdk (framework-free, shared with the
|
|
@@ -26,20 +26,24 @@ export interface UseAgentShareReturn {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* The canonical share among an agent's shares
|
|
30
|
-
* the agent's slug (the server's default when a
|
|
31
|
-
* an explicit slug), falling back to the first
|
|
32
|
-
* allows N shares per agent
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* The canonical share among an agent's shares **within one sharing org**:
|
|
30
|
+
* the one whose slug equals the agent's slug (the server's default when a
|
|
31
|
+
* share is created without an explicit slug), falling back to the first
|
|
32
|
+
* entry in that org. The data model allows N shares per agent across N
|
|
33
|
+
* orgs (decision 011 D3 + decision 013), so the org filter is what keeps
|
|
34
|
+
* each org's dialog on its own channel — without it, an owner who can
|
|
35
|
+
* also see another org's share of the same agent would edit the wrong
|
|
36
|
+
* one. Extra shares created via manifests never confuse the dialog.
|
|
35
37
|
*/
|
|
36
38
|
function pickCanonicalShare(
|
|
37
39
|
shares: readonly AgentShare[],
|
|
40
|
+
shareOrg: string,
|
|
38
41
|
agentSlug: string,
|
|
39
42
|
): AgentShare | null {
|
|
43
|
+
const inOrg = shares.filter((share) => share.metadata?.org === shareOrg);
|
|
40
44
|
return (
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
inOrg.find((share) => share.metadata?.slug === agentSlug) ??
|
|
46
|
+
inOrg[0] ??
|
|
43
47
|
null
|
|
44
48
|
);
|
|
45
49
|
}
|
|
@@ -54,9 +58,15 @@ function pickCanonicalShare(
|
|
|
54
58
|
* tell whether it is shared. This hook is how owner-side surfaces (the
|
|
55
59
|
* Share dialog) resolve that state.
|
|
56
60
|
*
|
|
61
|
+
* `shareOrg` scopes resolution to one sharing org's channel and defaults
|
|
62
|
+
* to the agent's own org (the owner's share). Pass the viewer's org to
|
|
63
|
+
* manage a **cross-org share** — the viewer's own channel of another
|
|
64
|
+
* org's marketplace-public agent (decision 013).
|
|
65
|
+
*
|
|
57
66
|
* Pass `null` for `agent` to skip fetching (stable no-op) — useful
|
|
58
67
|
* while the agent is still loading. A resolved `null` share means the
|
|
59
|
-
* agent has never been shared
|
|
68
|
+
* agent has never been shared in `shareOrg`; the first save creates the
|
|
69
|
+
* share.
|
|
60
70
|
*
|
|
61
71
|
* @example
|
|
62
72
|
* ```tsx
|
|
@@ -66,24 +76,28 @@ function pickCanonicalShare(
|
|
|
66
76
|
* const enabled = share?.spec?.enabled ?? false;
|
|
67
77
|
* ```
|
|
68
78
|
*/
|
|
69
|
-
export function useAgentShare(
|
|
79
|
+
export function useAgentShare(
|
|
80
|
+
agent: Agent | null,
|
|
81
|
+
shareOrg?: string,
|
|
82
|
+
): UseAgentShareReturn {
|
|
70
83
|
const stigmer = useStigmer();
|
|
71
84
|
|
|
72
85
|
const agentId = agent?.metadata?.id ?? "";
|
|
73
86
|
const agentSlug = agent?.metadata?.slug ?? "";
|
|
87
|
+
const resolvedShareOrg = shareOrg || (agent?.metadata?.org ?? "");
|
|
74
88
|
|
|
75
89
|
const fetchFn = agentId
|
|
76
90
|
? async () => {
|
|
77
91
|
const result = await stigmer.agentShare.getByAgent(
|
|
78
92
|
create(GetAgentSharesByAgentRequestSchema, { agentId }),
|
|
79
93
|
);
|
|
80
|
-
return pickCanonicalShare(result.items, agentSlug);
|
|
94
|
+
return pickCanonicalShare(result.items, resolvedShareOrg, agentSlug);
|
|
81
95
|
}
|
|
82
96
|
: null;
|
|
83
97
|
|
|
84
98
|
const { data: share, isLoading, isRefetching, error, refetch } = useFetch(
|
|
85
99
|
fetchFn,
|
|
86
|
-
[agentId, agentSlug, stigmer],
|
|
100
|
+
[agentId, agentSlug, resolvedShareOrg, stigmer],
|
|
87
101
|
null as AgentShare | null,
|
|
88
102
|
);
|
|
89
103
|
|