@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/CHANGELOG.md CHANGED
@@ -1,5 +1,605 @@
1
1
  # @object-ui/auth
2
2
 
3
+ ## 17.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b57107d: fix(auth,app-shell,console): a browser that changes hands no longer keeps the previous account's UI language
8
+
9
+ Both device language slots (`objectui-locale` and `objectui-locale-seed`) hold the
10
+ language of whoever last signed in on the browser. A boot resolves its language from
11
+ them before anyone is signed in. When the session then turns out to belong to someone
12
+ else (an SSO redirect back into the console, or a sign-in in another window),
13
+ `SessionUserScope.adopt` already purged both slots (objectui#5664). But the live
14
+ language had already been derived from them, and nothing re-derived it. A new account
15
+ with no `sys_user.locale` of its own therefore read the previous account's language
16
+ for its whole first session.
17
+
18
+ - `@object-ui/auth` (new exports, hence minor): `getSessionOwnerChangeCount()` and
19
+ `subscribeSessionOwnerChange(listener)` report that the change-of-owner purge ran
20
+ in this page-load. They are held in memory, with no new storage key.
21
+ `SessionUserScope.adopt` now returns `true` when it purged a previous owner.
22
+ - `@object-ui/app-shell`: `useSignedInUserLocale` resets the UI language on an owner
23
+ change, the same way a boot of the swept storage would resolve it. It then
24
+ re-reads the new owner's `sys_user.locale`, which applies after the reset.
25
+ - `@object-ui/console`: `LocalizationFetchProvider` and the boot-time seed fetch no
26
+ longer write an answer that was requested before an owner change. Such an answer
27
+ describes the previous owner.
28
+ - `@object-ui/i18n` (docs only): the seed was documented as the "tenant's" locale.
29
+ It is the server's resolved locale for the signed-in caller: their own
30
+ `sys_user.locale`, else `Accept-Language`, else the deployment default. The docs
31
+ now say so, and declare what a visitor who has not signed in reads: the last
32
+ owner's language, until someone else signs in.
33
+ - 7ea8118: fix(auth, console): a registration started from an invitation link comes back to the invitation after email verification
34
+
35
+ better-auth builds the verification mail's link server-side from the
36
+ `/sign-up/email` request's `callbackURL`, defaulting it to `/`. The console kept
37
+ `?redirect=/accept-invitation/ID` from the login page through `/register` to the
38
+ "check your inbox" screen, but `createAuthClient().signUp` never sent a
39
+ `callbackURL`, so the mail read `callbackURL=/` and the invitee verified onto the
40
+ workspace picker ("Create workspace") instead of the invitation.
41
+
42
+ `@object-ui/auth` (additive):
43
+
44
+ - `SignUpData.callbackURL` is forwarded verbatim to `/sign-up/email`. Absent, the
45
+ key stays off the wire and the server default is untouched.
46
+ - `useAuth().signUp(name, email, password, callbackURL?)` takes it as an optional
47
+ fourth argument.
48
+ - `RegisterForm` gains a `verificationCallbackURL` prop, forwarded to `signUp`.
49
+
50
+ `@object-ui/console`:
51
+
52
+ - `RegisterPage` passes a safe `?redirect=` as the verification callback, and the
53
+ verify-email prompt's "Resend" sends the same value. Before, Resend sent the
54
+ bare router path, which the server redirects to at the ORIGIN root, outside
55
+ the `/_console` mount.
56
+ - Both resolve the route through the new `withConsoleBaseRootRelative`. The server
57
+ never sees `<base href>`, and better-auth refuses a document-relative `./…`
58
+ callback — what `withConsoleBase` answers in the embedded build — with
59
+ `403 INVALID_CALLBACK_URL`, failing the whole sign-up.
60
+ - 328abeb: Four Console surfaces that read English under a zh-CN session now read the session's
61
+ language (objectui#10900). English stays the default.
62
+
63
+ - **The generic action success toast.** When an action declares no `successMessage`, the
64
+ runner falls back to "Action completed successfully".
65
+ That fallback now goes through a translator: `ActionRunner.setTranslator(translate)` is
66
+ new in `@object-ui/core`, and `<ActionProvider>` and `useActionRunner` in
67
+ `@object-ui/react` install the session's `t` on the runner they build. The pack key is
68
+ `actions.completedSuccessfully`. An author's `successMessage` still reaches the toast
69
+ untranslated, and a runner with no translator installed still toasts the English sentence.
70
+ - **The social sign-in buttons on the login and sign-up pages.** `SocialSignInButtons`
71
+ takes a new `buttonText` prop, a template whose `{provider}` is replaced with the
72
+ provider's display name; unset, the buttons keep "Continue with {provider}" and
73
+ "Sign up with {provider}". `LoginForm` and `RegisterForm` take a new `socialButton`
74
+ label and pass it, with their existing `orText` label, to the buttons. `orText` was
75
+ documented as the divider label but rendered nowhere; it now sets the divider under the
76
+ buttons, and its documented default is corrected from "or" to "or continue with email",
77
+ the text that divider has always shown. The console's login and sign-up pages and
78
+ `@object-ui/app-shell`'s `DefaultLoginPage` / `DefaultRegisterPage` pass the new
79
+ `auth.login.*` and `auth.register.*` `socialButton` / `orText` keys. The provider's display
80
+ name is inserted as-is: the component's own label for the branded providers it knows,
81
+ otherwise the name the server reports.
82
+ - **Build Doctor.** The build conversation's Build Doctor button (its accessible name and
83
+ both tooltips) and the title of the drawer it opens read `console.ai.buildDoctor`,
84
+ `console.ai.buildDoctorTitle` and `console.ai.buildDoctorDisabledTitle`.
85
+ - **Setup → marketplace.** On `system/marketplace` and the pages under it, the breadcrumb
86
+ segment after System reads `console.breadcrumb.marketplace` instead of the humanized URL
87
+ slug; other `system/*` segments are unchanged. The zh marketplace search placeholder
88
+ reads 「按名称或标识搜索应用…」 instead of 「按名称或 manifest ID 搜索应用…」. The search
89
+ itself is unchanged: it matches the display name, the identifier and the description.
90
+
91
+ All ten locale packs carry the nine new keys; no existing `en` value changes.
92
+
93
+ **Clause-②: yes** — besides the nine pack keys, the public surface widens by four optional
94
+ members: `ActionRunner.setTranslator`, `SocialSignInButtonsProps.buttonText`,
95
+ `LoginFormLabels.socialButton` and `RegisterFormLabels.socialButton`. Nothing is removed,
96
+ renamed or narrowed, no accept set changes, and `@object-ui/react` exports nothing new.
97
+ - 0ea7054: Remove 37 runtime dependencies that no file in the declaring package consumes, and gate
98
+ the direction so the next one cannot land (objectui#8198).
99
+
100
+ `check:phantom-deps` judges imports that are not declared; nothing judged the reverse,
101
+ so a declaration could outlive its last consumer indefinitely. That is what happened to
102
+ `recharts` in `@object-ui/components` after objectui#7397 deleted its only importer — it
103
+ was removed by hand on objectui#7625, and nothing would have reported the next one. The
104
+ new `pnpm check:unused-deps` asks the reverse question over `dependencies` and
105
+ `optionalDependencies` of every released package.
106
+
107
+ **Potentially breaking, for consumers relying on hoisting.** Nothing these packages ship
108
+ changes: their Vite `external` predicates are path-based and never read `dependencies`,
109
+ so no built artifact moves. What changes is the install graph — a project that imports
110
+ one of the removed packages while depending only on the ObjectUI package that used to
111
+ drag it in will no longer resolve it. Declare it directly; that is the correct
112
+ dependency edge in either case. The removals, by package:
113
+
114
+ - `@object-ui/plugin-designer`: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`, `@object-ui/fields`
115
+ - `@object-ui/plugin-chatbot`: `react-markdown`, `react-syntax-highlighter`, `remark-gfm` (and the orphaned `@types/react-syntax-highlighter`)
116
+ - `@object-ui/plugin-report`: `@object-ui/plugin-grid`, `clsx`, `react-i18next`, `tailwind-merge`
117
+ - `@object-ui/plugin-map`: `@objectstack/spec`, `lucide-react`, `zod`
118
+ - `@object-ui/runner`: `class-variance-authority`, `clsx`, `tailwind-merge`
119
+ - `@object-ui/core`: `lodash`, `zod`
120
+ - `@object-ui/layout`: `clsx`, `tailwind-merge`, and `react-dom` — which it pinned at an exact version in `dependencies` while also declaring it as a peer range, i.e. a library hard-depending on the renderer it asks its host to supply
121
+ - `@object-ui/plugin-dashboard`: `clsx`, `tailwind-merge`, and the same `react-dom` defect
122
+ - `@object-ui/plugin-ai`: `@object-ui/react`, `clsx`, `tailwind-merge`
123
+ - `@object-ui/fields`: `clsx`, `tailwind-merge`
124
+ - `@object-ui/console`: `@object-ui/react-runtime`, `sucrase`
125
+ - `@object-ui/auth`: `@object-ui/types`
126
+ - `@object-ui/plugin-calendar`: `@object-ui/fields`
127
+ - `@object-ui/plugin-editor`, `@object-ui/plugin-markdown`: `@object-ui/react`
128
+ - `@object-ui/react`: `react-hook-form`
129
+
130
+ Every one was verified by a whole-package grep before removal — the name appeared nowhere
131
+ under the package but its own manifest and CHANGELOG — and the whole workspace builds,
132
+ type-checks and tests green afterwards.
133
+ - 686d5d9: `AuthGuard`'s `requiredRoles` now matches against `user.positions` — the one published spelling (framework ADR-0090 D3) — keeping the `user.role` scalar fallback only for sessions that carry no `positions` key at all (guest / legacy identities). This restores position-holder admission on protocol-17 deployments (where the retired `roles` key is never emitted) and closes the matching over-admission: a coarse scalar `role` no longer passes a gate whose position the server says the user lacks. Preview mode now emits `positions: [role]` instead of the retired `roles` key — it was the last producer — so preview identities are shaped like protocol-17 sessions for every `positions` consumer. The hand-copied `roles?: string[]` mirror key is removed from the client `AuthUser` type; breaking only for TypeScript consumers that compiled against `AuthUser['roles']` (read `positions` instead — no runtime payload ever carried `roles` at protocol 17). Maintainer ruling 2026-08-22 on objectui#5424.
134
+ - c8da8b3: Stop showing an access-denied screen to a real administrator while their
135
+ adminship is still resolving.
136
+
137
+ `useIsWorkspaceAdmin()` returned a bare `boolean`, so "the inputs have not
138
+ arrived yet" and "resolved: not an admin" were the same answer. One of its three
139
+ sources — the active organization member row — is fetched some round trips after
140
+ the session (`listOrganizations` → `getActiveOrganization` → `getActiveMember`),
141
+ so an administrator whose adminship lives only in that row rendered at least
142
+ once as a non-admin, and every gate downstream acted on it: the two marketplace
143
+ surfaces painted `MarketplaceAccessDenied`, the console chrome dropped and
144
+ re-added its admin nav entry, and `AppContent` fired a `<Navigate to="/home"
145
+ replace>` that the later flip could not undo.
146
+
147
+ **Breaking (published API, hence `minor` per this repo's version policy):**
148
+ `useIsWorkspaceAdmin(): boolean` is replaced by
149
+ `useWorkspaceAdminStatus(): { isAdmin: boolean; isResolved: boolean }`. The old
150
+ name is removed rather than kept alongside, so a call site that ignores the
151
+ third state fails to compile instead of silently refusing an administrator.
152
+
153
+ -const isAdmin = useIsWorkspaceAdmin();
154
+ +const { isAdmin, isResolved } = useWorkspaceAdminStatus();
155
+
156
+ `AuthProvider` gains `isMembershipResolved` on its context — the organization /
157
+ member pipeline has reached a terminal state — because `organizations`,
158
+ `activeOrganization` and `activeMember` read `[]` / `null` / `null` both before
159
+ the pipeline starts and after it finds nothing.
160
+
161
+ No extra wait for administrators: `isResolved` is true the instant `isAdmin` is,
162
+ so an admin the session already identifies through `positions[]` never waits on
163
+ the member row.
164
+
165
+ ### Patch Changes
166
+
167
+ - 639114c: Reconcile `@object-ui/app-shell` with `@objectstack/spec` 17.3.0 (objectui#7122).
168
+
169
+ `SchemaDiffEntryKind` gained an `unreachable` member in 17.3.0, and the external
170
+ datasource validation panel labels those kinds through a map that is TOTAL over
171
+ the union on purpose — so an upstream addition fails the build rather than
172
+ rendering a blank cell. That mechanism fired: the package did not compile against
173
+ 17.3.0 until the kind was labelled. It now reads "Not checked — remote
174
+ unreachable", following the spec's own ruling that this kind asserts nothing
175
+ about the remote schema and must never be surfaced as "schema changed": it means
176
+ introspection could not complete, which is often transient, and labelling it like
177
+ a mismatch would tell an operator to repair a schema nobody has read.
178
+
179
+ Two internal flow-inspector types were renamed (`FlowNodeLike` →
180
+ `InspectorFlowNode` / `ScopeFlowNode`) because 17.3.0 began exporting its own
181
+ `FlowNodeLike`; neither name is in this package's published entry, so no consumer
182
+ import changes.
183
+
184
+ `@object-ui/auth`: a README sentence claiming the preview-mode prop aligns with
185
+ the spec's `PreviewModeConfig` is corrected — 17.3.0 removed that symbol. The
186
+ `previewMode` prop itself is host-supplied, unchanged, and unaffected.
187
+ - 9b1b105: `useWorkspaceAdminStatus` stops deriving platform-administrator standing from a NAME in the
188
+ session's `positions[]`, and reads the ADR-0095 posture rung (`user.isPlatformAdmin`) instead.
189
+
190
+ Framework objectstack#15948 (declared BREAKING) redefined that array: it used to be the
191
+ `sys_user.role` scalar split on commas, and it is now built by `resolveUserAuthzGrants` from
192
+ ADR-0057 D4 `sys_user_position` assignments. `sys_user_position` is `apiEnabled` with
193
+ unconstrained `position` values, so a caller holding tenant-administration authority can mint a
194
+ row spelling `platform_admin` for one of their own users — and the third leg of this hook, which
195
+ scanned `positions[]` for any admin-sounding name, answered `true` for them. The server was
196
+ already fixed (`platform-admin-gate.ts` dropped its positions leg and four evaluator sites moved
197
+ to the rung), so what such a principal got was a Console painting platform-administrator surfaces
198
+ while every underlying call was refused: misleading, in the direction that reveals platform
199
+ structure to a tenant-controlled account.
200
+
201
+ The array read is narrowed rather than deleted, name by name. `org_owner` and `org_admin` stay:
202
+ `resolveUserAuthzGrants` emits exactly those from the ACTIVE organization's `sys_member.role`, a
203
+ minted spelling of them claims authority inside the tenant that the minter already holds, and they
204
+ are what lets a stamped tenant administrator resolve on the first frame (objectui#5619) instead of
205
+ waiting for the member pipeline. `platform_admin` is dropped and replaced by the rung, which is
206
+ byte-for-byte what the server's own `hasPlatformAdminStanding` returns. `owner`, `admin`,
207
+ `super_admin`, `superadmin` and `system_admin` are dropped from the array read too — no server
208
+ derivation emits any of them into `positions[]` (the membership leg normalizes `owner`/`admin` to
209
+ `org_*` first), so a hit could only ever have been a tenant-written row. All five remain valid for
210
+ the two scalar legs, where they are the raw membership role and the stored `user.role`.
211
+
212
+ The docblock that argued AGAINST reading `user.isPlatformAdmin` — on the grounds that the server
213
+ computed it as `'platform_admin' in positions`, "two spellings for one fact" — is rewritten rather
214
+ than left standing. After objectstack#15948 the flag derives from the unscoped `admin_full_access` grant and
215
+ the array does not, so they can disagree and the rung is right.
216
+
217
+ No behaviour changes for a genuine administrator: a platform administrator on a single-tenant
218
+ deployment still resolves through the flag (objectui#5389 stays green), a tenant administrator
219
+ still resolves through the member row and through `org_owner`/`org_admin`, and the objectui#5619
220
+ `isResolved` semantics are unchanged. Pinned by `__tests__/workspaceAdminRung-8291.test.tsx`.
221
+ - ebfc3c2: `ActiveOrganizationStorage.clear()` now verifies that the persisted key is actually
222
+ gone and, when it is not, both reports the failure and stops `get()` answering from
223
+ the surviving value — instead of swallowing the failed removal (objectui#5731).
224
+
225
+ `clear()` nulled `_memoryValue` and then removed the persisted key inside a
226
+ `try`/`catch` that discarded any failure. Since objectui#5703 `get()` prefers a
227
+ NON-NULL `localStorage` read and only falls through to `_memoryValue`, so the two
228
+ halves of `clear()` were not equally strong: nulling memory always sticks, while a
229
+ removal that did not stick left the key readable and the read order preferred it.
230
+ Sign-out is one of `clear()`'s five callers, so the failure mode was "sign-out does
231
+ not stick", and it was silent — the cleared organization went back on the wire as
232
+ `X-Tenant-ID` on every subsequent request.
233
+
234
+ The removal is now judged by a READ-BACK rather than by catching the throw, which is
235
+ both narrower and wider in the right directions. Wider: a wrapped or proxied
236
+ `localStorage` whose `removeItem` is a silent no-op never throws and leaves identical
237
+ residue, and is now covered. Narrower: SSR and the partitioned-iframe browser where
238
+ every operation throws have nothing readable to resurrect, were already safe, and are
239
+ not reported as failures.
240
+
241
+ A key whose removal could not be verified is quarantined in memory for the rest of the
242
+ page-load: `get()` skips the persisted branch for it and answers from `_memoryValue`,
243
+ which `clear()` has just nulled and which a later `set()` refills with the value that
244
+ write was meant to persist. The quarantine is released as soon as a removal on that key
245
+ sticks. An unstamped `X-Tenant-ID` is a documented state of the edge contract
246
+ (objectui#5279); a re-stamped signed-out organization is not.
247
+
248
+ The failure is not thrown and not returned. All five call sites — sign-out's
249
+ `purgeSignedOutClientCaches`, `switchOrganization`, `deleteOrganization`,
250
+ `leaveOrganization`, and the session-user purge that runs on the SIGN-IN path — arrive
251
+ after the transition they follow up on has already happened, and none can act on a
252
+ storage failure; a `boolean` every caller ignores would read as handled when it is not.
253
+ So the invariant is restored inside `clear()` and the failure is reported to the
254
+ console.
255
+
256
+ A working `localStorage` behaves exactly as before: the removal sticks, nothing is
257
+ quarantined, nothing is reported, and a non-null persisted read is still authoritative.
258
+ `set()`'s swallowed write failure is deliberately untouched — that swallow is
259
+ objectui#5703's memory fallback, and it is the correct kind, because the memory copy
260
+ upholds `set()`'s postcondition where nulling memory could not uphold `clear()`'s.
261
+ - 9a5d669: `ActiveOrganizationStorage.get()` now prefers a non-null `localStorage` read and falls
262
+ back to the in-memory value otherwise, instead of returning the `localStorage` read
263
+ unconditionally — so the fallback is reachable in the browser state it was written for
264
+ (objectui#5703).
265
+
266
+ `set()` already swallowed a failed `localStorage.setItem` into `_memoryValue`, but
267
+ `get()` only consulted `_memoryValue` when the READ itself threw. There is a real
268
+ browser state where the read does not throw and the fallback is nonetheless the only
269
+ copy: `localStorage` present and readable but rejecting writes — Safari private
270
+ browsing, and any quota-exhausted origin, where `setItem` throws `QuotaExceededError`.
271
+ In that state the active org was stored and could not be read back, measured as
272
+ `_memoryValue = org-42` alongside `get() = null`.
273
+
274
+ The cost was silent and lasted the whole session rather than the documented first-boot
275
+ window: `createAuthenticatedFetch` reads `get()`, so `X-Tenant-ID` was never stamped on
276
+ any request. Per the edge contract documented on objectui#5279 that header is a routing
277
+ hint a reader falls through on — the framework scopes from the session — so no row
278
+ visibility rode on this; what was missing is the tenant-routing input, on every request.
279
+ `switchOrganization` also appeared to succeed while the client-side stamp never
280
+ followed.
281
+
282
+ Sign-out is unaffected, and that is the half worth stating: the new fallback fires
283
+ exactly when the `localStorage` read is null, which is the state `clear()` leaves
284
+ behind. It answers `null` there because `clear()` nulls `_memoryValue` too. That
285
+ property is now pinned by test rather than relied upon, so a future `clear()` that only
286
+ removed the persisted key fails a test instead of quietly re-stamping a cleared org.
287
+
288
+ A non-null persisted read still wins over the memory value, so a working `localStorage`
289
+ behaves exactly as before.
290
+ - 32ef595: Restore platform-admin detection for permission-set-derived administrators.
291
+
292
+ `useIsWorkspaceAdmin` decides Setup app + Studio visibility, App Marketplace
293
+ gating and the "Build an app" CTAs. Its third source read `user.roles`, a key
294
+ the protocol-17 session face no longer emits (framework ADR-0090 D3 renamed it
295
+ to `positions`). An administrator whose adminship comes from the
296
+ `admin_full_access` permission set — the single-tenant deployment shape, where
297
+ there is no organization member row and the server deliberately no longer
298
+ overwrites `user.role` — matched none of the three sources and read as **not an
299
+ administrator**: Setup and Studio simply disappeared for them.
300
+
301
+ The hook now reads `user.positions[]`, the one spelling the session publishes.
302
+ Detection is restored for that path and unchanged everywhere else: an active
303
+ member row with an admin role, a stored `user.role` admin scalar, and
304
+ preview/no-auth mode all behave exactly as before, and nobody who was not an
305
+ administrator becomes one — pinned by four negative cases alongside the
306
+ positive one.
307
+
308
+ Also corrects the now-stale documentation that described the removed spelling:
309
+ the hook's own docblock, the `roles?: string[]` declaration on the client
310
+ `AuthUser` (kept for one remaining compile-time reader; see objectui#5424), and
311
+ two comments in `@object-ui/app-shell`'s Home page. No behaviour change from the
312
+ comment corrections.
313
+ - 343c598: The active organization id is now stored per user, and a change of session user drops the
314
+ previous user's client state wholesale (objectui#5664).
315
+
316
+ `auth-active-organization-id` was a single un-namespaced `localStorage` key while its
317
+ siblings were already user-scoped (`objectui-recent-items:u:`, `objectui-favorites:u:`,
318
+ `flow-palette-recents:u:`). On a browser handed from one account to another — a shared
319
+ machine, a kiosk, a handover, a support session — the arriving user's console read the
320
+ PREVIOUS user's organization id. The header workspace chip rendered the previous user's
321
+ workspace for a user whose `organization/list` was empty, and the consequence past the
322
+ cosmetics is the one worth stating: the polluted org context suppressed
323
+ `RequireOrganization`'s routing into the guided "Create your workspace" first-run flow, so
324
+ a brand-new user on that browser silently never got the new-user flow at all.
325
+
326
+ Nothing about row visibility rode on this. With the stale id the server answers
327
+ `403 USER_IS_NOT_A_MEMBER` on `get-full-organization` and `set-active`, and lists zero
328
+ environments; the damage was entirely in what the client believed about itself.
329
+
330
+ Three changes, and the third is the one that closes the class rather than the instance:
331
+
332
+ - The key is per-user (`auth-active-organization-id:u:$userId`), matching the convention
333
+ its siblings already use.
334
+ - It can no longer be written un-namespaced at all. Where no session user is known yet the
335
+ value lives in memory for that page-load only — a namespacing that kept a bare-key
336
+ fallback would re-open the defect the first time a write happened before the user id
337
+ resolved.
338
+ - **A change of session user drops the previous user's client state wholesale.** This is an
339
+ allowlist sweep of both `localStorage` and `sessionStorage`, not a list of known keys, so
340
+ the NEXT storage key someone adds without a `:u:` scope is covered before it is written.
341
+ Only device-scoped entries survive: the arriving session's own bearer token, the pointer
342
+ recording whose state the browser holds, and the UI theme.
343
+
344
+ Both properties objectui#5703 established are preserved and still pinned: `get()` prefers a
345
+ non-null `localStorage` read and falls back to the in-memory value, and the memory value is
346
+ nulled BEFORE storage is touched — by `clear()` as before, and now by the user-change purge
347
+ too, so the outgoing user's org id cannot outlive their persisted key on the sign-out-then-
348
+ sign-in path that never reloads the page.
349
+
350
+ Existing browsers are not migrated. A value sitting under the retired bare key is
351
+ unattributable — nothing recorded whose org id it is — so migrating it is precisely the
352
+ defect it would be migrating away from, and it is deleted instead. A signed-in user loses
353
+ nothing durable: the active organization is a server-owned fact that
354
+ `AuthProvider.refreshOrganizations` re-asks for whenever the list is non-empty and no
355
+ active org is held, including the ADR-0081 single-membership repair. One boot re-supplies
356
+ it; users with no organization land on the guided first-run flow, which is the outcome this
357
+ card is about.
358
+
359
+ `apps/console`'s pre-render auth preflight purges every spelling of the active-org key —
360
+ the retired bare one and each `:u:` scope — when it finds a dead bearer token, and
361
+ deliberately leaves the session-user pointer in place so the next sign-in can still tell
362
+ that the browser changed hands.
363
+ - 2dd9443: `purgeSignedOutClientCaches()` — the sweep that drops the signed-out user's
364
+ `objectui:metadata:*` seed cache on sign-out (objectui#5198) — now costs one key when a
365
+ single `removeItem` throws, instead of aborting the rest of the sweep (objectui#5777).
366
+
367
+ The `try` wrapped the WHOLE loop, not each removal. A `removeItem` that threw on key `n`
368
+ aborted the walk, so keys `n+1..end` were never swept, and the failure was swallowed —
369
+ `AuthProvider`'s `signOut` believed the purge had completed. The entries this sweeps are
370
+ the previous principal's org-scoped, PERMISSION-FILTERED app list — objectui#5198
371
+ classifies a surviving entry as a cross-principal disclosure on a shared browser, not
372
+ mere staleness — so a partial sweep here is the sharper half of the same defect class
373
+ objectui#5763 fixed on the sign-in path (`sweepStore` in `ActiveOrganizationStorage.ts`).
374
+
375
+ `Object.keys(sessionStorage)` — the reason a guard exists here at all — stays guarded on
376
+ its own; only the per-key guard is new. Same as `sweepStore`, a failed removal here is
377
+ not verified by read-back and not quarantined the way `ActiveOrganizationStorage.clear()`
378
+ (objectui#5731) quarantines a key: this function does not own reads for the metadata
379
+ seed cache (`MetadataProvider` in `@object-ui/app-shell` does), so there is no `get()` to
380
+ guard and nothing to quarantine — adding read-back verification would be a general
381
+ storage-error-handling refactor of the module, out of this card's scope. What is
382
+ mirrored is the reporting channel: a key whose `removeItem` throws is named in a
383
+ `console.warn`, the same channel `sweepStore` and `clear()` use, so a partial sweep is
384
+ discoverable instead of silent.
385
+
386
+ Adds a partial-failure test: a `sessionStorage` whose `removeItem` throws on one metadata
387
+ key, asserting every other metadata key on both sides of it is still swept and unrelated
388
+ non-matching keys are untouched, plus a control that the warning fires only on an actual
389
+ failure.
390
+ - ff2d547: `sweepStore()` — the walk that drops the previous user's `localStorage`/`sessionStorage`
391
+ state on a change of session user (objectui#5664 part 3) — now costs one key when a
392
+ single `removeItem` throws, instead of aborting the rest of the sweep (objectui#5763).
393
+
394
+ The `try` wrapped the WHOLE loop, not each removal. A `removeItem` that threw on key
395
+ `n` aborted the walk, so keys `n+1..end` were never swept, and the failure was
396
+ swallowed — `purgePreviousUserClientState()` returned normally and `SessionUserScope.adopt`
397
+ believed the sign-in purge had completed. This is an ALLOWLIST sweep precisely so the
398
+ next un-namespaced key — one nobody has written yet — cannot re-open the cross-user
399
+ pollution class #5664 fixed; a partial sweep is a partial allowlist, and which keys
400
+ survived depended on `Object.keys` iteration order rather than on anything bounded. The
401
+ previous user's org id, recents, favourites, or a `sessionStorage` metadata seed (their
402
+ permission-filtered app list, a cross-principal disclosure per objectui#5198) could all
403
+ land on the wrong side of the abort.
404
+
405
+ `Object.keys(store)` — the reason a guard exists here at all — stays guarded on its
406
+ own; only the per-key guard is new, so one uncooperative key now costs exactly that key.
407
+
408
+ Unlike `ActiveOrganizationStorage.clear()` (objectui#5731), a failed removal here is
409
+ NOT verified by read-back and NOT quarantined: `clear()` owns every future read of its
410
+ one key through `ActiveOrganizationStorage.get()`, so a "still readable" verdict and a
411
+ quarantine are what keep a failed `clear()` from handing the value straight back.
412
+ `sweepStore` walks keys it does not own reads for — another package's recents cache, a
413
+ metadata seed — so there is no `get()` here to guard and nothing to quarantine; adding
414
+ read-back verification for keys this function does not otherwise touch would be a
415
+ general storage-error-handling refactor of the module, which this card is scoped away
416
+ from. What IS mirrored is the reporting channel: a key whose `removeItem` throws is
417
+ named in a `console.warn`, the same channel `clear()` uses, so a partial sweep is
418
+ discoverable instead of silent. The caller (`SessionUserScope.adopt`, on the sign-in
419
+ path, inside an `AuthProvider` effect) still cannot act on the failure and must not
420
+ throw either.
421
+
422
+ A working `localStorage`/`sessionStorage` behaves exactly as before: every
423
+ non-device-scoped key is removed, nothing is reported, and the device-scoped allowlist
424
+ (`auth-session-token`, `auth-session-user-id`, `vite-ui-theme`) is unaffected.
425
+ - 36918bf: `switchOrganization` now re-resolves identity for the organization it just switched
426
+ to by its own explicit decision, instead of depending on an accidental
427
+ `TokenStorage` side effect to notice the switch (objectui#5750).
428
+
429
+ `AuthProvider.switchOrganization` has never called `loadSession()` itself. Identity
430
+ re-resolved across a switch only because `POST /organization/set-active` happens to
431
+ return the SIGNED `token.signature` spelling in `set-auth-token`, which differs from
432
+ the UNSIGNED `session.token` spelling `getSession()` normally stores — so
433
+ `TokenStorage.set` reads the flip as a rotation and the objectui#4467 subscription
434
+ calls `loadSession()` for it (measured and pinned in objectui#5749/#5719). That
435
+ signed spelling is deterministic on the raw session token, not on the organization:
436
+ two switches with no `get-session` landing in between produce the identical signed
437
+ value, so the SECOND `TokenStorage.set` sees no change, never notifies, and identity
438
+ is left answering for whichever organization the FIRST switch targeted even though
439
+ `activeOrganization` already reads as the new one.
440
+
441
+ Reachable in the console via `OrganizationLayout`'s slug-driven effect (the "Manage"
442
+ link on an org card, plus its own "Back to organizations" button) — ordinary
443
+ client-side navigation with no full-page reload and nothing debouncing repeat
444
+ switches. `WorkspaceSwitcher` and `OrganizationsPage`'s own card click were not
445
+ reachable paths for this: both force `window.location.href` immediately after a
446
+ successful switch, and the resulting fresh `AuthProvider` mount always performs an
447
+ authoritative `loadSession()` regardless of how the race above resolved.
448
+
449
+ `switchOrganization` now tracks the organization it last resolved to itself and
450
+ re-resolves explicitly whenever a switch's target differs from that, while
451
+ suppressing the (now redundant) rotation notification for its own `set-active`
452
+ call — so the common single-switch path still spends exactly one `get-session`, not
453
+ two. A generation guard discards a still-in-flight, now-superseded switch's answer
454
+ rather than let it clobber a later switch's fresher one.
455
+ - 934a532: Document the `X-Tenant-ID` edge contract that `createAuthenticatedFetch` stamps, and the
456
+ unstamped-first-request window in which it is not sent (objectui#5279). Documentation
457
+ only — no behaviour changes.
458
+
459
+ The header had no written contract anywhere, and the shape of the missing information was
460
+ actively misleading: its only non-CORS consumer lives in the **cloud** repository, so a
461
+ search confined to this repo and the framework (`objectstack`) returns zero readers and
462
+ reads as "nothing consumes this stamp". #5279 was filed on exactly that reading, and was
463
+ held until a cloud-side reading came back non-empty. Without the contract written down,
464
+ the next person to grep reaches the same false conclusion and deletes a live routing
465
+ input.
466
+
467
+ `packages/auth/README.md` gains "The `X-Tenant-ID` edge contract": what the header means
468
+ (a routing hint carrying the better-auth `activeOrganizationId` — not an identity claim,
469
+ not an authorization input, not what scopes rows), who stamps it and under exactly which
470
+ condition, who reads it, and what a reader may and may not assume. The framework half is
471
+ stated as a negative with its pin — `resolveAuthzContext` takes `tenantId` from the
472
+ API-key principal or `session.activeOrganizationId` and from no header — alongside
473
+ `plugin-sharing`'s record that trusting `x-tenant-id` as identity *was* a vulnerability.
474
+ The configuration half is quoted from the contract this package can actually resolve,
475
+ `TenantRoutingConfigSchema` in `@objectstack/spec/cloud`, where `X-Tenant-ID` is the
476
+ default of a configurable `tenantHeaderName` and `header` ranks second of six
477
+ identification sources behind `subdomain`.
478
+
479
+ The unstamped-first-request gap gets its own section: `ActiveOrganizationStorage` is
480
+ filled only after `AuthProvider`'s async `getSession` -> `listOrganizations` ->
481
+ `getActiveOrganization` chain resolves, so early-boot requests carry no tenant header at
482
+ all. What a reader observes is documented as **absent, never present-and-empty**, with the
483
+ five situations that open the window and the instruction to fall through to the next
484
+ identification source rather than fail closed. The gap is recorded, deliberately not
485
+ closed: the cloud readers observe today's behaviour, so changing when the header first
486
+ appears is its own decision.
487
+
488
+ Three cases in `createAuthenticatedFetch.test.tsx` pin the statements the prose makes
489
+ about the wire — no active organization means no header at all, the stamp is not gated on
490
+ `/api/` the way `Authorization` is, and the active organization overwrites a caller-set
491
+ `X-Tenant-ID` — so the documentation cannot drift away from the behaviour unnoticed.
492
+
493
+ ## 17.6.0
494
+
495
+ ### Minor Changes
496
+
497
+ - 1b21b1a: `AuthInvitation.status` is the closed four-member union it always documented, enforced at the auth client's wire boundary.
498
+
499
+ The field was declared `status: string` while its own doc comment enumerated
500
+ `'pending' | 'accepted' | 'rejected' | 'canceled'`, and `createAuthClient` cast
501
+ better-auth's `any` responses straight to `AuthInvitation[]` with no check. The
502
+ enumeration was therefore advisory: any value a backend stored reached the
503
+ console untouched, where the invitations screen coloured it through a
504
+ `default: 'secondary'` badge arm and printed it with
505
+ `defaultValue: inv.status` — the raw wire string rendered as interface copy, in
506
+ all ten packs (objectui#3879). Nobody could trigger it today, because the four
507
+ are what better-auth writes; it was the contract that was loose, and a loose
508
+ consumer is where an unexpected value would have hidden.
509
+
510
+ `AuthInvitationStatus` now carries the union and **binds better-auth's own
511
+ `InvitationStatus`** rather than restating it, the way `org-roles.ts` binds the
512
+ spec's `BUILTIN_MEMBERSHIP_ROLES` — a member-for-member copy is the state one
513
+ upstream release away from drifting silently. The runtime list
514
+ (`AUTH_INVITATION_STATUSES`) is derived from a total map keyed by that union, so
515
+ a fifth status upstream is a build failure here rather than a gap in the guard.
516
+ `isAuthInvitationStatus` is exported alongside, and all four invitation-returning
517
+ client methods (`listInvitations`, `listUserInvitations`, `getInvitation`,
518
+ `inviteMember`) narrow their wire rows through it.
519
+
520
+ Behaviour change, stated because it is one: an invitation whose `status` is
521
+ outside the set now **fails loudly** — the call rejects with a message naming the
522
+ value it refused and the four it expected — instead of resolving into a badge.
523
+ Both call paths already render a rejection with a retry, so the throw lands on a
524
+ designed surface. Degrading quietly was the alternative and was deliberately not
525
+ taken: a neutral label is how the raw value shipped in the first place, and
526
+ dropping the row would delete an invitation from an administrative ledger without
527
+ saying so. The console's badge switch is exhaustive over the union now and has no
528
+ `default:` arm, so if better-auth ever adds a member the type-check gate stops the
529
+ build at the one place a human has to choose a colour.
530
+
531
+ Consumers assigning an arbitrary string to `AuthInvitation.status` (a hand-built
532
+ fixture, a mock) will need one of the four members.
533
+
534
+ ### Patch Changes
535
+
536
+ - 5458414: Publish relative import specifiers with explicit `.js` extensions so these six packages load under plain Node ESM.
537
+
538
+ 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`.
539
+ - 61b097c: Sign-out now drops the client-side caches that belonged to the session it ends,
540
+ and the metadata seed cache is keyed by session identity.
541
+
542
+ `sessionStorage` is per-tab, not per-session, and no sign-out call site reloads
543
+ the page — so the `objectui:metadata:*` entries (the app list the server
544
+ permission-filters per session) and the active-organization id survived into
545
+ whatever happened next in that tab. A second person signing in on a shared,
546
+ kiosk or handover browser was seeded with the previous user's filtered app list.
547
+ Organization scoping did not close this: two users in the same organization
548
+ computed the same cache key.
549
+
550
+ `AuthProvider.signOut()` now purges the `objectui:metadata:` entries and clears
551
+ `ActiveOrganizationStorage` (and the in-memory organization block) on both the
552
+ success and failure paths, and `MetadataProvider` keys each entry by a
553
+ fingerprint of the session token, so an entry that escapes the purge is
554
+ unreadable by the next principal rather than merely undeleted. Entries left by
555
+ another principal are deleted the first time a console mounts.
556
+ - a9e17b4: `auth.forgotPassword.successDescription`'s address hole is now spelled with
557
+ single braces (`{email}`) instead of i18next's double braces (`{{email}}`),
558
+ in all ten locale packs.
559
+
560
+ This is a spelling-only change — rendered output is byte-identical in every
561
+ language, because the hole was never filled by i18next in the first place:
562
+ `ForgotPasswordForm` substitutes the address itself once the user submits
563
+ the form (the label renders before the address exists, so `t()` cannot do
564
+ it). `{{email}}` and a genuinely unfilled i18next hole were indistinguishable
565
+ at the call site, and passing `email` as an interpolation argument — the
566
+ natural "fix" for what looks like a missing argument — would let i18next
567
+ consume the hole and cause the address to be appended a second time
568
+ (objectui#4135).
569
+
570
+ Converging on `{x}` for every hole a component fills downstream of `t()`
571
+ (the convention `resendOtpCountdownText`'s `{seconds}` already used) puts
572
+ this hole outside i18next's `{{…}}` syntax entirely, so the ambiguity is
573
+ gone by construction rather than fenced by an exemption. Accordingly,
574
+ `scripts/check-i18n-call-site-keys.mjs`'s `EXTERNALLY_INTERPOLATED_HOLES`
575
+ registry entry for this key is retired — the gate needs no exemption for a
576
+ hole i18next was never going to touch.
577
+
578
+ `ForgotPasswordForm.tsx`'s replacement marker and its own built-in default
579
+ label move to the same spelling in the same change, as does the inline
580
+ `defaultValue` at `apps/console/src/pages/auth/ForgotPasswordPage.tsx`'s
581
+ call site.
582
+ - Updated dependencies [88085e3]
583
+ - Updated dependencies [279fb13]
584
+ - Updated dependencies [1184192]
585
+ - Updated dependencies [a2a9747]
586
+ - Updated dependencies [af5e292]
587
+ - Updated dependencies [7f96b10]
588
+ - Updated dependencies [f1d4748]
589
+ - Updated dependencies [578e025]
590
+ - Updated dependencies [598c89a]
591
+ - Updated dependencies [b8b9af4]
592
+ - Updated dependencies [97abb24]
593
+ - Updated dependencies [deb157a]
594
+ - Updated dependencies [d2ce342]
595
+ - Updated dependencies [9695da7]
596
+ - Updated dependencies [58b8346]
597
+ - Updated dependencies [3cf4de0]
598
+ - Updated dependencies [c9dc811]
599
+ - Updated dependencies [a0b9e91]
600
+ - Updated dependencies [99bd015]
601
+ - @object-ui/types@17.6.0
602
+
3
603
  ## 17.5.0
4
604
 
5
605
  ### Minor Changes