@stigmer/react 3.14.1 → 3.15.1

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 (35) hide show
  1. package/identity-account/AccountPreferencesPanel.d.ts +6 -4
  2. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
  3. package/identity-account/AccountPreferencesPanel.js +5 -16
  4. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  5. package/identity-account/useAccountExecutionDefaults.d.ts +6 -4
  6. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
  7. package/identity-account/useAccountExecutionDefaults.js +7 -7
  8. package/identity-account/useAccountExecutionDefaults.js.map +1 -1
  9. package/identity-account/useIdentityAccountGate.d.ts +9 -3
  10. package/identity-account/useIdentityAccountGate.d.ts.map +1 -1
  11. package/identity-account/useIdentityAccountGate.js +21 -22
  12. package/identity-account/useIdentityAccountGate.js.map +1 -1
  13. package/identity-account/useMyIdentityAccount.d.ts +8 -7
  14. package/identity-account/useMyIdentityAccount.d.ts.map +1 -1
  15. package/identity-account/useMyIdentityAccount.js +5 -4
  16. package/identity-account/useMyIdentityAccount.js.map +1 -1
  17. package/index.d.ts +6 -5
  18. package/index.d.ts.map +1 -1
  19. package/index.js +7 -4
  20. package/index.js.map +1 -1
  21. package/organization/useOrgGate.d.ts +19 -23
  22. package/organization/useOrgGate.d.ts.map +1 -1
  23. package/organization/useOrgGate.js +19 -51
  24. package/organization/useOrgGate.js.map +1 -1
  25. package/package.json +4 -4
  26. package/src/identity-account/AccountPreferencesPanel.tsx +5 -38
  27. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +8 -5
  28. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +9 -5
  29. package/src/identity-account/__tests__/useIdentityAccountGate.test.tsx +236 -0
  30. package/src/identity-account/useAccountExecutionDefaults.ts +7 -7
  31. package/src/identity-account/useIdentityAccountGate.ts +21 -22
  32. package/src/identity-account/useMyIdentityAccount.ts +8 -7
  33. package/src/index.ts +30 -5
  34. package/src/organization/__tests__/useOrgGate.test.tsx +84 -0
  35. package/src/organization/useOrgGate.ts +20 -69
