@object-ui/auth 17.5.0 → 17.7.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +600 -0
  2. package/README.md +245 -15
  3. package/dist/ActiveOrganizationStorage.d.ts +199 -0
  4. package/dist/ActiveOrganizationStorage.d.ts.map +1 -0
  5. package/dist/ActiveOrganizationStorage.js +584 -0
  6. package/dist/AuthContext.d.ts +23 -3
  7. package/dist/AuthContext.d.ts.map +1 -1
  8. package/dist/AuthGuard.d.ts +8 -1
  9. package/dist/AuthGuard.d.ts.map +1 -1
  10. package/dist/AuthGuard.js +16 -3
  11. package/dist/AuthProvider.d.ts +1 -1
  12. package/dist/AuthProvider.d.ts.map +1 -1
  13. package/dist/AuthProvider.js +274 -9
  14. package/dist/ForgotPasswordForm.d.ts +1 -1
  15. package/dist/ForgotPasswordForm.d.ts.map +1 -1
  16. package/dist/ForgotPasswordForm.js +5 -5
  17. package/dist/LoginForm.d.ts +11 -2
  18. package/dist/LoginForm.d.ts.map +1 -1
  19. package/dist/LoginForm.js +5 -6
  20. package/dist/PreviewBanner.js +1 -1
  21. package/dist/RegisterForm.d.ts +22 -3
  22. package/dist/RegisterForm.d.ts.map +1 -1
  23. package/dist/RegisterForm.js +6 -7
  24. package/dist/SocialSignInButtons.d.ts +9 -2
  25. package/dist/SocialSignInButtons.d.ts.map +1 -1
  26. package/dist/SocialSignInButtons.js +5 -5
  27. package/dist/UserMenu.d.ts +1 -1
  28. package/dist/UserMenu.js +3 -3
  29. package/dist/createAuthClient.d.ts +1 -1
  30. package/dist/createAuthClient.d.ts.map +1 -1
  31. package/dist/createAuthClient.js +42 -4
  32. package/dist/createAuthenticatedFetch.d.ts +9 -9
  33. package/dist/createAuthenticatedFetch.d.ts.map +1 -1
  34. package/dist/createAuthenticatedFetch.js +50 -38
  35. package/dist/index.d.ts +21 -19
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +34 -17
  38. package/dist/invitation-status.d.ts +59 -0
  39. package/dist/invitation-status.d.ts.map +1 -0
  40. package/dist/invitation-status.js +32 -0
  41. package/dist/types.d.ts +25 -5
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/useAuth.d.ts +1 -1
  44. package/dist/useAuth.d.ts.map +1 -1
  45. package/dist/useAuth.js +5 -1
  46. package/dist/useWorkspaceAdminStatus.d.ts +134 -0
  47. package/dist/useWorkspaceAdminStatus.d.ts.map +1 -0
  48. package/dist/useWorkspaceAdminStatus.js +208 -0
  49. package/package.json +6 -7
  50. package/dist/useIsWorkspaceAdmin.d.ts +0 -21
  51. package/dist/useIsWorkspaceAdmin.d.ts.map +0 -1
  52. package/dist/useIsWorkspaceAdmin.js +0 -47
package/README.md CHANGED
@@ -25,16 +25,18 @@ npm install @object-ui/auth
25
25
  ## Quick Start
26
26
 
27
27
  ```tsx
28
- import { AuthProvider, useAuth, AuthGuard } from '@object-ui/auth';
29
- import { createAuthClient } from '@object-ui/auth';
28
+ import type { ReactNode } from 'react';
29
+ import { AuthProvider, AuthGuard, createAuthClient, useAuth } from '@object-ui/auth';
30
30
 
31
- const authClient = createAuthClient({
32
- baseURL: 'https://api.example.com/auth',
33
- });
31
+ // Your app supplies the sign-in page shown to unauthenticated visitors.
32
+ declare const LoginPage: () => ReactNode;
33
+
34
+ const authUrl = 'https://api.example.com/auth';
35
+ const authClient = createAuthClient({ baseURL: authUrl });
34
36
 
35
37
  function App() {
36
38
  return (
37
- <AuthProvider client={authClient}>
39
+ <AuthProvider authUrl={authUrl} client={authClient}>
38
40
  <AuthGuard fallback={<LoginPage />}>
39
41
  <Dashboard />
40
42
  </AuthGuard>
@@ -57,12 +59,21 @@ function Dashboard() {
57
59
 
58
60
  ### AuthProvider
59
61
 
60
- Wraps your application with authentication context:
62
+ Wraps your application with authentication context. `authUrl` is **required**;
63
+ `client` is optional, and when you pass one it is used instead of the client
64
+ `AuthProvider` would otherwise build from `authUrl`:
61
65
 
62
66
  ```tsx
