@stigmer/react 3.14.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +5 -2
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/useChannelTemplateReadiness.d.ts.map +1 -1
- package/channel/useChannelTemplateReadiness.js +2 -0
- package/channel/useChannelTemplateReadiness.js.map +1 -1
- package/deployment-mode.d.ts +12 -6
- package/deployment-mode.d.ts.map +1 -1
- package/deployment-mode.js +12 -6
- package/deployment-mode.js.map +1 -1
- package/identity-account/AccountPreferencesPanel.d.ts +6 -4
- package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
- package/identity-account/AccountPreferencesPanel.js +5 -16
- package/identity-account/AccountPreferencesPanel.js.map +1 -1
- package/identity-account/useAccountExecutionDefaults.d.ts +6 -4
- package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
- package/identity-account/useAccountExecutionDefaults.js +7 -7
- package/identity-account/useAccountExecutionDefaults.js.map +1 -1
- package/identity-account/useIdentityAccountGate.d.ts +9 -3
- package/identity-account/useIdentityAccountGate.d.ts.map +1 -1
- package/identity-account/useIdentityAccountGate.js +21 -22
- package/identity-account/useIdentityAccountGate.js.map +1 -1
- package/identity-account/useMyIdentityAccount.d.ts +8 -7
- package/identity-account/useMyIdentityAccount.d.ts.map +1 -1
- package/identity-account/useMyIdentityAccount.js +5 -4
- package/identity-account/useMyIdentityAccount.js.map +1 -1
- package/index.d.ts +6 -5
- package/index.d.ts.map +1 -1
- package/index.js +7 -4
- package/index.js.map +1 -1
- package/library/ResourceVisibilityControl.d.ts.map +1 -1
- package/library/ResourceVisibilityControl.js +5 -3
- package/library/ResourceVisibilityControl.js.map +1 -1
- package/library/useCanSetPublicVisibility.d.ts +12 -9
- package/library/useCanSetPublicVisibility.d.ts.map +1 -1
- package/library/useCanSetPublicVisibility.js +17 -14
- package/library/useCanSetPublicVisibility.js.map +1 -1
- package/library/visibilityLevels.d.ts +11 -8
- package/library/visibilityLevels.d.ts.map +1 -1
- package/library/visibilityLevels.js +5 -4
- package/library/visibilityLevels.js.map +1 -1
- package/mcp-server/StdioSandboxNotice.d.ts +5 -1
- package/mcp-server/StdioSandboxNotice.d.ts.map +1 -1
- package/mcp-server/StdioSandboxNotice.js +5 -1
- package/mcp-server/StdioSandboxNotice.js.map +1 -1
- package/package.json +4 -4
- package/provider.d.ts +10 -7
- package/provider.d.ts.map +1 -1
- package/provider.js.map +1 -1
- package/sharing/ShareAgentDialog.js +2 -1
- package/sharing/ShareAgentDialog.js.map +1 -1
- package/src/__tests__/deployment-mode.test.tsx +70 -0
- package/src/channel/AgentChannelsPanel.tsx +5 -2
- package/src/channel/useChannelTemplateReadiness.ts +2 -0
- package/src/deployment-mode.ts +12 -6
- package/src/identity-account/AccountPreferencesPanel.tsx +5 -38
- package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +8 -5
- package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +9 -5
- package/src/identity-account/__tests__/useIdentityAccountGate.test.tsx +236 -0
- package/src/identity-account/useAccountExecutionDefaults.ts +7 -7
- package/src/identity-account/useIdentityAccountGate.ts +21 -22
- package/src/identity-account/useMyIdentityAccount.ts +8 -7
- package/src/index.ts +30 -5
- package/src/library/ResourceVisibilityControl.tsx +5 -3
- package/src/library/__tests__/useCanSetPublicVisibility.test.tsx +85 -0
- package/src/library/__tests__/visibilityLevels.test.ts +28 -0
- package/src/library/useCanSetPublicVisibility.ts +17 -14
- package/src/library/visibilityLevels.ts +11 -8
- package/src/mcp-server/StdioSandboxNotice.tsx +5 -1
- package/src/provider.tsx +10 -7
- package/src/sharing/ShareAgentDialog.tsx +2 -1
- package/src/usage/CreditRunwayIndicator.tsx +3 -0
- package/usage/CreditRunwayIndicator.d.ts.map +1 -1
- package/usage/CreditRunwayIndicator.js +3 -0
- package/usage/CreditRunwayIndicator.js.map +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the behaviour of `useIdentityAccountGate` — the console's first-sign-in
|
|
3
|
+
* gate — so the hook can delegate its whoAmI → NOT_FOUND → provisionMyAccount
|
|
4
|
+
* machine to `@stigmer/sdk`'s `ensureMyIdentityAccount` (20260911.11 A3)
|
|
5
|
+
* without changing what any consumer observes. Written green against the
|
|
6
|
+
* hook as it stood before the delegation; it must stay green after.
|
|
7
|
+
*
|
|
8
|
+
* The states and transitions it holds to:
|
|
9
|
+
*
|
|
10
|
+
* checking ─ whoAmI answers ────────────────────────────▶ ready(account)
|
|
11
|
+
* checking ─ whoAmI NOT_FOUND ─▶ provisioning ─ answers ─▶ ready(account)
|
|
12
|
+
* checking ─ whoAmI other error ────────────────────────▶ error(message)
|
|
13
|
+
* provisioning ─ provisionMyAccount fails ──────────────▶ error(message)
|
|
14
|
+
* error ─ retry() ──────────────────────────────────────▶ checking (again)
|
|
15
|
+
* isEnabled: false ─────────────────────────────────────▶ ready (bypassed)
|
|
16
|
+
*
|
|
17
|
+
* and one invariant a delegation could silently break: a `retry()` issued
|
|
18
|
+
* while an earlier attempt is in flight discards that attempt's outcome,
|
|
19
|
+
* whichever RPC it was waiting on.
|
|
20
|
+
*/
|
|
21
|
+
import { Code } from "@connectrpc/connect";
|
|
22
|
+
import { create } from "@bufbuild/protobuf";
|
|
23
|
+
import {
|
|
24
|
+
IdentityAccountSchema,
|
|
25
|
+
type IdentityAccount,
|
|
26
|
+
} from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
27
|
+
import { StigmerError } from "@stigmer/sdk";
|
|
28
|
+
import {
|
|
29
|
+
act,
|
|
30
|
+
cleanup,
|
|
31
|
+
fireEvent,
|
|
32
|
+
render,
|
|
33
|
+
screen,
|
|
34
|
+
waitFor,
|
|
35
|
+
} from "@testing-library/react";
|
|
36
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
37
|
+
|
|
38
|
+
import { StigmerContext } from "../../context";
|
|
39
|
+
import { useIdentityAccountGate } from "../useIdentityAccountGate";
|
|
40
|
+
|
|
41
|
+
const EXISTING: IdentityAccount = create(IdentityAccountSchema, {
|
|
42
|
+
metadata: { id: "ida_existing" },
|
|
43
|
+
spec: { idpId: "auth0|existing" },
|
|
44
|
+
});
|
|
45
|
+
const CREATED: IdentityAccount = create(IdentityAccountSchema, {
|
|
46
|
+
metadata: { id: "ida_created" },
|
|
47
|
+
spec: { idpId: "auth0|created" },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const notFound = () =>
|
|
51
|
+
new StigmerError(
|
|
52
|
+
"not-found",
|
|
53
|
+
"Identity account not found for the authenticated user",
|
|
54
|
+
Code.NotFound,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** A promise whose settlement the test controls. */
|
|
58
|
+
function deferred<T>() {
|
|
59
|
+
let resolve!: (value: T) => void;
|
|
60
|
+
let reject!: (reason: unknown) => void;
|
|
61
|
+
const promise = new Promise<T>((res, rej) => {
|
|
62
|
+
resolve = res;
|
|
63
|
+
reject = rej;
|
|
64
|
+
});
|
|
65
|
+
return { promise, resolve, reject };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Renders the gate and exposes its state via data attributes; click = retry. */
|
|
69
|
+
function GateProbe({ isEnabled }: { isEnabled: boolean }) {
|
|
70
|
+
const { state, retry } = useIdentityAccountGate({ isEnabled });
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
data-testid="probe"
|
|
75
|
+
data-status={state.status}
|
|
76
|
+
data-account={
|
|
77
|
+
state.status === "ready" ? (state.account?.metadata?.id ?? "") : ""
|
|
78
|
+
}
|
|
79
|
+
data-message={state.status === "error" ? state.message : ""}
|
|
80
|
+
onClick={retry}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withClient(client: unknown, isEnabled = true) {
|
|
86
|
+
return render(
|
|
87
|
+
<StigmerContext.Provider value={client as never}>
|
|
88
|
+
<GateProbe isEnabled={isEnabled} />
|
|
89
|
+
</StigmerContext.Provider>,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const probe = () => screen.getByTestId("probe");
|
|
94
|
+
const status = () => probe().getAttribute("data-status");
|
|
95
|
+
|
|
96
|
+
afterEach(cleanup);
|
|
97
|
+
|
|
98
|
+
describe("useIdentityAccountGate", () => {
|
|
99
|
+
it("checking → ready when whoAmI answers; provisionMyAccount is never called", async () => {
|
|
100
|
+
const whoAmI = vi.fn(async () => EXISTING);
|
|
101
|
+
const provisionMyAccount = vi.fn();
|
|
102
|
+
withClient({ identityAccount: { whoAmI, provisionMyAccount } });
|
|
103
|
+
|
|
104
|
+
expect(status()).toBe("checking");
|
|
105
|
+
await waitFor(() => expect(status()).toBe("ready"));
|
|
106
|
+
expect(probe().getAttribute("data-account")).toBe("ida_existing");
|
|
107
|
+
expect(whoAmI).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(provisionMyAccount).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("checking → provisioning → ready on NOT_FOUND (the first sign-in)", async () => {
|
|
112
|
+
const provision = deferred<IdentityAccount>();
|
|
113
|
+
const whoAmI = vi.fn(async () => {
|
|
114
|
+
throw notFound();
|
|
115
|
+
});
|
|
116
|
+
const provisionMyAccount = vi.fn(() => provision.promise);
|
|
117
|
+
withClient({ identityAccount: { whoAmI, provisionMyAccount } });
|
|
118
|
+
|
|
119
|
+
await waitFor(() => expect(status()).toBe("provisioning"));
|
|
120
|
+
expect(provisionMyAccount).toHaveBeenCalledTimes(1);
|
|
121
|
+
|
|
122
|
+
provision.resolve(CREATED);
|
|
123
|
+
await waitFor(() => expect(status()).toBe("ready"));
|
|
124
|
+
expect(probe().getAttribute("data-account")).toBe("ida_created");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("any other whoAmI error is the error state with its message; no provisioning", async () => {
|
|
128
|
+
const whoAmI = vi.fn(async () => {
|
|
129
|
+
throw new StigmerError(
|
|
130
|
+
"unauthenticated",
|
|
131
|
+
"token expired",
|
|
132
|
+
Code.Unauthenticated,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
const provisionMyAccount = vi.fn();
|
|
136
|
+
withClient({ identityAccount: { whoAmI, provisionMyAccount } });
|
|
137
|
+
|
|
138
|
+
await waitFor(() => expect(status()).toBe("error"));
|
|
139
|
+
expect(probe().getAttribute("data-message")).toBe("token expired");
|
|
140
|
+
expect(provisionMyAccount).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a provisioning failure is the error state with the provisioner's message", async () => {
|
|
144
|
+
const whoAmI = vi.fn(async () => {
|
|
145
|
+
throw notFound();
|
|
146
|
+
});
|
|
147
|
+
const provisionMyAccount = vi.fn(async () => {
|
|
148
|
+
throw new StigmerError(
|
|
149
|
+
"unavailable",
|
|
150
|
+
"userinfo answered 503",
|
|
151
|
+
Code.Unavailable,
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
withClient({ identityAccount: { whoAmI, provisionMyAccount } });
|
|
155
|
+
|
|
156
|
+
await waitFor(() => expect(status()).toBe("error"));
|
|
157
|
+
expect(probe().getAttribute("data-message")).toBe("userinfo answered 503");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("retry() from error restarts at checking and can reach ready", async () => {
|
|
161
|
+
let calls = 0;
|
|
162
|
+
const whoAmI = vi.fn(async () => {
|
|
163
|
+
calls += 1;
|
|
164
|
+
if (calls === 1)
|
|
165
|
+
throw new StigmerError("unavailable", "warming up", Code.Unavailable);
|
|
166
|
+
return EXISTING;
|
|
167
|
+
});
|
|
168
|
+
withClient({ identityAccount: { whoAmI } });
|
|
169
|
+
|
|
170
|
+
await waitFor(() => expect(status()).toBe("error"));
|
|
171
|
+
fireEvent.click(probe());
|
|
172
|
+
await waitFor(() => expect(status()).toBe("ready"));
|
|
173
|
+
expect(probe().getAttribute("data-account")).toBe("ida_existing");
|
|
174
|
+
expect(whoAmI).toHaveBeenCalledTimes(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("a retry while whoAmI is in flight discards the earlier attempt's answer", async () => {
|
|
178
|
+
const first = deferred<IdentityAccount>();
|
|
179
|
+
const second = deferred<IdentityAccount>();
|
|
180
|
+
const answers = [first.promise, second.promise];
|
|
181
|
+
const whoAmI = vi.fn(() => answers.shift()!);
|
|
182
|
+
withClient({ identityAccount: { whoAmI } });
|
|
183
|
+
|
|
184
|
+
await waitFor(() => expect(whoAmI).toHaveBeenCalledTimes(1));
|
|
185
|
+
fireEvent.click(probe());
|
|
186
|
+
await waitFor(() => expect(whoAmI).toHaveBeenCalledTimes(2));
|
|
187
|
+
|
|
188
|
+
// `act` flushes React's work, so a stale setState would be visible here.
|
|
189
|
+
await act(async () => {
|
|
190
|
+
first.resolve(EXISTING);
|
|
191
|
+
});
|
|
192
|
+
expect(status(), "the stale attempt must not settle the gate").toBe(
|
|
193
|
+
"checking",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
second.resolve(CREATED);
|
|
197
|
+
await waitFor(() => expect(status()).toBe("ready"));
|
|
198
|
+
expect(probe().getAttribute("data-account")).toBe("ida_created");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("a retry while provisioning is in flight discards the earlier attempt's outcome", async () => {
|
|
202
|
+
const provision = deferred<IdentityAccount>();
|
|
203
|
+
let whoAmICalls = 0;
|
|
204
|
+
const whoAmI = vi.fn(async () => {
|
|
205
|
+
whoAmICalls += 1;
|
|
206
|
+
if (whoAmICalls === 1) throw notFound();
|
|
207
|
+
return EXISTING;
|
|
208
|
+
});
|
|
209
|
+
const provisionMyAccount = vi.fn(() => provision.promise);
|
|
210
|
+
withClient({ identityAccount: { whoAmI, provisionMyAccount } });
|
|
211
|
+
|
|
212
|
+
await waitFor(() => expect(status()).toBe("provisioning"));
|
|
213
|
+
fireEvent.click(probe());
|
|
214
|
+
await waitFor(() => expect(status()).toBe("ready"));
|
|
215
|
+
expect(probe().getAttribute("data-account")).toBe("ida_existing");
|
|
216
|
+
|
|
217
|
+
// The abandoned provisioning settles late; the gate keeps the second
|
|
218
|
+
// attempt's answer.
|
|
219
|
+
await act(async () => {
|
|
220
|
+
provision.reject(
|
|
221
|
+
new StigmerError("unavailable", "late failure", Code.Unavailable),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
expect(status()).toBe("ready");
|
|
225
|
+
expect(probe().getAttribute("data-account")).toBe("ida_existing");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("isEnabled: false bypasses the gate — ready at once, no RPC", async () => {
|
|
229
|
+
const whoAmI = vi.fn(async () => EXISTING);
|
|
230
|
+
withClient({ identityAccount: { whoAmI } }, false);
|
|
231
|
+
|
|
232
|
+
expect(status()).toBe("ready");
|
|
233
|
+
await act(async () => {});
|
|
234
|
+
expect(whoAmI).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMemo } from "react";
|
|
4
4
|
import { useMyIdentityAccount } from "./useMyIdentityAccount.js";
|
|
5
|
-
import { useResourceAvailable, ApiResourceKind } from "../deployment-mode.js";
|
|
6
5
|
import type { HarnessOption } from "../models/harness.js";
|
|
7
6
|
|
|
8
7
|
/**
|
|
@@ -30,10 +29,12 @@ export interface AccountExecutionDefaults {
|
|
|
30
29
|
* (`IdentityAccountSpec.preferences.default_*`) for seeding new-session
|
|
31
30
|
* composers.
|
|
32
31
|
*
|
|
33
|
-
* Returns `undefined`
|
|
34
|
-
*
|
|
35
|
-
* degrades to the platform's existing defaults, so consumers
|
|
36
|
-
* result straight through:
|
|
32
|
+
* Returns `undefined` while loading, on error (including a server that
|
|
33
|
+
* cannot answer `whoAmI`), and when no default is declared — every one of
|
|
34
|
+
* those cases degrades to the platform's existing defaults, so consumers
|
|
35
|
+
* wire the result straight through. Served in every edition: a
|
|
36
|
+
* trusted-local server answers with the operator account it creates at
|
|
37
|
+
* boot, so a laptop's saved defaults seed the composer too.
|
|
37
38
|
*
|
|
38
39
|
* @example
|
|
39
40
|
* ```tsx
|
|
@@ -47,8 +48,7 @@ export interface AccountExecutionDefaults {
|
|
|
47
48
|
* synchronously on every visit after the first.
|
|
48
49
|
*/
|
|
49
50
|
export function useAccountExecutionDefaults(): AccountExecutionDefaults | undefined {
|
|
50
|
-
const
|
|
51
|
-
const { account } = useMyIdentityAccount({ enabled: available });
|
|
51
|
+
const { account } = useMyIdentityAccount();
|
|
52
52
|
|
|
53
53
|
return useMemo(() => {
|
|
54
54
|
const prefs = account?.spec?.preferences;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
5
|
-
import {
|
|
5
|
+
import { ensureMyIdentityAccount } from "@stigmer/sdk";
|
|
6
6
|
import { useStigmer } from "../hooks.js";
|
|
7
7
|
import { toError } from "../internal/toError.js";
|
|
8
8
|
|
|
@@ -65,12 +65,18 @@ export interface UseIdentityAccountGateReturn {
|
|
|
65
65
|
* Headless behavior hook that ensures the authenticated caller has an
|
|
66
66
|
* identity account before the application renders.
|
|
67
67
|
*
|
|
68
|
+
* The flow itself is `@stigmer/sdk`'s {@link ensureMyIdentityAccount} — the
|
|
69
|
+
* same one the `stigmer` CLI runs on `auth login` and `auth whoami`, and the
|
|
70
|
+
* one a platform builder without React calls directly. This hook adds the
|
|
71
|
+
* React lifecycle around it: a state machine, a `retry`, and the discipline
|
|
72
|
+
* that a retry issued mid-flight discards the earlier attempt's outcome.
|
|
73
|
+
*
|
|
68
74
|
* The hook drives the following lifecycle:
|
|
69
75
|
*
|
|
70
|
-
* 1. **`checking`** —
|
|
76
|
+
* 1. **`checking`** — `whoAmI()` looks up the caller's account.
|
|
71
77
|
* 2. **`provisioning`** — `whoAmI` returned NOT_FOUND (first login after
|
|
72
|
-
* signup);
|
|
73
|
-
*
|
|
78
|
+
* signup); `provisionMyAccount()` creates the account (and, on Stigmer
|
|
79
|
+
* Cloud, the personal organization).
|
|
74
80
|
* 3. **`ready`** — an identity account is available (either pre-existing
|
|
75
81
|
* or freshly provisioned).
|
|
76
82
|
* 4. **`error`** — a non-recoverable failure occurred; `message` carries
|
|
@@ -109,31 +115,24 @@ export function useIdentityAccountGate(options: {
|
|
|
109
115
|
const attemptRef = useRef(0);
|
|
110
116
|
|
|
111
117
|
const resolve = useCallback(async () => {
|
|
118
|
+
// Every setState below is guarded by `attempt`: a retry() issued while
|
|
119
|
+
// this attempt is in flight bumps the counter, and this attempt's
|
|
120
|
+
// outcome — whichever RPC it was waiting on — is discarded.
|
|
112
121
|
const attempt = ++attemptRef.current;
|
|
113
122
|
setState({ status: "checking" });
|
|
114
123
|
|
|
115
124
|
try {
|
|
116
|
-
const account = await stigmer
|
|
125
|
+
const { account } = await ensureMyIdentityAccount(stigmer, {
|
|
126
|
+
onProvisioning: () => {
|
|
127
|
+
if (attempt === attemptRef.current)
|
|
128
|
+
setState({ status: "provisioning" });
|
|
129
|
+
},
|
|
130
|
+
});
|
|
117
131
|
if (attempt !== attemptRef.current) return;
|
|
118
132
|
setState({ status: "ready", account });
|
|
119
|
-
} catch (
|
|
133
|
+
} catch (err: unknown) {
|
|
120
134
|
if (attempt !== attemptRef.current) return;
|
|
121
|
-
|
|
122
|
-
if (!isNotFound(whoAmIErr)) {
|
|
123
|
-
setState({ status: "error", message: toError(whoAmIErr).message });
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
setState({ status: "provisioning" });
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
const account = await stigmer.identityAccount.provisionMyAccount();
|
|
131
|
-
if (attempt !== attemptRef.current) return;
|
|
132
|
-
setState({ status: "ready", account });
|
|
133
|
-
} catch (provisionErr: unknown) {
|
|
134
|
-
if (attempt !== attemptRef.current) return;
|
|
135
|
-
setState({ status: "error", message: toError(provisionErr).message });
|
|
136
|
-
}
|
|
135
|
+
setState({ status: "error", message: toError(err).message });
|
|
137
136
|
}
|
|
138
137
|
}, [stigmer]);
|
|
139
138
|
|
|
@@ -8,9 +8,9 @@ import { useFetch } from "../internal/useFetch.js";
|
|
|
8
8
|
export interface UseMyIdentityAccountOptions {
|
|
9
9
|
/**
|
|
10
10
|
* When `false`, the hook is idle: no RPC is issued and `account` stays
|
|
11
|
-
* `null`. Lets always-called hooks (React's rules)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* `null`. Lets always-called hooks (React's rules) stay silent on a
|
|
12
|
+
* surface that must not fetch identity — the guest and embed audiences,
|
|
13
|
+
* or a caller who has not signed in yet.
|
|
14
14
|
*
|
|
15
15
|
* @default true
|
|
16
16
|
*/
|
|
@@ -41,10 +41,11 @@ export interface UseMyIdentityAccountReturn {
|
|
|
41
41
|
* `whoAmI()` returns the complete resource, so no follow-up `get()` is
|
|
42
42
|
* needed.
|
|
43
43
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* Served by every edition: on Stigmer Cloud and an authenticated self-host
|
|
45
|
+
* the account is the signed-in user's; on a trusted-local server it is the
|
|
46
|
+
* operator account the server creates at boot. A caller with no account yet
|
|
47
|
+
* gets NOT_FOUND — the first-sign-in flow (`ensureMyIdentityAccount`, run by
|
|
48
|
+
* {@link useIdentityAccountGate} and the CLI) is what creates one.
|
|
48
49
|
*
|
|
49
50
|
* Cached across mounts under a {@link FetchCacheProvider} (DD-014): a
|
|
50
51
|
* revisit renders the previous result immediately and refetches in the
|
package/src/index.ts
CHANGED
|
@@ -29,8 +29,15 @@ export {
|
|
|
29
29
|
useDeploymentMode,
|
|
30
30
|
useResourceAvailable,
|
|
31
31
|
} from "./deployment-mode.js";
|
|
32
|
-
export {
|
|
33
|
-
|
|
32
|
+
export {
|
|
33
|
+
type DeploymentMode,
|
|
34
|
+
isResourceAvailable,
|
|
35
|
+
ApiResourceKind,
|
|
36
|
+
} from "@stigmer/sdk";
|
|
37
|
+
export {
|
|
38
|
+
CloudFeatureNotice,
|
|
39
|
+
type CloudFeatureNoticeProps,
|
|
40
|
+
} from "./internal/CloudFeatureNotice.js";
|
|
34
41
|
|
|
35
42
|
// Models — data hook, styled components, and registry data
|
|
36
43
|
export {
|
|
@@ -864,6 +871,14 @@ export type {
|
|
|
864
871
|
UseUpdateIdentityAccountReturn,
|
|
865
872
|
AccountPreferencesPanelProps,
|
|
866
873
|
} from "./identity-account/index.js";
|
|
874
|
+
// The first-sign-in flow the gate hook delegates to, for consumers who want
|
|
875
|
+
// it without the hook (the `isResourceAvailable` re-export is the precedent).
|
|
876
|
+
export {
|
|
877
|
+
ensureMyIdentityAccount,
|
|
878
|
+
type EnsuredIdentityAccount,
|
|
879
|
+
type EnsureMyIdentityAccountOptions,
|
|
880
|
+
type IdentityAccountLane,
|
|
881
|
+
} from "@stigmer/sdk";
|
|
867
882
|
|
|
868
883
|
// Memory — agent-proposed, user-confirmed facts: data hook, decision
|
|
869
884
|
// hooks (confirm/reject/delete/edit), grouping helpers, and the list panel
|
|
@@ -1454,8 +1469,15 @@ export type {
|
|
|
1454
1469
|
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
1455
1470
|
|
|
1456
1471
|
// Error — structured error display with classification, retry, and contextual guidance
|
|
1457
|
-
export {
|
|
1458
|
-
|
|
1472
|
+
export {
|
|
1473
|
+
ErrorMessage,
|
|
1474
|
+
SecretFlowErrorGuide,
|
|
1475
|
+
isSecretFlowError,
|
|
1476
|
+
} from "./error/index.js";
|
|
1477
|
+
export type {
|
|
1478
|
+
ErrorMessageProps,
|
|
1479
|
+
SecretFlowErrorGuideProps,
|
|
1480
|
+
} from "./error/index.js";
|
|
1459
1481
|
|
|
1460
1482
|
// Library — cross-resource UI components, resource detection, apply flow, browsing, and visibility management
|
|
1461
1483
|
export {
|
|
@@ -1695,7 +1717,10 @@ export type {
|
|
|
1695
1717
|
} from "./resource-creation/index.js";
|
|
1696
1718
|
|
|
1697
1719
|
// Dependency Graph — visual tree of agent dependencies (MCP servers, skills, sub-agents)
|
|
1698
|
-
export {
|
|
1720
|
+
export {
|
|
1721
|
+
DependencyGraph,
|
|
1722
|
+
useDependencyGraph,
|
|
1723
|
+
} from "./dependency-graph/index.js";
|
|
1699
1724
|
export type {
|
|
1700
1725
|
NodeKind,
|
|
1701
1726
|
DependencyNode,
|
|
@@ -114,10 +114,12 @@ export function ResourceVisibilityControl({
|
|
|
114
114
|
|
|
115
115
|
const isInstance = INSTANCE_KINDS.has(kind);
|
|
116
116
|
const isEnvironment = kind === "environment";
|
|
117
|
-
// The IdP lookup only matters for blueprints
|
|
118
|
-
//
|
|
117
|
+
// The IdP lookup only matters for blueprints where org/platform
|
|
118
|
+
// visibility is enforced — every edition but the open-source one, which
|
|
119
|
+
// has no IdP resources (editions program, DD-001). Passing null makes the
|
|
120
|
+
// hook a stable no-op everywhere else.
|
|
119
121
|
const idpLookupOrg =
|
|
120
|
-
!isInstance && !isEnvironment && deploymentMode
|
|
122
|
+
!isInstance && !isEnvironment && deploymentMode !== "local"
|
|
121
123
|
? (org ?? null)
|
|
122
124
|
: null;
|
|
123
125
|
const { ssoProvider } = useSsoProvider(idpLookupOrg);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins useCanSetPublicVisibility's edition shape (editions program,
|
|
3
|
+
* sp.edition-contract §3d): the hook asks whether the server has an
|
|
4
|
+
* operator seat (the `platform` kind is served), not which deployment mode
|
|
5
|
+
* it is in. Without a seat the answer is always allowed and no RPC is made
|
|
6
|
+
* (the self-hosted operator owns the store). With a seat — Enterprise and
|
|
7
|
+
* Cloud alike — the fail-closed permission check decides. Before this
|
|
8
|
+
* shape, two independent mode compares left an "enterprise" server with
|
|
9
|
+
* neither arm and a permanent `allowed: false`.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
12
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
|
+
import type { DeploymentMode } from "@stigmer/sdk";
|
|
15
|
+
import { StigmerContext } from "../../context";
|
|
16
|
+
import { DeploymentModeContext } from "../../deployment-mode";
|
|
17
|
+
import { useCanSetPublicVisibility } from "../useCanSetPublicVisibility";
|
|
18
|
+
|
|
19
|
+
function wrapper(
|
|
20
|
+
mode: DeploymentMode,
|
|
21
|
+
checkMyPermission: ReturnType<typeof vi.fn>,
|
|
22
|
+
) {
|
|
23
|
+
const client = { iamPolicy: { checkMyPermission } } as never;
|
|
24
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
25
|
+
return (
|
|
26
|
+
<StigmerContext.Provider value={client}>
|
|
27
|
+
<DeploymentModeContext.Provider value={mode}>
|
|
28
|
+
{children}
|
|
29
|
+
</DeploymentModeContext.Provider>
|
|
30
|
+
</StigmerContext.Provider>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Probe() {
|
|
36
|
+
const { allowed, isLoading } = useCanSetPublicVisibility();
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-testid="probe"
|
|
40
|
+
data-allowed={String(allowed)}
|
|
41
|
+
data-loading={String(isLoading)}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
afterEach(cleanup);
|
|
47
|
+
|
|
48
|
+
describe("useCanSetPublicVisibility", () => {
|
|
49
|
+
it("local: always allowed, no permission RPC (no operator seat to ask)", () => {
|
|
50
|
+
const check = vi.fn();
|
|
51
|
+
render(<Probe />, { wrapper: wrapper("local", check) });
|
|
52
|
+
const probe = screen.getByTestId("probe");
|
|
53
|
+
expect(probe.dataset.allowed).toBe("true");
|
|
54
|
+
expect(probe.dataset.loading).toBe("false");
|
|
55
|
+
expect(check).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it.each(["enterprise", "cloud"] as const)(
|
|
59
|
+
"%s: the fail-closed check on platform:stigmer decides",
|
|
60
|
+
async (mode) => {
|
|
61
|
+
const check = vi.fn().mockResolvedValue({ isAuthorized: true });
|
|
62
|
+
render(<Probe />, { wrapper: wrapper(mode, check) });
|
|
63
|
+
await waitFor(() =>
|
|
64
|
+
expect(screen.getByTestId("probe").dataset.allowed).toBe("true"),
|
|
65
|
+
);
|
|
66
|
+
expect(check).toHaveBeenCalledTimes(1);
|
|
67
|
+
const input = check.mock.calls[0]?.[0] as {
|
|
68
|
+
resource?: { kind: string; id: string };
|
|
69
|
+
relation: string;
|
|
70
|
+
};
|
|
71
|
+
expect(input.resource).toMatchObject({ kind: "platform", id: "stigmer" });
|
|
72
|
+
expect(input.relation).toBe("can_set_public_visibility");
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it("enterprise: denied when the seat says no — never the accidental always-false of a missed arm", async () => {
|
|
77
|
+
const check = vi.fn().mockResolvedValue({ isAuthorized: false });
|
|
78
|
+
render(<Probe />, { wrapper: wrapper("enterprise", check) });
|
|
79
|
+
await waitFor(() =>
|
|
80
|
+
expect(screen.getByTestId("probe").dataset.loading).toBe("false"),
|
|
81
|
+
);
|
|
82
|
+
expect(screen.getByTestId("probe").dataset.allowed).toBe("false");
|
|
83
|
+
expect(check).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -36,6 +36,34 @@ describe("environmentVisibilityLevels", () => {
|
|
|
36
36
|
it("collapses to a single read-only level in local mode", () => {
|
|
37
37
|
expect(environmentVisibilityLevels("local")).toHaveLength(1);
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it("offers the cloud shape in enterprise mode — org sharing is an FGA-model question, not a facility", () => {
|
|
41
|
+
expect(environmentVisibilityLevels("enterprise").map((l) => l.value)).toEqual(
|
|
42
|
+
environmentVisibilityLevels("cloud").map((l) => l.value),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("blueprintVisibilityLevels across the three editions", () => {
|
|
48
|
+
const valuesFor = (deploymentMode: "local" | "enterprise" | "cloud") =>
|
|
49
|
+
blueprintVisibilityLevels({
|
|
50
|
+
deploymentMode,
|
|
51
|
+
hasIdentityProvider: true,
|
|
52
|
+
canSetPublicVisibility: true,
|
|
53
|
+
}).map((l) => l.value);
|
|
54
|
+
|
|
55
|
+
it("collapses to Private/Public only in local mode", () => {
|
|
56
|
+
expect(valuesFor("local")).toEqual([
|
|
57
|
+
ApiResourceVisibility.visibility_private,
|
|
58
|
+
ApiResourceVisibility.visibility_public,
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("enterprise offers exactly what cloud offers — the org, platform and public levels", () => {
|
|
63
|
+
expect(valuesFor("enterprise")).toEqual(valuesFor("cloud"));
|
|
64
|
+
expect(valuesFor("enterprise")).toContain(ApiResourceVisibility.visibility_org);
|
|
65
|
+
expect(valuesFor("enterprise")).toContain(ApiResourceVisibility.visibility_platform);
|
|
66
|
+
});
|
|
39
67
|
});
|
|
40
68
|
|
|
41
69
|
describe("operator-gated PUBLIC level", () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { ApiResourceKind, useResourceAvailable } from "../deployment-mode.js";
|
|
4
4
|
import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -23,33 +23,36 @@ export interface UseCanSetPublicVisibilityReturn {
|
|
|
23
23
|
* only level that crosses every org boundary (the cross-org "explore"
|
|
24
24
|
* catalog).
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the
|
|
26
|
+
* Where the server has an operator seat — the `platform` kind is served,
|
|
27
|
+
* which is Enterprise and Cloud (editions program, DD-001) — publishing is
|
|
28
|
+
* a curation decision: the backend gates both write doors (updateVisibility
|
|
29
|
+
* escalation and create-with-public) on `can_set_public_visibility` on
|
|
30
|
+
* `platform:stigmer`, an explicit platform-operator capability. This hook
|
|
31
|
+
* runs the same check so selectors can present the Public option as locked
|
|
32
|
+
* instead of letting the request fail.
|
|
32
33
|
*
|
|
33
34
|
* Fail-closed, deliberately the opposite of the `useCheckPermission`
|
|
34
35
|
* default: an escalation gate must not open while the check is loading or
|
|
35
36
|
* when it errors — a locked option that unlocks a moment later is a far
|
|
36
37
|
* better failure than an unlocked option the server then rejects.
|
|
37
38
|
*
|
|
38
|
-
*
|
|
39
|
-
* the self-hosted operator owns the store, the same scoping
|
|
40
|
-
* applied to reserved labels.
|
|
39
|
+
* Where there is no operator seat (the open-source edition) the answer is
|
|
40
|
+
* always `true`: the self-hosted operator owns the store, the same scoping
|
|
41
|
+
* cloud#320 applied to reserved labels. The question is asked of the kind
|
|
42
|
+
* registry, not of the deployment mode, so a new edition answers it by its
|
|
43
|
+
* tier table and never by falling between two mode compares.
|
|
41
44
|
*/
|
|
42
45
|
export function useCanSetPublicVisibility(): UseCanSetPublicVisibilityReturn {
|
|
43
|
-
const
|
|
44
|
-
// Hooks run unconditionally;
|
|
46
|
+
const hasOperatorSeat = useResourceAvailable(ApiResourceKind.platform);
|
|
47
|
+
// Hooks run unconditionally; without a seat the null resource skips the
|
|
45
48
|
// RPC entirely and the fail mode's answer is discarded below.
|
|
46
49
|
const { allowed, isLoading } = useCheckPermission(
|
|
47
|
-
|
|
50
|
+
hasOperatorSeat ? PLATFORM_RESOURCE : null,
|
|
48
51
|
"can_set_public_visibility",
|
|
49
52
|
{ fail: "closed" },
|
|
50
53
|
);
|
|
51
54
|
|
|
52
|
-
if (
|
|
55
|
+
if (!hasOperatorSeat) {
|
|
53
56
|
return { allowed: true, isLoading: false };
|
|
54
57
|
}
|
|
55
58
|
return { allowed, isLoading };
|