@@ -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` in local mode (no IdentityAccount), while loading,
34
- * on error, and when no default is declared — every one of those cases
35
- * degrades to the platform's existing defaults, so consumers wire the
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 available = useResourceAvailable(ApiResourceKind.identity_account);
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 { isNotFound } from "@stigmer/sdk";
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`** — calls `whoAmI()` to look up the caller's account.
76
+ * 1. **`checking`** — `whoAmI()` looks up the caller's account.
71
77
  * 2. **`provisioning`** — `whoAmI` returned NOT_FOUND (first login after
72
- * signup); the hook calls `provisionMyAccount()` to create the account
73
- * and personal organization.
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.identityAccount.whoAmI();
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 (whoAmIErr: unknown) {
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) skip the doomed
12
- * whoAmI against a local serverpass
13
- * `useResourceAvailable(ApiResourceKind.identity_account)` here.
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
- * Cloud-only by nature: the OSS local server does not implement
45
- * IdentityAccount. Gate consumers with
46
- * `useResourceAvailable(ApiResourceKind.identity_account)` either by
47
- * conditional mounting or via {@link UseMyIdentityAccountOptions.enabled}.
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 { type DeploymentMode, isResourceAvailable, ApiResourceKind } from "@stigmer/sdk";
33
- export { CloudFeatureNotice, type CloudFeatureNoticeProps } from "./internal/CloudFeatureNotice.js";
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 { ErrorMessage, SecretFlowErrorGuide, isSecretFlowError } from "./error/index.js";
1458
- export type { ErrorMessageProps, SecretFlowErrorGuideProps } from "./error/index.js";
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 { DependencyGraph, useDependencyGraph } from "./dependency-graph/index.js";
1720
+ export {
1721
+ DependencyGraph,
1722
+ useDependencyGraph,
1723
+ } from "./dependency-graph/index.js";
1699
1724
  export type {
1700
1725
  NodeKind,
1701
1726
  DependencyNode,
@@ -0,0 +1,84 @@
1
+ // Pins useOrgGate as a pure derivation over useOrg() (20260913.02,
2
+ // sp.console-login Q-CL-5). The hook once carried a "provisioning" arm that
3
+ // polled for a personal organization the server was "still creating"; the
4
+ // cloud has created that organization synchronously inside
5
+ // provisionMyAccount since 20260911.11, and the step is best-effort, so by
6
+ // the time the identity gate is ready the organization list is final in
7
+ // every edition. Zero organizations is therefore `no-orgs` at once — no
8
+ // timers, no options beyond the route bypass — and the arms below fail if
9
+ // a wait ever comes back.
10
+ import { describe, it, expect, vi, afterEach } from "vitest";
11
+ import { renderHook } from "@testing-library/react";
12
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
13
+
14
+ import type { OrgContextValue } from "../OrgProvider";
15
+
16
+ const mocks = vi.hoisted(() => ({
17
+ org: null as OrgContextValue | null,
18
+ }));
19
+
20
+ vi.mock("../OrgProvider.js", () => ({
21
+ useOrg: () => mocks.org,
22
+ }));
23
+
24
+ import { useOrgGate } from "../useOrgGate";
25
+
26
+ const acme = {
27
+ metadata: { id: "acme", slug: "acme", name: "Acme" },
28
+ } as Organization;
29
+
30
+ function orgContext(overrides: Partial<OrgContextValue>): OrgContextValue {
31
+ return {
32
+ orgs: [],
33
+ isLoading: false,
34
+ error: null,
35
+ retry: vi.fn(),
36
+ refresh: vi.fn(),
37
+ ...overrides,
38
+ } as OrgContextValue;
39
+ }
40
+
41
+ describe("useOrgGate is a derivation, not a state machine", () => {
42
+ afterEach(() => vi.useRealTimers());
43
+
44
+ it("zero organizations is no-orgs immediately — no provisioning wait in any edition", () => {
45
+ vi.useFakeTimers();
46
+ mocks.org = orgContext({ orgs: [] });
47
+ const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
48
+ expect(result.current.state).toEqual({ status: "no-orgs" });
49
+ expect(vi.getTimerCount()).toBe(0);
50
+ });
51
+
52
+ it("the initial fetch is loading", () => {
53
+ mocks.org = orgContext({ isLoading: true });
54
+ const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
55
+ expect(result.current.state).toEqual({ status: "loading" });
56
+ });
57
+
58
+ it("a failed fetch is error with its message", () => {
59
+ mocks.org = orgContext({ error: "boom" });
60
+ const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
61
+ expect(result.current.state).toEqual({ status: "error", message: "boom" });
62
+ });
63
+
64
+ it("at least one organization is ready", () => {
65
+ mocks.org = orgContext({ orgs: [acme] });
66
+ const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
67
+ expect(result.current.state).toEqual({ status: "ready" });
68
+ });
69
+
70
+ it("a bypassed route is bypassed whatever the list says", () => {
71
+ mocks.org = orgContext({ orgs: [], isLoading: true });
72
+ const { result } = renderHook(() => useOrgGate({ isBypassed: true }));
73
+ expect(result.current.state).toEqual({ status: "bypassed" });
74
+ });
75
+
76
+ it("hands the consumer useOrg's own retry and refresh", () => {
77
+ const retry = vi.fn();
78
+ const refresh = vi.fn();
79
+ mocks.org = orgContext({ retry, refresh });
80
+ const { result } = renderHook(() => useOrgGate({ isBypassed: false }));
81
+ expect(result.current.retry).toBe(retry);
82
+ expect(result.current.refresh).toBe(refresh);
83
+ });
84
+ });
@@ -1,11 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
4
3
  import { useOrg } from "./OrgProvider.js";
5
4
 
6
- const PROVISIONING_POLL_MS = 2_000;
7
- const PROVISIONING_TIMEOUT_MS = 10_000;
8
-
9
5
  // ---------------------------------------------------------------------------
10
6
  // Types
11
7
  // ---------------------------------------------------------------------------
@@ -13,15 +9,13 @@ const PROVISIONING_TIMEOUT_MS = 10_000;
13
9
  /**
14
10
  * Options passed to {@link useOrgGate} by the host application.
15
11
  *
16
- * Both values are computed by the consumer using framework-specific APIs
12
+ * `isBypassed` is computed by the consumer using framework-specific APIs
17
13
  * (e.g. `usePathname()` in Next.js, `useLocation()` in react-router) so
18
14
  * that the hook itself has zero framework dependencies.
19
15
  */
20
16
  export interface UseOrgGateOptions {
21
17
  /** True when the current route should bypass the gate (e.g. `/invite/` links). */
22
18
  readonly isBypassed: boolean;
23
- /** True when the auth mode supports server-side personal org provisioning. */
24
- readonly isOidcMode: boolean;
25
19
  }
26
20
 
27
21
  /**
@@ -44,10 +38,6 @@ export type OrgGateState =
44
38
  /** Initial organization list fetch is in progress. */
45
39
  readonly status: "loading";
46
40
  }