63
- <AuthProvider client={authClient}>
67
+ import type { ReactNode } from 'react';
68
+ import { AuthProvider } from '@object-ui/auth';
69
+ import type { AuthClient } from '@object-ui/auth';
70
+
71
+ declare const authClient: AuthClient;
72
+ declare const App: () => ReactNode;
73
+
74
+ <AuthProvider authUrl="/api/v1/auth" client={authClient}>
64
75
  <App />
65
- </AuthProvider>
76
+ </AuthProvider>;
66
77
  ```
67
78
 
68
79
  ### useAuth
@@ -70,6 +81,8 @@ Wraps your application with authentication context:
70
81
  Hook for accessing auth state and methods:
71
82
 
72
83
  ```tsx
84
+ import { useAuth } from '@object-ui/auth';
85
+
73
86
  const {
74
87
  user,
75
88
  session,
@@ -100,9 +113,14 @@ const {
100
113
  Protects children from unauthenticated access:
101
114
 
102
115
  ```tsx
116
+ import type { ReactNode } from 'react';
117
+ import { AuthGuard, LoginForm } from '@object-ui/auth';
118
+
119
+ declare const ProtectedContent: () => ReactNode;
120
+
103
121
  <AuthGuard fallback={<LoginForm />}>
104
122
  <ProtectedContent />
105
- </AuthGuard>
123
+ </AuthGuard>;
106
124
  ```
107
125
 
108
126
  ### LoginForm / RegisterForm / ForgotPasswordForm
@@ -110,9 +128,40 @@ Protects children from unauthenticated access:
110
128
  Pre-built authentication form components:
111
129
 
112
130
  ```tsx
113
- <LoginForm onSuccess={() => navigate('/dashboard')} />
114
- <RegisterForm onSuccess={() => navigate('/welcome')} />
115
- <ForgotPasswordForm onSuccess={() => navigate('/check-email')} />
131
+ import { ForgotPasswordForm, LoginForm, RegisterForm } from '@object-ui/auth';
132
+
133
+ declare const navigate: (to: string) => void;
134
+
135
+ <LoginForm onSuccess={() => navigate('/dashboard')} />;
136
+ <RegisterForm onSuccess={() => navigate('/welcome')} />;
137
+ <ForgotPasswordForm onSuccess={() => navigate('/check-email')} />;
138
+ ```
139
+
140
+ `labels` is how the forms take translated text, and it reaches the social
141
+ provider buttons the server's `/auth/config` turns on: `socialButton`
142
+ is a template whose `{provider}` is replaced with the provider's display name
143
+ (defaults "Continue with {provider}" on `LoginForm`, "Sign up with {provider}"
144
+ on `RegisterForm`), and `orText` is the divider under the buttons (default
145
+ "or continue with email").
146
+
147
+ When the server requires email verification, a registration ends in the user's
148
+ inbox, and the verification link is the only way back. `verificationCallbackURL`
149
+ says where that link lands, e.g. the invitation the user signed up from. It is
150
+ sent as better-auth's sign-up `callbackURL` (the same value as the fourth
151
+ argument of `useAuth().signUp`). The server accepts only a root-relative path
152
+ or an absolute URL on a trusted origin, and refuses a document-relative `./…`
153
+ with `INVALID_CALLBACK_URL`, which fails the sign-up. Resolve an SPA route
154
+ against its mount first. Omitted, the server default (`/`) applies.
155
+
156
+ ```tsx
157
+ import { RegisterForm } from '@object-ui/auth';
158
+
159
+ declare const navigate: (to: string) => void;
160
+
161
+ <RegisterForm
162
+ verificationCallbackURL="/_console/accept-invitation/inv_123"
163
+ onVerificationRequired={(email) => navigate(`/verify-email-prompt?email=${encodeURIComponent(email)}`)}
164
+ />;
116
165
  ```
117
166
 
118
167
  ### UserMenu
@@ -120,7 +169,9 @@ Pre-built authentication form components:
120
169
  Displays current user info with avatar and sign-out:
121
170
 
122
171
  ```tsx
123
- <UserMenu />
172
+ import { UserMenu } from '@object-ui/auth';
173
+
174
+ <UserMenu />;
124
175
  ```
125
176
 
126
177
  ### createAuthenticatedFetch
@@ -129,6 +180,8 @@ Creates a fetch wrapper that injects the stored Bearer token (plus
129
180
  `X-Tenant-ID` and `Accept-Language`) into API requests:
130
181
 
131
182
  ```tsx
183
+ import { createAuthenticatedFetch } from '@object-ui/auth';
184
+
132
185
  const authedFetch = createAuthenticatedFetch();
133
186
 
134
187
  // For fetches whose target URL comes from view metadata (`provider: 'api'`
@@ -137,6 +190,178 @@ const authedFetch = createAuthenticatedFetch();
137
190
  const apiProviderFetch = createAuthenticatedFetch({ sameOriginOnly: true });
138
191
  ```
139
192
 
193
+ ### getSessionOwnerChangeCount / subscribeSessionOwnerChange
194
+
195
+ When the session resolves to a different user than the one this browser last
196
+ held, `AuthProvider` drops the previous user's client state (objectui#5664).
197
+ That clears storage only. State already derived from that storage and held in
198
+ memory, such as the UI language the boot resolved, has to be re-derived by
199
+ whoever owns it. These two exports tell that owner a change happened in this
200
+ page-load (objectui#10193). The count lets an owner that mounts after the purge
201
+ still see it:
202
+
203
+ ```tsx
204
+ import { useSyncExternalStore } from 'react';
205
+ import { getSessionOwnerChangeCount, subscribeSessionOwnerChange } from '@object-ui/auth';
206
+
207
+ const ownerChanges = useSyncExternalStore(
208
+ subscribeSessionOwnerChange,
209
+ getSessionOwnerChangeCount,
210
+ getSessionOwnerChangeCount,
211
+ );
212
+ ```
213
+
214
+ The count lives in memory for one page-load and is never persisted.
215
+ `@object-ui/app-shell`'s `useSignedInUserLocale` is the in-repo consumer.
216
+
217
+ ## The `X-Tenant-ID` edge contract
218
+
219
+ `createAuthenticatedFetch` stamps `X-Tenant-ID` on the requests it wraps. This section is
220
+ that header's contract: what it means, who stamps it, who reads it, what a reader may
221
+ assume, and the one window in which it is not sent at all.
222
+
223
+ It is written down because **a grep cannot answer any of those questions from this
224
+ repository.** The header's only non-CORS consumer lives in the **cloud** repository, so a
225
+ search confined to this repo and the framework (`objectstack`) finds zero readers and
226
+ reads as *"nothing consumes this stamp"*. That reading is what
227
+ [objectui#5279](https://github.com/objectstack-ai/objectui/issues/5279) was filed on, and
228
+ it is false. Do not act on it.
229
+
230
+ ### What it means
231
+
232
+ A **routing hint** for the hosting edge — *this request belongs to tenant `<id>`*:
233
+
234
+ ```http
235
+ X-Tenant-ID: <better-auth activeOrganizationId>
236
+ ```
237
+
238
+ The value is the active **organization** id, the same id the session carries as
239
+ `session.activeOrganizationId`. The header is **not** an identity claim, **not** an
240
+ authorization input, and **not** what scopes rows.
241
+
242
+ ### Who stamps it
243
+
244
+ | | |
245
+ | --- | --- |
246
+ | Stamped by | `createAuthenticatedFetch` ([`src/createAuthenticatedFetch.ts`](src/createAuthenticatedFetch.ts)) |
247
+ | Value read from | `ActiveOrganizationStorage` ([`src/ActiveOrganizationStorage.ts`](src/ActiveOrganizationStorage.ts)) — `localStorage`, key `auth-active-organization-id:u:$userId` (per-user since objectui#5664; the bare key is the retired pre-#5664 spelling) |
248
+ | Condition | that storage holds a non-empty value |
249
+ | *Not* conditioned on | the URL being an `/api/` call. `Authorization` and `Accept-Language` are; this is not |
250
+ | Suppressed by | `createAuthenticatedFetch({ sameOriginOnly: true })` for cross-origin URLs — it returns before any header work |
251
+ | Precedence | overwrites an `X-Tenant-ID` the caller passed in `init.headers` |
252
+
253
+ `ActiveOrganizationStorage` has exactly one writer, [`AuthProvider`](src/AuthProvider.tsx):
254
+
255
+ | Event | Effect |
256
+ | --- | --- |
257
+ | `refreshOrganizations` — after `getSession` &rarr; `listOrganizations` &rarr; `getActiveOrganization` resolves (including the ADR-0081 single-membership repair) | set |
258
+ | `switchOrganization` — the org switcher | set, or clear when the server returns no org |
259
+ | `deleteOrganization` / `leaveOrganization`, when the active org is the one going away | clear |
260
+ | sign-out | clear |
261
+
262
+ ### Who reads it
263
+
264
+ **The cloud edge — yes.** The non-test readers are recorded on objectui#5279:
265
+ `packages/service-tenant/src/tenant-context.ts` (header resolution) and
266
+ `packages/tenant-router/src/spec/turso-multi-tenant.zod.ts`, both in the `cloud`
267
+ repository. That repository is not readable from this one, so those paths are cited as the
268
+ recorded reading rather than re-derived here.
269
+
270
+ **Its configuration contract, though, IS readable from here**, because this package
271
+ depends on `@objectstack/spec`. `TenantRoutingConfigSchema` (`@objectstack/spec/cloud`) is
272
+ what configures a tenant resolver; parsing an empty config on the version this package
273
+ resolves today (17.1.0) yields:
274
+
275
+ ```text
276
+ enabled: false
277
+ identificationSources: ["subdomain", "header", "jwt_claim"]
278
+ tenantHeaderName: "X-Tenant-ID"
279
+ jwtOrganizationClaim: "organizationId"
280
+ ```
281
+
282
+ Two consequences matter to a client author:
283
+
284
+ - **The header name is configurable.** `X-Tenant-ID` is its default, not a constant.
285
+ - **The header is one of six identification sources** (`subdomain`, `custom_domain`,
286
+ `header`, `jwt_claim`, `session`, `default`), and in the default precedence it ranks
287
+ **second, behind `subdomain`**. On a subdomain-routed deployment it is not the thing
288
+ that picks the tenant, and a deployment may leave it out of `identificationSources`
289
+ entirely.
290
+
291
+ **The framework (`objectstack`) — no.** `resolveAuthzContext`
292
+ (`packages/core/src/security/resolve-authz-context.ts`) derives `tenantId` from the
293
+ API-key principal or from `session.activeOrganizationId`, and from no header;
294
+ `packages/verify/src/harness.org-context.test.ts` pins it — *"`session.activeOrganizationId`
295
+ is the ONE field `resolveAuthzContext` reads into `tenantId`"*. Environment and kernel
296
+ routing read the hostname and `X-Environment-Id`. The framework's only other mentions are
297
+ the CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS`, whose comment describes
298
+ `X-Tenant-ID` / `X-Environment-Id` as what routes "a request to its environment") and
299
+ `plugin-sharing`, which records that trusting `x-tenant-id` as identity **was a
300
+ vulnerability**: its secure default stopped reading identity from headers because doing so
301
+ let a client forge attribution and enumerate or revoke other users' links.
302
+
303
+ ### What a reader may assume
304
+
305
+ A reader **may**:
306
+
307
+ - use it to select the tenant database or environment to route to, subject to its own
308
+ `identificationSources` precedence;
309
+ - treat it as a hint that may be absent, stale, or contradicted by the session.
310
+
311
+ A reader **may not**:
312
+
313
+ - treat it as authenticated identity, or as an authorization decision. It is
314
+ client-controlled — any caller can send any value, and `plugin-sharing` is the recorded
315
+ precedent for what happens to a server that trusts it;
316
+ - assume the row scoping it sees downstream came from this header. It did not: the
317
+ framework scopes from the session;
318
+ - assume the header is present. See below.
319
+
320
+ ### The unstamped-first-request gap
321
+
322
+ The stamp reads storage that `AuthProvider` fills only **after** `getSession` &rarr;
323
+ `listOrganizations` &rarr; `getActiveOrganization` resolves. Every request that leaves
324
+ before that chain completes carries **no** `X-Tenant-ID` at all.
325
+
326
+ The window opens in five situations:
327
+
328
+ 1. a browser that has never signed in, or whose site data was cleared, including a private
329
+ window;
330
+ 2. the page load in which the user signs in — from the sign-in response until the
331
+ organization chain resolves;
332
+ 3. after sign-out, which clears the storage, until a new organization resolves;
333
+ 4. outside a browser (SSR, tests, a worker), where `localStorage` is unavailable and the
334
+ storage falls back to a per-process in-memory value that starts empty on every cold
335
+ start;
336
+ 5. a browser where `localStorage` exists but **rejects writes** (Safari private browsing,
337
+ quota exhaustion). There the window never closes — the in-memory fallback is written
338
+ but never read back, because `get()` only falls back when reading itself *throws*, and
339
+ a rejected write leaves reads working and returning `null`. Reported on objectui#5279
340
+ as a separate defect; it is not fixed here.
341
+
342
+ Case 1 is not theoretical in this codebase: `app-shell`'s `MetadataProvider` issues its
343
+ eager `app` metadata fetch inside exactly this window, which is why its first-boot cache
344
+ scope needed its own reasoning
345
+ ([`packages/app-shell/src/providers/MetadataProvider.tsx`](../app-shell/src/providers/MetadataProvider.tsx),
346
+ pinned by `MetadataProvider.firstBootOrgScope.test.tsx`).
347
+
348
+ **What a reader observes in the window:** the header is **absent**, never
349
+ present-and-empty. A resolver must fall through to its next configured identification
350
+ source — `subdomain` already outranks it by default, with `jwt_claim`, `session` and
351
+ `defaultTenantId` behind it — rather than fail closed on the absence. It must also not
352
+ cache a routing decision taken inside the window as *the* tenant for the session: the very
353
+ next request will normally carry the header.
354
+
355
+ **Why the gap does not corrupt data scoping.** A response computed inside the window is
356
+ still computed for the right tenant, because the framework takes `tenantId` from the
357
+ session rather than from the header. The gap is a *routing-input* gap, not a scoping gap.
358
+ This is the same reasoning objectui#5243 relied on when it relabelled a metadata cache
359
+ entry that had been written in the window.
360
+
361
+ **Closing the gap is a separate decision, not an omission.** The cloud readers observe the
362
+ current behaviour, so changing when the header first appears changes what they see. If it
363
+ should be closed, it needs its own card.
364
+
140
365
  ## Server Feature Flags (`GET /auth/config`)
141
366
 
142
367
  `createAuthClient().getConfig()` fetches the server's public auth configuration. The
@@ -177,13 +402,16 @@ not a gap.
177
402
 
178
403
  Preview mode allows visitors (e.g. marketplace customers) to explore the platform without registering or logging in. The `AuthProvider` auto-authenticates with a simulated user identity and bypasses login/registration screens.
179
404
 
180
- This feature aligns with the `PreviewModeConfig` from `@objectstack/spec/kernel` ([spec PR #676](https://github.com/objectstack-ai/spec/pull/676)).
405
+ This capability is host-supplied and has no `@objectstack/spec` anchor. It aligned with `PreviewModeConfig` from `@objectstack/spec/kernel` until that symbol was retired upstream (objectstack#11846), which removed it together with the `RuntimeMode` value `'preview'`; the spec this package resolves no longer exports it. The `previewMode` prop below is unaffected — it is the host's to supply, and always was.
181
406
 
182
407
  ### Usage
183
408
 
184
409
  ```tsx
410
+ import type { ReactNode } from 'react';
185
411
  import { AuthProvider, PreviewBanner } from '@object-ui/auth';
186
412
 
413
+ declare const Dashboard: () => ReactNode;
414
+
187
415
  function App() {
188
416
  return (
189
417
  <AuthProvider
@@ -229,6 +457,8 @@ import { PreviewBanner } from '@object-ui/auth';
229
457
  Use the `useAuth` hook to check if the app is in preview mode:
230
458
 
231
459
  ```tsx
460
+ import { useAuth } from '@object-ui/auth';
461
+
232
462
  function MyComponent() {
233
463
  const { isPreviewMode, previewMode } = useAuth();
234
464
 
@@ -0,0 +1,199 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Drop the outgoing user's client state wholesale — part 3 of the fix.
10
+ *
11
+ * Order is load-bearing and mirrors `clear()`'s: the in-memory active org goes
12
+ * FIRST, because `ActiveOrganizationStorage.get()` falls through to
13
+ * `_memoryValue` whenever the persisted read is null, which is exactly the
14
+ * state this function leaves behind (objectui#5703). Sweeping storage first
15
+ * would leave the outgoing user's org id live in memory for the rest of the
16
+ * page-load — the SPA sign-out-then-sign-in path never reloads.
17
+ *
18
+ * Both stores are swept. `sessionStorage` is per-TAB, not per-session, so the
19
+ * previous user's `objectui:metadata:*` seed (their PERMISSION-FILTERED app
20
+ * list) and their `objectui-ctx-*` scope picks survive into whoever signs in
21
+ * next in that tab unless something removes them.
22
+ */
23
+ export declare function purgePreviousUserClientState(): void;
24
+ /**
25
+ * The number of owner changes {@link SessionUserScope.adopt} has performed in
26
+ * this page-load — `0` until a DIFFERENT user than the one the browser
27
+ * previously held is adopted.
28
+ *
29
+ * ## Why a count and not only an event (objectui#10193)
30
+ *
31
+ * The purge sweeps the previous owner's STORAGE, but state that was already
32
+ * derived from that storage before the purge — the UI language the boot
33
+ * resolved out of the previous owner's language slots, above all — lives in
34
+ * memory and is untouched by it. Whoever owns such state has to re-derive it,
35
+ * and the component that does may mount AFTER the purge happened (the shell
36
+ * mounts behind the session gate, the purge fires as the session resolves). A
37
+ * bare event would be missed by it; a count lets a late reader compare against
38
+ * what it has already handled. Shaped for React's `useSyncExternalStore`
39
+ * together with {@link subscribeSessionOwnerChange}.
40
+ */
41
+ export declare function getSessionOwnerChangeCount(): number;
42
+ /**
43
+ * Be told each time {@link SessionUserScope.adopt} drops a previous owner's
44
+ * state. Returns the unsubscribe function. A listener that throws is reported
45
+ * and skipped, never allowed to break the sign-in path that called `adopt`.
46
+ */
47
+ export declare function subscribeSessionOwnerChange(listener: () => void): () => void;
48
+ /**
49
+ * Which user's client state this browser holds, and the transition that drops
50
+ * the previous user's state.
51
+ */
52
+ export declare const SessionUserScope: {
53
+ /**
54
+ * Memory copy of the pointer. Read FIRST by {@link current}: once this
55
+ * page-load has adopted a user, that answer is authoritative for this tab
56
+ * even if another tab has since written a different id, and it is the only
57
+ * copy in a browser whose `localStorage` rejects writes.
58
+ */
59
+ _userId: string | null;
60
+ /** The user whose state this browser holds, or `null` if it has never held one. */
61
+ current(): string | null;
62
+ /**
63
+ * Record `userId` as the owner of this browser's client state, dropping the
64
+ * previous owner's state wholesale when it changes.
65
+ *
66
+ * Returns `true` when it dropped a previous owner's state, `false`
67
+ * otherwise (same user, first-ever owner, empty id). A drop is also reported
68
+ * to {@link subscribeSessionOwnerChange} listeners and counted by
69
+ * {@link getSessionOwnerChangeCount} (objectui#10193): the purge clears
70
+ * storage, and anything already derived from that storage in memory is the
71
+ * listener's to re-derive.
72
+ *
73
+ * Idempotent: re-adopting the same user purges nothing. Called from
74
+ * `AuthProvider` for real sessions only — the synthetic `preview-user` /
75
+ * `guest` identities never reach it, so a preview mount cannot purge a real
76
+ * user's state.
77
+ *
78
+ * Because the decision reads the PERSISTED pointer, anything that deletes
79
+ * that pointer without deleting the state it describes would silently retire
80
+ * this invariant. `apps/console/src/lib/auth-preflight.ts` is the one place
81
+ * that sweeps auth keys before render, and it says so in its own comment:
82
+ * it removes every active-org spelling and leaves `auth-session-user-id`
83
+ * alone on purpose.
84
+ */
85
+ adopt(userId: string): boolean;
86
+ /**
87
+ * Forget the in-memory pointer. Test seam only — production has no reason to
88
+ * un-know who is signed in, and sign-out deliberately KEEPS the persisted
89
+ * pointer so that the next sign-in by a DIFFERENT user still sees a previous
90
+ * owner to purge.
91
+ */
92
+ _resetForTests(): void;
93
+ };
94
+ /**
95
+ * Get/set the active organization id that {@link createAuthenticatedFetch}
96
+ * stamps as `X-Tenant-ID`.
97
+ *
98
+ * `AuthProvider` is the only writer, at four moments: `refreshOrganizations`
99
+ * sets it once the `getSession` -> `listOrganizations` ->
100
+ * `getActiveOrganization` chain resolves (including the ADR-0081
101
+ * single-membership repair), `switchOrganization` sets or clears it,
102
+ * `deleteOrganization` / `leaveOrganization` clear it when the active org is
103
+ * the one going away, and sign-out clears it.
104
+ *
105
+ * Because the first of those is asynchronous, this reads EMPTY for the first
106
+ * stretch of a boot, and every request that leaves in that window carries no
107
+ * tenant header at all. That window is a documented part of the header's
108
+ * contract, not an accident to paper over — see the "unstamped-first-request
109
+ * gap" section of this package's README (objectui#5279).
110
+ */
111
+ export declare const ActiveOrganizationStorage: {
112
+ _memoryValue: string | null;
113
+ /**
114
+ * Keys whose removal by {@link clear} did not stick — the value was still
115
+ * readable afterwards (objectui#5731).
116
+ *
117
+ * {@link get} refuses to answer from the persisted layer for a key in here,
118
+ * which is the mechanism that keeps a cleared organization cleared. Held in
119
+ * memory, so it lasts exactly one page-load: a browser that starts fresh
120
+ * re-measures rather than inheriting a verdict.
121
+ */
122
+ _unremovedKeys: Set<string>;
123
+ /**
124
+ * The active org id for the CURRENT user, or `null`.
125
+ *
126
+ * READ ORDER — a non-null `localStorage` read wins; anything else falls
127
+ * through to `_memoryValue`. Returning the `localStorage` read
128
+ * UNCONDITIONALLY (what this did before objectui#5703) left the fallback
129
+ * reachable only when the read THREW, and there is a real browser state
130
+ * where the read does not throw and the fallback is still the only copy:
131
+ * `localStorage` present and readable but REJECTING WRITES — Safari private
132
+ * browsing, and any quota-exhausted origin, where `setItem` throws
133
+ * `QuotaExceededError`. `set()` swallows that failure into `_memoryValue`
134
+ * (correctly — the fallback is right there), and `get()` then never
135
+ * consulted it: the value was stored and could not be read back, so
136
+ * `X-Tenant-ID` went unstamped for the whole session, not just the early
137
+ * requests.
138
+ *
139
+ * WHY FALLING BACK ON `null` DOES NOT RESURRECT A CLEARED ORG. After
140
+ * `clear()` the `localStorage` read is null by construction, so this
141
+ * fallback fires — and it must answer `null`, because sign-out is one of
142
+ * `clear()`'s callers. It does, because `clear()` nulls `_memoryValue` too.
143
+ * That property is what makes this read order safe rather than an
144
+ * incidental detail of `clear()`'s body, so it is pinned by test
145
+ * (`__tests__/activeOrgStorageFallback-5703.test.tsx`) instead of being
146
+ * re-derived by whoever edits `clear()` next.
147
+ *
148
+ * The same reasoning is why a change of session user purges through
149
+ * {@link purgePreviousUserClientState} rather than by removing keys: the
150
+ * outgoing user's id in `_memoryValue` would otherwise outlive their
151
+ * persisted key on the SPA sign-out-then-sign-in path (objectui#5664).
152
+ */
153
+ get(): string | null;
154
+ /**
155
+ * With no session user known this writes to memory ONLY. That is not a
156
+ * degraded path to apologise for — it is the fix: a persisted write with no
157
+ * owner is the un-namespaced key this card retired.
158
+ */
159
+ set(orgId: string): void;
160
+ /**
161
+ * Drop the active organization, from memory and from the persisted key.
162
+ *
163
+ * POSTCONDITION: {@link get} answers `null` afterwards. Sign-out is one of
164
+ * the callers, so that is a security-relevant guarantee rather than a
165
+ * convenience — and before objectui#5731 it was not one, because a
166
+ * `removeItem` that failed was swallowed and the surviving key won `get()`'s
167
+ * read order.
168
+ *
169
+ * ## Why a failed removal is neither thrown nor returned (objectui#5731)
170
+ *
171
+ * Every caller arrives here AFTER the transition it is following up on has
172
+ * already happened, and not one of them can act on a storage failure:
173
+ *
174
+ * - `AuthProvider`'s `purgeSignedOutClientCaches` — the session is already
175
+ * ended. Sign-out cannot be refused because a key would not delete.
176
+ * - `switchOrganization` — the server already switched or cleared the
177
+ * active org; a throw would report a successful switch as a failure and
178
+ * route the caller into its error branch.
179
+ * - `deleteOrganization` / `leaveOrganization` — the org is already deleted
180
+ * or already left.
181
+ * - {@link purgePreviousUserClientState}, via {@link SessionUserScope.adopt}
182
+ * — runs on the SIGN-IN path inside an `AuthProvider` effect, where a
183
+ * throw breaks the boot of the ARRIVING user.
184
+ *
185
+ * A `boolean` return only moves the problem one step: all five call sites
186
+ * would have nothing to write in the failure branch, and a return value that
187
+ * every caller ignores reads as handled when it is not. So the invariant is
188
+ * restored HERE, where it can be, and the failure is reported to the console,
189
+ * where a human can find it.
190
+ *
191
+ * The third shape considered and rejected was re-writing the key with an
192
+ * empty value. That relocates the fix into every consumer's truthiness test
193
+ * (`createAuthenticatedFetch` does `if (activeOrgId)`), which is the lenient
194
+ * consumer AGENTS.md #0.1 forbids, and it leaves a signed-out browser holding
195
+ * a live key.
196
+ */
197
+ clear(): void;
198
+ };
199
+ //# sourceMappingURL=ActiveOrganizationStorage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ActiveOrganizationStorage.d.ts","sourceRoot":"","sources":["../src/ActiveOrganizationStorage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAyOH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,4BAA4B,IAAI,IAAI,CAInD;AAkBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAEnD;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAK5E;AAaD;;;GAGG;AACH,eAAO,MAAM,gBAAgB;IAC3B;;;;;OAKG;aACc,MAAM,GAAG,IAAI;IAE9B,mFAAmF;eACxE,MAAM,GAAG,IAAI;IAKxB;;;;;;;;;;;;;;;;;;;;;;OAsBG;kBACW,MAAM,GAAG,OAAO;IA+B9B;;;;;OAKG;sBACe,IAAI;CAGvB,CAAC;AAqBF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,yBAAyB;kBACd,MAAM,GAAG,IAAI;IAEnC;;;;;;;;OAQG;;IAGH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;WACI,MAAM,GAAG,IAAI;IAsBpB;;;;OAIG;eACQ,MAAM,GAAG,IAAI;IAOxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;aACM,IAAI;CAyCd,CAAC"}