@object-ui/auth 17.5.0 → 17.6.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/CHANGELOG.md +110 -0
- package/README.md +148 -0
- package/dist/ActiveOrganizationStorage.d.ts +168 -0
- package/dist/ActiveOrganizationStorage.d.ts.map +1 -0
- package/dist/ActiveOrganizationStorage.js +515 -0
- package/dist/AuthContext.d.ts +19 -1
- package/dist/AuthContext.d.ts.map +1 -1
- package/dist/AuthGuard.d.ts +8 -1
- package/dist/AuthGuard.d.ts.map +1 -1
- package/dist/AuthGuard.js +16 -3
- package/dist/AuthProvider.d.ts +1 -1
- package/dist/AuthProvider.d.ts.map +1 -1
- package/dist/AuthProvider.js +272 -7
- package/dist/ForgotPasswordForm.d.ts +1 -1
- package/dist/ForgotPasswordForm.d.ts.map +1 -1
- package/dist/ForgotPasswordForm.js +5 -5
- package/dist/LoginForm.d.ts +1 -1
- package/dist/LoginForm.d.ts.map +1 -1
- package/dist/LoginForm.js +4 -4
- package/dist/PreviewBanner.js +1 -1
- package/dist/RegisterForm.d.ts +1 -1
- package/dist/RegisterForm.d.ts.map +1 -1
- package/dist/RegisterForm.js +3 -3
- package/dist/SocialSignInButtons.js +2 -2
- package/dist/UserMenu.js +2 -2
- package/dist/createAuthClient.d.ts +1 -1
- package/dist/createAuthClient.d.ts.map +1 -1
- package/dist/createAuthClient.js +36 -4
- package/dist/createAuthenticatedFetch.d.ts +9 -9
- package/dist/createAuthenticatedFetch.d.ts.map +1 -1
- package/dist/createAuthenticatedFetch.js +49 -37
- package/dist/index.d.ts +20 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -17
- package/dist/invitation-status.d.ts +59 -0
- package/dist/invitation-status.d.ts.map +1 -0
- package/dist/invitation-status.js +32 -0
- package/dist/types.d.ts +11 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/useAuth.d.ts +1 -1
- package/dist/useAuth.d.ts.map +1 -1
- package/dist/useAuth.js +5 -1
- package/dist/useWorkspaceAdminStatus.d.ts +85 -0
- package/dist/useWorkspaceAdminStatus.d.ts.map +1 -0
- package/dist/useWorkspaceAdminStatus.js +102 -0
- package/package.json +4 -4
- package/dist/useIsWorkspaceAdmin.d.ts +0 -21
- package/dist/useIsWorkspaceAdmin.d.ts.map +0 -1
- package/dist/useIsWorkspaceAdmin.js +0 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
1
|
# @object-ui/auth
|
|
2
2
|
|
|
3
|
+
## 17.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1b21b1a: `AuthInvitation.status` is the closed four-member union it always documented, enforced at the auth client's wire boundary.
|
|
8
|
+
|
|
9
|
+
The field was declared `status: string` while its own doc comment enumerated
|
|
10
|
+
`'pending' | 'accepted' | 'rejected' | 'canceled'`, and `createAuthClient` cast
|
|
11
|
+
better-auth's `any` responses straight to `AuthInvitation[]` with no check. The
|
|
12
|
+
enumeration was therefore advisory: any value a backend stored reached the
|
|
13
|
+
console untouched, where the invitations screen coloured it through a
|
|
14
|
+
`default: 'secondary'` badge arm and printed it with
|
|
15
|
+
`defaultValue: inv.status` — the raw wire string rendered as interface copy, in
|
|
16
|
+
all ten packs (objectui#3879). Nobody could trigger it today, because the four
|
|
17
|
+
are what better-auth writes; it was the contract that was loose, and a loose
|
|
18
|
+
consumer is where an unexpected value would have hidden.
|
|
19
|
+
|
|
20
|
+
`AuthInvitationStatus` now carries the union and **binds better-auth's own
|
|
21
|
+
`InvitationStatus`** rather than restating it, the way `org-roles.ts` binds the
|
|
22
|
+
spec's `BUILTIN_MEMBERSHIP_ROLES` — a member-for-member copy is the state one
|
|
23
|
+
upstream release away from drifting silently. The runtime list
|
|
24
|
+
(`AUTH_INVITATION_STATUSES`) is derived from a total map keyed by that union, so
|
|
25
|
+
a fifth status upstream is a build failure here rather than a gap in the guard.
|
|
26
|
+
`isAuthInvitationStatus` is exported alongside, and all four invitation-returning
|
|
27
|
+
client methods (`listInvitations`, `listUserInvitations`, `getInvitation`,
|
|
28
|
+
`inviteMember`) narrow their wire rows through it.
|
|
29
|
+
|
|
30
|
+
Behaviour change, stated because it is one: an invitation whose `status` is
|
|
31
|
+
outside the set now **fails loudly** — the call rejects with a message naming the
|
|
32
|
+
value it refused and the four it expected — instead of resolving into a badge.
|
|
33
|
+
Both call paths already render a rejection with a retry, so the throw lands on a
|
|
34
|
+
designed surface. Degrading quietly was the alternative and was deliberately not
|
|
35
|
+
taken: a neutral label is how the raw value shipped in the first place, and
|
|
36
|
+
dropping the row would delete an invitation from an administrative ledger without
|
|
37
|
+
saying so. The console's badge switch is exhaustive over the union now and has no
|
|
38
|
+
`default:` arm, so if better-auth ever adds a member the type-check gate stops the
|
|
39
|
+
build at the one place a human has to choose a colour.
|
|
40
|
+
|
|
41
|
+
Consumers assigning an arbitrary string to `AuthInvitation.status` (a hand-built
|
|
42
|
+
fixture, a mock) will need one of the four members.
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- 5458414: Publish relative import specifiers with explicit `.js` extensions so these six packages load under plain Node ESM.
|
|
47
|
+
|
|
48
|
+
Node's ESM resolver does not extension-search relative specifiers and `tsc` never rewrites them, so an extensionless `./Foo` in the source shipped as an extensionless `./Foo` in `dist` and importing the package entry outside a bundler failed with `ERR_MODULE_NOT_FOUND`. Bundled consumers were unaffected. Unbundled consumers — plain Node ESM, an SSR host importing the package directly, anyone running the published tarball without a build step — can now import these entries, and so can the downstream `@object-ui/plugin-*` packages that evaluate through `mobile`, `permissions` and `providers`.
|
|
49
|
+
- 61b097c: Sign-out now drops the client-side caches that belonged to the session it ends,
|
|
50
|
+
and the metadata seed cache is keyed by session identity.
|
|
51
|
+
|
|
52
|
+
`sessionStorage` is per-tab, not per-session, and no sign-out call site reloads
|
|
53
|
+
the page — so the `objectui:metadata:*` entries (the app list the server
|
|
54
|
+
permission-filters per session) and the active-organization id survived into
|
|
55
|
+
whatever happened next in that tab. A second person signing in on a shared,
|
|
56
|
+
kiosk or handover browser was seeded with the previous user's filtered app list.
|
|
57
|
+
Organization scoping did not close this: two users in the same organization
|
|
58
|
+
computed the same cache key.
|
|
59
|
+
|
|
60
|
+
`AuthProvider.signOut()` now purges the `objectui:metadata:` entries and clears
|
|
61
|
+
`ActiveOrganizationStorage` (and the in-memory organization block) on both the
|
|
62
|
+
success and failure paths, and `MetadataProvider` keys each entry by a
|
|
63
|
+
fingerprint of the session token, so an entry that escapes the purge is
|
|
64
|
+
unreadable by the next principal rather than merely undeleted. Entries left by
|
|
65
|
+
another principal are deleted the first time a console mounts.
|
|
66
|
+
- a9e17b4: `auth.forgotPassword.successDescription`'s address hole is now spelled with
|
|
67
|
+
single braces (`{email}`) instead of i18next's double braces (`{{email}}`),
|
|
68
|
+
in all ten locale packs.
|
|
69
|
+
|
|
70
|
+
This is a spelling-only change — rendered output is byte-identical in every
|
|
71
|
+
language, because the hole was never filled by i18next in the first place:
|
|
72
|
+
`ForgotPasswordForm` substitutes the address itself once the user submits
|
|
73
|
+
the form (the label renders before the address exists, so `t()` cannot do
|
|
74
|
+
it). `{{email}}` and a genuinely unfilled i18next hole were indistinguishable
|
|
75
|
+
at the call site, and passing `email` as an interpolation argument — the
|
|
76
|
+
natural "fix" for what looks like a missing argument — would let i18next
|
|
77
|
+
consume the hole and cause the address to be appended a second time
|
|
78
|
+
(objectui#4135).
|
|
79
|
+
|
|
80
|
+
Converging on `{x}` for every hole a component fills downstream of `t()`
|
|
81
|
+
(the convention `resendOtpCountdownText`'s `{seconds}` already used) puts
|
|
82
|
+
this hole outside i18next's `{{…}}` syntax entirely, so the ambiguity is
|
|
83
|
+
gone by construction rather than fenced by an exemption. Accordingly,
|
|
84
|
+
`scripts/check-i18n-call-site-keys.mjs`'s `EXTERNALLY_INTERPOLATED_HOLES`
|
|
85
|
+
registry entry for this key is retired — the gate needs no exemption for a
|
|
86
|
+
hole i18next was never going to touch.
|
|
87
|
+
|
|
88
|
+
`ForgotPasswordForm.tsx`'s replacement marker and its own built-in default
|
|
89
|
+
label move to the same spelling in the same change, as does the inline
|
|
90
|
+
`defaultValue` at `apps/console/src/pages/auth/ForgotPasswordPage.tsx`'s
|
|
91
|
+
call site.
|
|
92
|
+
- Updated dependencies [88085e3]
|
|
93
|
+
- Updated dependencies [279fb13]
|
|
94
|
+
- Updated dependencies [1184192]
|
|
95
|
+
- Updated dependencies [a2a9747]
|
|
96
|
+
- Updated dependencies [af5e292]
|
|
97
|
+
- Updated dependencies [7f96b10]
|
|
98
|
+
- Updated dependencies [f1d4748]
|
|
99
|
+
- Updated dependencies [578e025]
|
|
100
|
+
- Updated dependencies [598c89a]
|
|
101
|
+
- Updated dependencies [b8b9af4]
|
|
102
|
+
- Updated dependencies [97abb24]
|
|
103
|
+
- Updated dependencies [deb157a]
|
|
104
|
+
- Updated dependencies [d2ce342]
|
|
105
|
+
- Updated dependencies [9695da7]
|
|
106
|
+
- Updated dependencies [58b8346]
|
|
107
|
+
- Updated dependencies [3cf4de0]
|
|
108
|
+
- Updated dependencies [c9dc811]
|
|
109
|
+
- Updated dependencies [a0b9e91]
|
|
110
|
+
- Updated dependencies [99bd015]
|
|
111
|
+
- @object-ui/types@17.6.0
|
|
112
|
+
|
|
3
113
|
## 17.5.0
|
|
4
114
|
|
|
5
115
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -137,6 +137,154 @@ const authedFetch = createAuthenticatedFetch();
|
|
|
137
137
|
const apiProviderFetch = createAuthenticatedFetch({ sameOriginOnly: true });
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
## The `X-Tenant-ID` edge contract
|
|
141
|
+
|
|
142
|
+
`createAuthenticatedFetch` stamps `X-Tenant-ID` on the requests it wraps. This section is
|
|
143
|
+
that header's contract: what it means, who stamps it, who reads it, what a reader may
|
|
144
|
+
assume, and the one window in which it is not sent at all.
|
|
145
|
+
|
|
146
|
+
It is written down because **a grep cannot answer any of those questions from this
|
|
147
|
+
repository.** The header's only non-CORS consumer lives in the **cloud** repository, so a
|
|
148
|
+
search confined to this repo and the framework (`objectstack`) finds zero readers and
|
|
149
|
+
reads as *"nothing consumes this stamp"*. That reading is what
|
|
150
|
+
[objectui#5279](https://github.com/objectstack-ai/objectui/issues/5279) was filed on, and
|
|
151
|
+
it is false. Do not act on it.
|
|
152
|
+
|
|
153
|
+
### What it means
|
|
154
|
+
|
|
155
|
+
A **routing hint** for the hosting edge — *this request belongs to tenant `<id>`*:
|
|
156
|
+
|
|
157
|
+
```http
|
|
158
|
+
X-Tenant-ID: <better-auth activeOrganizationId>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The value is the active **organization** id, the same id the session carries as
|
|
162
|
+
`session.activeOrganizationId`. The header is **not** an identity claim, **not** an
|
|
163
|
+
authorization input, and **not** what scopes rows.
|
|
164
|
+
|
|
165
|
+
### Who stamps it
|
|
166
|
+
|
|
167
|
+
| | |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| Stamped by | `createAuthenticatedFetch` ([`src/createAuthenticatedFetch.ts`](src/createAuthenticatedFetch.ts)) |
|
|
170
|
+
| 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) |
|
|
171
|
+
| Condition | that storage holds a non-empty value |
|
|
172
|
+
| *Not* conditioned on | the URL being an `/api/` call. `Authorization` and `Accept-Language` are; this is not |
|
|
173
|
+
| Suppressed by | `createAuthenticatedFetch({ sameOriginOnly: true })` for cross-origin URLs — it returns before any header work |
|
|
174
|
+
| Precedence | overwrites an `X-Tenant-ID` the caller passed in `init.headers` |
|
|
175
|
+
|
|
176
|
+
`ActiveOrganizationStorage` has exactly one writer, [`AuthProvider`](src/AuthProvider.tsx):
|
|
177
|
+
|
|
178
|
+
| Event | Effect |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| `refreshOrganizations` — after `getSession` → `listOrganizations` → `getActiveOrganization` resolves (including the ADR-0081 single-membership repair) | set |
|
|
181
|
+
| `switchOrganization` — the org switcher | set, or clear when the server returns no org |
|
|
182
|
+
| `deleteOrganization` / `leaveOrganization`, when the active org is the one going away | clear |
|
|
183
|
+
| sign-out | clear |
|
|
184
|
+
|
|
185
|
+
### Who reads it
|
|
186
|
+
|
|
187
|
+
**The cloud edge — yes.** The non-test readers are recorded on objectui#5279:
|
|
188
|
+
`packages/service-tenant/src/tenant-context.ts` (header resolution) and
|
|
189
|
+
`packages/tenant-router/src/spec/turso-multi-tenant.zod.ts`, both in the `cloud`
|
|
190
|
+
repository. That repository is not readable from this one, so those paths are cited as the
|
|
191
|
+
recorded reading rather than re-derived here.
|
|
192
|
+
|
|
193
|
+
**Its configuration contract, though, IS readable from here**, because this package
|
|
194
|
+
depends on `@objectstack/spec`. `TenantRoutingConfigSchema` (`@objectstack/spec/cloud`) is
|
|
195
|
+
what configures a tenant resolver; parsing an empty config on the version this package
|
|
196
|
+
resolves today (17.1.0) yields:
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
enabled: false
|
|
200
|
+
identificationSources: ["subdomain", "header", "jwt_claim"]
|
|
201
|
+
tenantHeaderName: "X-Tenant-ID"
|
|
202
|
+
jwtOrganizationClaim: "organizationId"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Two consequences matter to a client author:
|
|
206
|
+
|
|
207
|
+
- **The header name is configurable.** `X-Tenant-ID` is its default, not a constant.
|
|
208
|
+
- **The header is one of six identification sources** (`subdomain`, `custom_domain`,
|
|
209
|
+
`header`, `jwt_claim`, `session`, `default`), and in the default precedence it ranks
|
|
210
|
+
**second, behind `subdomain`**. On a subdomain-routed deployment it is not the thing
|
|
211
|
+
that picks the tenant, and a deployment may leave it out of `identificationSources`
|
|
212
|
+
entirely.
|
|
213
|
+
|
|
214
|
+
**The framework (`objectstack`) — no.** `resolveAuthzContext`
|
|
215
|
+
(`packages/core/src/security/resolve-authz-context.ts`) derives `tenantId` from the
|
|
216
|
+
API-key principal or from `session.activeOrganizationId`, and from no header;
|
|
217
|
+
`packages/verify/src/harness.org-context.test.ts` pins it — *"`session.activeOrganizationId`
|
|
218
|
+
is the ONE field `resolveAuthzContext` reads into `tenantId`"*. Environment and kernel
|
|
219
|
+
routing read the hostname and `X-Environment-Id`. The framework's only other mentions are
|
|
220
|
+
the CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS`, whose comment describes
|
|
221
|
+
`X-Tenant-ID` / `X-Environment-Id` as what routes "a request to its environment") and
|
|
222
|
+
`plugin-sharing`, which records that trusting `x-tenant-id` as identity **was a
|
|
223
|
+
vulnerability**: its secure default stopped reading identity from headers because doing so
|
|
224
|
+
let a client forge attribution and enumerate or revoke other users' links.
|
|
225
|
+
|
|
226
|
+
### What a reader may assume
|
|
227
|
+
|
|
228
|
+
A reader **may**:
|
|
229
|
+
|
|
230
|
+
- use it to select the tenant database or environment to route to, subject to its own
|
|
231
|
+
`identificationSources` precedence;
|
|
232
|
+
- treat it as a hint that may be absent, stale, or contradicted by the session.
|
|
233
|
+
|
|
234
|
+
A reader **may not**:
|
|
235
|
+
|
|
236
|
+
- treat it as authenticated identity, or as an authorization decision. It is
|
|
237
|
+
client-controlled — any caller can send any value, and `plugin-sharing` is the recorded
|
|
238
|
+
precedent for what happens to a server that trusts it;
|
|
239
|
+
- assume the row scoping it sees downstream came from this header. It did not: the
|
|
240
|
+
framework scopes from the session;
|
|
241
|
+
- assume the header is present. See below.
|
|
242
|
+
|
|
243
|
+
### The unstamped-first-request gap
|
|
244
|
+
|
|
245
|
+
The stamp reads storage that `AuthProvider` fills only **after** `getSession` →
|
|
246
|
+
`listOrganizations` → `getActiveOrganization` resolves. Every request that leaves
|
|
247
|
+
before that chain completes carries **no** `X-Tenant-ID` at all.
|
|
248
|
+
|
|
249
|
+
The window opens in five situations:
|
|
250
|
+
|
|
251
|
+
1. a browser that has never signed in, or whose site data was cleared, including a private
|
|
252
|
+
window;
|
|
253
|
+
2. the page load in which the user signs in — from the sign-in response until the
|
|
254
|
+
organization chain resolves;
|
|
255
|
+
3. after sign-out, which clears the storage, until a new organization resolves;
|
|
256
|
+
4. outside a browser (SSR, tests, a worker), where `localStorage` is unavailable and the
|
|
257
|
+
storage falls back to a per-process in-memory value that starts empty on every cold
|
|
258
|
+
start;
|
|
259
|
+
5. a browser where `localStorage` exists but **rejects writes** (Safari private browsing,
|
|
260
|
+
quota exhaustion). There the window never closes — the in-memory fallback is written
|
|
261
|
+
but never read back, because `get()` only falls back when reading itself *throws*, and
|
|
262
|
+
a rejected write leaves reads working and returning `null`. Reported on objectui#5279
|
|
263
|
+
as a separate defect; it is not fixed here.
|
|
264
|
+
|
|
265
|
+
Case 1 is not theoretical in this codebase: `app-shell`'s `MetadataProvider` issues its
|
|
266
|
+
eager `app` metadata fetch inside exactly this window, which is why its first-boot cache
|
|
267
|
+
scope needed its own reasoning
|
|
268
|
+
([`packages/app-shell/src/providers/MetadataProvider.tsx`](../app-shell/src/providers/MetadataProvider.tsx),
|
|
269
|
+
pinned by `MetadataProvider.firstBootOrgScope.test.tsx`).
|
|
270
|
+
|
|
271
|
+
**What a reader observes in the window:** the header is **absent**, never
|
|
272
|
+
present-and-empty. A resolver must fall through to its next configured identification
|
|
273
|
+
source — `subdomain` already outranks it by default, with `jwt_claim`, `session` and
|
|
274
|
+
`defaultTenantId` behind it — rather than fail closed on the absence. It must also not
|
|
275
|
+
cache a routing decision taken inside the window as *the* tenant for the session: the very
|
|
276
|
+
next request will normally carry the header.
|
|
277
|
+
|
|
278
|
+
**Why the gap does not corrupt data scoping.** A response computed inside the window is
|
|
279
|
+
still computed for the right tenant, because the framework takes `tenantId` from the
|
|
280
|
+
session rather than from the header. The gap is a *routing-input* gap, not a scoping gap.
|
|
281
|
+
This is the same reasoning objectui#5243 relied on when it relabelled a metadata cache
|
|
282
|
+
entry that had been written in the window.
|
|
283
|
+
|
|
284
|
+
**Closing the gap is a separate decision, not an omission.** The cloud readers observe the
|
|
285
|
+
current behaviour, so changing when the header first appears changes what they see. If it
|
|
286
|
+
should be closed, it needs its own card.
|
|
287
|
+
|
|
140
288
|
## Server Feature Flags (`GET /auth/config`)
|
|
141
289
|
|
|
142
290
|
`createAuthClient().getConfig()` fetches the server's public auth configuration. The
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
* Which user's client state this browser holds, and the transition that drops
|
|
26
|
+
* the previous user's state.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SessionUserScope: {
|
|
29
|
+
/**
|
|
30
|
+
* Memory copy of the pointer. Read FIRST by {@link current}: once this
|
|
31
|
+
* page-load has adopted a user, that answer is authoritative for this tab
|
|
32
|
+
* even if another tab has since written a different id, and it is the only
|
|
33
|
+
* copy in a browser whose `localStorage` rejects writes.
|
|
34
|
+
*/
|
|
35
|
+
_userId: string | null;
|
|
36
|
+
/** The user whose state this browser holds, or `null` if it has never held one. */
|
|
37
|
+
current(): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Record `userId` as the owner of this browser's client state, dropping the
|
|
40
|
+
* previous owner's state wholesale when it changes.
|
|
41
|
+
*
|
|
42
|
+
* Idempotent: re-adopting the same user purges nothing. Called from
|
|
43
|
+
* `AuthProvider` for real sessions only — the synthetic `preview-user` /
|
|
44
|
+
* `guest` identities never reach it, so a preview mount cannot purge a real
|
|
45
|
+
* user's state.
|
|
46
|
+
*
|
|
47
|
+
* Because the decision reads the PERSISTED pointer, anything that deletes
|
|
48
|
+
* that pointer without deleting the state it describes would silently retire
|
|
49
|
+
* this invariant. `apps/console/src/lib/auth-preflight.ts` is the one place
|
|
50
|
+
* that sweeps auth keys before render, and it says so in its own comment:
|
|
51
|
+
* it removes every active-org spelling and leaves `auth-session-user-id`
|
|
52
|
+
* alone on purpose.
|
|
53
|
+
*/
|
|
54
|
+
adopt(userId: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Forget the in-memory pointer. Test seam only — production has no reason to
|
|
57
|
+
* un-know who is signed in, and sign-out deliberately KEEPS the persisted
|
|
58
|
+
* pointer so that the next sign-in by a DIFFERENT user still sees a previous
|
|
59
|
+
* owner to purge.
|
|
60
|
+
*/
|
|
61
|
+
_resetForTests(): void;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Get/set the active organization id that {@link createAuthenticatedFetch}
|
|
65
|
+
* stamps as `X-Tenant-ID`.
|
|
66
|
+
*
|
|
67
|
+
* `AuthProvider` is the only writer, at four moments: `refreshOrganizations`
|
|
68
|
+
* sets it once the `getSession` -> `listOrganizations` ->
|
|
69
|
+
* `getActiveOrganization` chain resolves (including the ADR-0081
|
|
70
|
+
* single-membership repair), `switchOrganization` sets or clears it,
|
|
71
|
+
* `deleteOrganization` / `leaveOrganization` clear it when the active org is
|
|
72
|
+
* the one going away, and sign-out clears it.
|
|
73
|
+
*
|
|
74
|
+
* Because the first of those is asynchronous, this reads EMPTY for the first
|
|
75
|
+
* stretch of a boot, and every request that leaves in that window carries no
|
|
76
|
+
* tenant header at all. That window is a documented part of the header's
|
|
77
|
+
* contract, not an accident to paper over — see the "unstamped-first-request
|
|
78
|
+
* gap" section of this package's README (objectui#5279).
|
|
79
|
+
*/
|
|
80
|
+
export declare const ActiveOrganizationStorage: {
|
|
81
|
+
_memoryValue: string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Keys whose removal by {@link clear} did not stick — the value was still
|
|
84
|
+
* readable afterwards (objectui#5731).
|
|
85
|
+
*
|
|
86
|
+
* {@link get} refuses to answer from the persisted layer for a key in here,
|
|
87
|
+
* which is the mechanism that keeps a cleared organization cleared. Held in
|
|
88
|
+
* memory, so it lasts exactly one page-load: a browser that starts fresh
|
|
89
|
+
* re-measures rather than inheriting a verdict.
|
|
90
|
+
*/
|
|
91
|
+
_unremovedKeys: Set<string>;
|
|
92
|
+
/**
|
|
93
|
+
* The active org id for the CURRENT user, or `null`.
|
|
94
|
+
*
|
|
95
|
+
* READ ORDER — a non-null `localStorage` read wins; anything else falls
|
|
96
|
+
* through to `_memoryValue`. Returning the `localStorage` read
|
|
97
|
+
* UNCONDITIONALLY (what this did before objectui#5703) left the fallback
|
|
98
|
+
* reachable only when the read THREW, and there is a real browser state
|
|
99
|
+
* where the read does not throw and the fallback is still the only copy:
|
|
100
|
+
* `localStorage` present and readable but REJECTING WRITES — Safari private
|
|
101
|
+
* browsing, and any quota-exhausted origin, where `setItem` throws
|
|
102
|
+
* `QuotaExceededError`. `set()` swallows that failure into `_memoryValue`
|
|
103
|
+
* (correctly — the fallback is right there), and `get()` then never
|
|
104
|
+
* consulted it: the value was stored and could not be read back, so
|
|
105
|
+
* `X-Tenant-ID` went unstamped for the whole session, not just the early
|
|
106
|
+
* requests.
|
|
107
|
+
*
|
|
108
|
+
* WHY FALLING BACK ON `null` DOES NOT RESURRECT A CLEARED ORG. After
|
|
109
|
+
* `clear()` the `localStorage` read is null by construction, so this
|
|
110
|
+
* fallback fires — and it must answer `null`, because sign-out is one of
|
|
111
|
+
* `clear()`'s callers. It does, because `clear()` nulls `_memoryValue` too.
|
|
112
|
+
* That property is what makes this read order safe rather than an
|
|
113
|
+
* incidental detail of `clear()`'s body, so it is pinned by test
|
|
114
|
+
* (`__tests__/activeOrgStorageFallback-5703.test.tsx`) instead of being
|
|
115
|
+
* re-derived by whoever edits `clear()` next.
|
|
116
|
+
*
|
|
117
|
+
* The same reasoning is why a change of session user purges through
|
|
118
|
+
* {@link purgePreviousUserClientState} rather than by removing keys: the
|
|
119
|
+
* outgoing user's id in `_memoryValue` would otherwise outlive their
|
|
120
|
+
* persisted key on the SPA sign-out-then-sign-in path (objectui#5664).
|
|
121
|
+
*/
|
|
122
|
+
get(): string | null;
|
|
123
|
+
/**
|
|
124
|
+
* With no session user known this writes to memory ONLY. That is not a
|
|
125
|
+
* degraded path to apologise for — it is the fix: a persisted write with no
|
|
126
|
+
* owner is the un-namespaced key this card retired.
|
|
127
|
+
*/
|
|
128
|
+
set(orgId: string): void;
|
|
129
|
+
/**
|
|
130
|
+
* Drop the active organization, from memory and from the persisted key.
|
|
131
|
+
*
|
|
132
|
+
* POSTCONDITION: {@link get} answers `null` afterwards. Sign-out is one of
|
|
133
|
+
* the callers, so that is a security-relevant guarantee rather than a
|
|
134
|
+
* convenience — and before objectui#5731 it was not one, because a
|
|
135
|
+
* `removeItem` that failed was swallowed and the surviving key won `get()`'s
|
|
136
|
+
* read order.
|
|
137
|
+
*
|
|
138
|
+
* ## Why a failed removal is neither thrown nor returned (objectui#5731)
|
|
139
|
+
*
|
|
140
|
+
* Every caller arrives here AFTER the transition it is following up on has
|
|
141
|
+
* already happened, and not one of them can act on a storage failure:
|
|
142
|
+
*
|
|
143
|
+
* - `AuthProvider`'s `purgeSignedOutClientCaches` — the session is already
|
|
144
|
+
* ended. Sign-out cannot be refused because a key would not delete.
|
|
145
|
+
* - `switchOrganization` — the server already switched or cleared the
|
|
146
|
+
* active org; a throw would report a successful switch as a failure and
|
|
147
|
+
* route the caller into its error branch.
|
|
148
|
+
* - `deleteOrganization` / `leaveOrganization` — the org is already deleted
|
|
149
|
+
* or already left.
|
|
150
|
+
* - {@link purgePreviousUserClientState}, via {@link SessionUserScope.adopt}
|
|
151
|
+
* — runs on the SIGN-IN path inside an `AuthProvider` effect, where a
|
|
152
|
+
* throw breaks the boot of the ARRIVING user.
|
|
153
|
+
*
|
|
154
|
+
* A `boolean` return only moves the problem one step: all five call sites
|
|
155
|
+
* would have nothing to write in the failure branch, and a return value that
|
|
156
|
+
* every caller ignores reads as handled when it is not. So the invariant is
|
|
157
|
+
* restored HERE, where it can be, and the failure is reported to the console,
|
|
158
|
+
* where a human can find it.
|
|
159
|
+
*
|
|
160
|
+
* The third shape considered and rejected was re-writing the key with an
|
|
161
|
+
* empty value. That relocates the fix into every consumer's truthiness test
|
|
162
|
+
* (`createAuthenticatedFetch` does `if (activeOrgId)`), which is the lenient
|
|
163
|
+
* consumer AGENTS.md #0.1 forbids, and it leaves a signed-out browser holding
|
|
164
|
+
* a live key.
|
|
165
|
+
*/
|
|
166
|
+
clear(): void;
|
|
167
|
+
};
|
|
168
|
+
//# 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;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB;IAC3B;;;;;OAKG;aACc,MAAM,GAAG,IAAI;IAE9B,mFAAmF;eACxE,MAAM,GAAG,IAAI;IAKxB;;;;;;;;;;;;;;;OAeG;kBACW,MAAM,GAAG,IAAI;IA0B3B;;;;;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"}
|