47
- | {
48
- /** Personal organization provisioning is in progress. */
49
- readonly status: "provisioning";
50
- }
51
41
  | {
52
42
  /** Organization fetch failed and user action is required. */
53
43
  readonly status: "error";
@@ -84,28 +74,31 @@ export interface UseOrgGateReturn {
84
74
  // ---------------------------------------------------------------------------
85
75
 
86
76
  /**
87
- * Headless behavior hook that encapsulates the org-gate provisioning
88
- * state machine.
89
- *
90
- * The hook observes the organization context from {@link useOrg} and
91
- * drives the following lifecycle:
77
+ * Headless behavior hook that derives the org-gate state from
78
+ * {@link useOrg}.
92
79
  *
93
80
  * 1. **`bypassed`** — `isBypassed` is true; the gate is inactive.
94
81
  * 2. **`loading`** — the initial org list fetch is in flight.
95
- * 3. **`provisioning`** — OIDC mode, zero orgs: the server is creating
96
- * the personal org. The hook polls every 2 s and times out after 10 s.
97
- * 4. **`error`** — the org fetch failed; `message` carries the reason.
98
- * 5. **`no-orgs`** — no organizations exist (or provisioning timed out);
99
- * the consumer should show an onboarding form.
100
- * 6. **`ready`** — at least one org exists; render the app.
82
+ * 3. **`error`** — the org fetch failed; `message` carries the reason.
83
+ * 4. **`no-orgs`** no organizations exist; the consumer should show an
84
+ * onboarding form.
85
+ * 5. **`ready`** — at least one org exists; render the app.
101
86
  *
102
- * The consumer computes `isBypassed` and `isOidcMode` using
103
- * framework-specific APIs and passes them in, keeping this hook free of
104
- * routing or auth-framework dependencies (DD-004).
87
+ * A pure derivation, deliberately: the organization list is final by the
88
+ * time this hook runs. Where a server creates a personal organization on
89
+ * first sign-in (Stigmer Cloud), it does so inside `provisionMyAccount`,
90
+ * before the identity gate ahead of this one reports ready — so there is
91
+ * nothing to wait for, and an empty list means the person creates their
92
+ * first organization now. (The hook once polled for that organization for
93
+ * ten seconds, a relic of an earlier asynchronous provisioner; on a server
94
+ * that never provisions one, every first sign-in paid the full wait.)
95
+ *
96
+ * The consumer computes `isBypassed` using framework-specific APIs and
97
+ * passes it in, keeping this hook free of routing dependencies (DD-004).
105
98
  *
106
99
  * @example
107
100
  * ```tsx
108
- * const { state, retry, refresh } = useOrgGate({ isBypassed, isOidcMode });
101
+ * const { state, retry, refresh } = useOrgGate({ isBypassed });
109
102
  *
110
103
  * switch (state.status) {
111
104
  * case "bypassed":
@@ -113,8 +106,6 @@ export interface UseOrgGateReturn {
113
106
  * return <>{children}</>;
114
107
  * case "loading":
115
108
  * return <Spinner />;
116
- * case "provisioning":
117
- * return <WelcomeScreen />;
118
109
  * case "error":
119
110
  * return <ErrorScreen message={state.message} onRetry={retry} />;
120
111
  * case "no-orgs":
@@ -123,52 +114,12 @@ export interface UseOrgGateReturn {
123
114
  * ```
124
115
  */
125
116
  export function useOrgGate(options: UseOrgGateOptions): UseOrgGateReturn {
126
- const { isBypassed, isOidcMode } = options;
117
+ const { isBypassed } = options;
127
118
  const { orgs, isLoading, error, retry, refresh } = useOrg();
128
119
 
129
- const [provisioningStarted, setProvisioningStarted] = useState(false);
130
- const [provisioningTimedOut, setProvisioningTimedOut] = useState(false);
131
-
132
- // React-sanctioned "adjust state during render" pattern: the guard on
133
- // `!provisioningStarted` prevents infinite re-render loops.
134
- if (
135
- !isBypassed &&
136
- !provisioningStarted &&
137
- !isLoading &&
138
- orgs.length === 0 &&
139
- !error &&
140
- isOidcMode
141
- ) {
142
- setProvisioningStarted(true);
143
- }
144
-
145
- const isProvisioning =
146
- provisioningStarted && orgs.length === 0 && !provisioningTimedOut;
147
-
148
- // Poll for the personal org while provisioning is in progress.
149
- // Errors from refresh() are absorbed — transient failures (identity not
150
- // yet created) are expected during the provisioning window.
151
- useEffect(() => {
152
- if (!isProvisioning) return;
153
-
154
- const interval = setInterval(() => refresh(), PROVISIONING_POLL_MS);
155
- const timeout = setTimeout(
156
- () => setProvisioningTimedOut(true),
157
- PROVISIONING_TIMEOUT_MS,
158
- );
159
-
160
- return () => {
161
- clearInterval(interval);
162
- clearTimeout(timeout);
163
- };
164
- }, [isProvisioning, refresh]);
165
-
166
- // Resolve state — order matters: bypass and provisioning take priority.
167
120
  let state: OrgGateState;
168
121
  if (isBypassed) {
169
122
  state = { status: "bypassed" };
170
- } else if (isProvisioning) {
171
- state = { status: "provisioning" };
172
123
  } else if (isLoading) {
173
124
  state = { status: "loading" };
174
125
  } else if (error) {