@object-ui/auth 17.6.0 → 18.0.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 (41) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/README.md +124 -19
  3. package/dist/ActiveOrganizationStorage.d.ts +32 -1
  4. package/dist/ActiveOrganizationStorage.d.ts.map +1 -1
  5. package/dist/ActiveOrganizationStorage.js +72 -3
  6. package/dist/AuthContext.d.ts +10 -3
  7. package/dist/AuthContext.d.ts.map +1 -1
  8. package/dist/AuthProvider.d.ts.map +1 -1
  9. package/dist/AuthProvider.js +101 -18
  10. package/dist/LoginForm.d.ts +35 -4
  11. package/dist/LoginForm.d.ts.map +1 -1
  12. package/dist/LoginForm.js +16 -4
  13. package/dist/RegisterForm.d.ts +21 -2
  14. package/dist/RegisterForm.d.ts.map +1 -1
  15. package/dist/RegisterForm.js +3 -4
  16. package/dist/SocialSignInButtons.d.ts +15 -2
  17. package/dist/SocialSignInButtons.d.ts.map +1 -1
  18. package/dist/SocialSignInButtons.js +3 -3
  19. package/dist/UserMenu.d.ts +1 -1
  20. package/dist/UserMenu.js +1 -1
  21. package/dist/auth-gate-events.d.ts +6 -4
  22. package/dist/auth-gate-events.d.ts.map +1 -1
  23. package/dist/auth-gate-events.js +11 -1
  24. package/dist/authStyles.d.ts +8 -3
  25. package/dist/authStyles.d.ts.map +1 -1
  26. package/dist/authStyles.js +8 -3
  27. package/dist/createAuthClient.d.ts +15 -1
  28. package/dist/createAuthClient.d.ts.map +1 -1
  29. package/dist/createAuthClient.js +54 -1
  30. package/dist/createAuthenticatedFetch.d.ts +5 -4
  31. package/dist/createAuthenticatedFetch.d.ts.map +1 -1
  32. package/dist/createAuthenticatedFetch.js +3 -3
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +6 -0
  36. package/dist/types.d.ts +53 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/useWorkspaceAdminStatus.d.ts +76 -27
  39. package/dist/useWorkspaceAdminStatus.d.ts.map +1 -1
  40. package/dist/useWorkspaceAdminStatus.js +146 -40
  41. package/package.json +6 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,603 @@
1
1
  # @object-ui/auth
2
2
 
3
+ ## 18.0.0
4
+
5
+ ### Dependency floors
6
+
7
+ Computed from `.objectstack-sha` `06cab4849a8a`: the earliest published `@objectstack/spec` that contains it is `18.0.0`.
8
+
9
+ - `@objectstack/spec` (`dependencies`): `^18.0.0-next.1` → `^18.0.0`
10
+
11
+ ### Major Changes
12
+
13
+ - 6f3299f: objectui moves to major 18, following `@objectstack/*` from 17 to 18 (objectui#12334). objectui's major is not a count of its own breaking changes: it tracks the major of the `@objectstack/spec` its packages depend on, so that the same major means compatible across the two projects. These packages already declare `@objectstack/*` on the 18 line (objectui#12030), so every package in the fixed release group crosses to 18 together. It is the one `major` that rule admits: objectui's own breaking changes ship as `minor`, described in their entries.
14
+
15
+ The 18 line is released from Changesets pre mode on the `next` tag (`.changeset/pre.json`), as `@objectstack/*` 18 is: these releases are `18.0.0-next.N` on the npm `next` dist-tag, and they do not move `latest`. The first `18.0.0` on `latest` comes when `@objectstack` 18 is generally available and this repository leaves pre mode.
16
+
17
+ What a consumer must do to take it is in the `@objectstack/*` 18 entry of this release (objectui#12030): move its own `@objectstack/*` packages to the 18 line at the same time.
18
+
19
+ ### Minor Changes
20
+
21
+ - f1a177c: `LoginForm` shows its "Don't have an account? Sign up" row only when the caller passes `registerUrl` (objectui#11634). The prop used to default to `'/register'`, so a caller that passed `undefined` to withhold the link got it back. Both console login pages pass `undefined` when the server's `/auth/config` reports `emailPassword.disableSignUp: true`, so a deployment with sign-up turned off still offered a sign-up link. The server refused the sign-up and `/register` sent the user back to the login page.
22
+
23
+ **Behaviour change.** A `LoginForm` rendered without `registerUrl` no longer shows a sign-up link. A caller that left the prop out and relied on the `'/register'` default now passes the URL itself, `registerUrl="/register"`, to keep the link. The console app's login page and `@object-ui/app-shell`'s `DefaultLoginPage` already pass the URL whenever sign-up is on, so neither changes. Leaving the prop out, or passing `undefined`, is the one way to render no link: no second "off" value is added.
24
+
25
+ **Clause-②: no.** The prop's type is unchanged (`registerUrl?: string`). No export, prop, type member or i18n key is added or removed.
26
+ - daa7caf: The console's login and register pages offer a generic sign-up only where the server would accept one (objectui#11691). `/api/v1/auth/config` states the sign-up rule as two keys, `emailPassword.disableSignUp` and `features.audiencePosture`, and the server deliberately does not force the first from the second: under `invite_only` its sign-up route still admits a pending invitee. The pages read only `disableSignUp`, so under the default `invite_only` posture `/login` offered "Sign up" and `/register` refused the finished form with `SELF_REGISTRATION_CLOSED`.
27
+
28
+ Both pages now read both keys:
29
+
30
+ - `disableSignUp: true` still hides sign-up outright, invitation links included.
31
+ - Under `open` or `email_domain`, nothing changes.
32
+ - Under `invite_only`, `/login` shows no "Sign up" link. A visitor who arrived from an invitation (`?redirect=/accept-invitation/ID`, the signed-out bounce of the invitation page) still gets the link, and `/register` still renders the form for them. A deployment with no owner yet (`GET /api/v1/auth/bootstrap-status` answers `hasOwner: false`) keeps the link for its first owner, because the server admits the first account under every posture.
33
+ - Otherwise `/register` says that self-registration is not open and points back to sign-in, before any field is filled in, instead of refusing the submitted form.
34
+ - A server that sends no `audiencePosture` is answered as before, by `disableSignUp` alone. A posture value the console does not recognise reads as closed.
35
+
36
+ **Clause-②: yes.** `@object-ui/auth`'s published `AuthPublicConfig.features` gains an optional `audiencePosture` member, typed as `@objectstack/spec`'s `AudiencePosture`. The package's `@objectstack/spec` range moves from `^17.0.0` to `^17.3.0`, the first release that declares that type. No export, prop or i18n key is added or removed: the register page's explanation reuses the existing `auth.register.errors.selfRegistrationClosed` sentence.
37
+ - dfa15c8: A new workspace now takes its creator's browser timezone when it is created, so its first administrator is no longer asked for a zone the browser already knows (objectui#11908).
38
+
39
+ `createOrganization` accepts an optional `timezone`, an IANA zone such as `Asia/Shanghai`, on both `AuthClient` and the `useAuth()` context. The client sends it as the `timezone` query parameter of `POST /organization/create`; the request body stays `{ name, slug }` (plus `logo` when given). When the zone is absent or empty, no parameter is sent and the server keeps its default zone. A server that does not read the parameter ignores it.
40
+
41
+ The console's create-workspace dialog and the first-run setup page's create branch both pass the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. A browser that reports no zone sends none. Existing workspaces are unchanged, and the one-time timezone prompt still asks about a workspace whose zone is still the default.
42
+ - c5406c2: Depend on the `@objectstack/*` 18 prerelease line (objectui#12030).
43
+
44
+ Every `@objectstack/*` range these packages declare in `dependencies` or `peerDependencies` moves from `^17.x` to `^18.0.0-next.0`, the `next` prerelease of `@objectstack/*` 18 (raised to `^18.0.0-next.1` by objectui#12370 before release).
45
+
46
+ **Breaking for consumers:** an install that resolves `@objectstack/spec` (or `@objectstack/client`, `@objectstack/formula`, `@objectstack/lint`) 17.x no longer satisfies these ranges. A consumer must move its own `@objectstack/*` packages to the 18 line, starting with `18.0.0-next.1` (objectui#12370), at the same time as it takes this release.
47
+
48
+ **objectui's own surface moves with it:**
49
+
50
+ - `element:record_picker`'s registration stops publishing the `object`, `filter`, `sort` and `limit` inputs, which spec 18 retires; a JSX page that writes one now gets `unknown-prop`.
51
+ - The `CapabilityMultiSelectField` widget's curated capability set (`@object-ui/fields`) follows spec 18: `manage_mailboxes` in, `manage_org_presentation` out.
52
+ - `element:number` and `element:repeater` nodes that bind no object are accepted, as spec 18's row and `PageComponentSchema` accept them; the two `ELEMENT_*_OBJECT_REQUIRED` refusals are gone.
53
+
54
+ **Retired with the spec:** `@objectstack/spec` 18 tombstones `object-grid`'s `defaultFilters` and refuses it by name, and `ObjectGrid` and `ObjectView` stop reading it (`table.defaultFilters` on an `object-view` included). A view that still carries it renders unfiltered: rename the key to `filter`, whose value is the same rule array.
55
+ - 37db09f: A user whose administrator forced a password change gets the change-password overlay instead of raw `403` responses (objectui#12365).
56
+
57
+ The ObjectStack backend answers an administrator-forced password change with `403 PASSWORD_RESET_REQUIRED`, its own code distinct from natural expiry `PASSWORD_EXPIRED` (objectstack#23287 ruling C′, carried by objectstack#23440).
58
+
59
+ - **`@object-ui/auth`:** the authenticated fetch wrapper now treats `PASSWORD_RESET_REQUIRED` as an auth-policy gate, like `MFA_REQUIRED` and `PASSWORD_EXPIRED`, so `AuthProvider` sets `remediationRequired` for it. Before the backend emits the code this changes nothing.
60
+ - **`@object-ui/app-shell`:** `RemediationOverlay` routes `PASSWORD_RESET_REQUIRED` to the change-password form. It shows the title "You must change your password" for it, because "Your password has expired" is false for a forced reset. `PASSWORD_EXPIRED` keeps its title.
61
+ - **`@object-ui/i18n`:** adds `auth.remediation.password.resetTitle` to all ten built-in language packs.
62
+
63
+ No export is added or removed, and the type of `remediationRequired` (`{ code: string; message: string }`) is unchanged.
64
+ - ff9df88: Depend on `@objectstack/*` `18.0.0-next.1` (objectui#12370).
65
+
66
+ Every `@objectstack/*` range these packages declare moves from `^18.0.0-next.0` to `^18.0.0-next.1`; the console and the runner bundle `18.0.0-next.1`. A consumer moves its own `@objectstack/*` packages to `18.0.0-next.1` with this release.
67
+
68
+ **Accept sets that narrow with it.** `@object-ui/types` validates page action rows and view data bindings through the spec's own rows, so its faces (`safeValidateSchema` and `StrictAnyComponentSchema`) now refuse what `18.0.0-next.1` refuses. Each was accepted on `18.0.0-next.0`:
69
+
70
+ - `onSuccess` on an `action:button` / `action:icon` row, or an `action:group` / `action:menu` member, whose executor is the `url`, `navigation`, `modal`, `flow` or `form` built-in, or that has no executor at all. Write the hop on an `api` or `script` action (or a host handler name), or drop it.
71
+ - `onSuccess` beside `operation: 'update'`, and an `onSuccess` block carrying a key outside `{ navigate, openIn }`.
72
+ - `actionType: 'navigation'` on a row (and `type: 'navigation'` on a container member): write `'url'`.
73
+ - A `url` executor's `target` that is not openable, judged on the executor the runner runs, including a row whose `name` selects it.
74
+ - A view `data` binding with `provider: 'api'`: bind the object or static rows; an API call is the page element's binding.
75
+ - 8ed7e5a: `LoginForm` gains an optional `hideHeader` prop and a password reveal toggle, and the shared auth form styles take the console login artboard's sizes (objectui#12600).
76
+
77
+ - **`hideHeader`** (default `false`) renders no header — no icon disc, no title, no description — for a host that draws its own. Left unset, the header renders exactly as before. Under SSO-only mode a headless form still shows the SSO-only description, as one line above the federated buttons.
78
+ - **Password reveal toggle.** The password field carries a 28 px show/hide button. Its accessible name comes from two new optional `LoginFormLabels` members, `showPasswordText` and `hidePasswordText` (defaults "Show password" / "Hide password").
79
+ - **Shared styles, every auth form:** `AUTH_INPUT_CLASS` and `AUTH_PRIMARY_BUTTON_CLASS` are 40 px tall (`h-10`, were `h-11`); the primary button is one flat `bg-primary` fill (was a `--primary` gradient); `AUTH_FIELD_LABEL_CLASS` is 13 px. `LoginForm`'s "Sign up" line is 13 px and its enterprise SSO button is 40 px with the inputs' radius.
80
+ - `LoginForm`'s root caps its width at 400 px (`sm:max-w-[400px]`) instead of fixing it there (`sm:w-[400px]`), so it fits a host card narrower than 400 px; wherever it had 400 px or more, nothing changes.
81
+
82
+ **Clause-②: yes.** Added: `LoginFormProps.hideHeader`, `LoginFormLabels.showPasswordText`, `LoginFormLabels.hidePasswordText`, all optional. Nothing is removed or renamed.
83
+ - 9b69e13: `LoginForm` no longer tells an SSO-enforced environment's users to use a password (objectui#12682).
84
+
85
+ - **While the auth config resolves**, the header shows its title and no description. It used to show the password form's description ("Enter your email and password to continue", or the `description` the host passed) because SSO enforcement was not known yet. Once the config answers, the description is what it was: `labels.ssoOnlyDescription` under SSO-only mode, `description` otherwise. A failed config read still falls back to the password form and its description.
86
+ - **Under SSO-only mode** (`features.ssoEnforced`), the provider buttons are no longer followed by an "or continue with email" divider with no email form under it. The divider now renders only while the password form is shown, so it comes back with the form when "Use a password instead" is clicked. A server that does not enforce SSO keeps the divider exactly as before.
87
+ - **`SocialSignInButtons` gains `hideDivider`** (optional, default `false`), which omits the divider under the buttons. Left unset, the component renders exactly as before, so `RegisterForm` and every other caller are unchanged.
88
+
89
+ **Clause-②: yes.** Added: `SocialSignInButtonsProps.hideDivider`, optional. Nothing is removed or renamed.
90
+ - 84b8ffa: The console splash no longer waits for requests the first screen does not need (objectui#12809).
91
+
92
+ - **The organization chain runs in parallel.** When the `get-session` answer carries the organization plugin's `activeOrganizationId`, `AuthProvider` names the active organization from that id and the `organization/list` answer, and asks for the member row (`get-active-member`) at the same time as the list. It no longer asks for `get-full-organization` on boot: nothing the console reads uses the members or invitations that answer adds. An id the list does not carry names no organization, and the stored id is cleared, as before. A session answer without the key (a sign-in response, or a server without the organization plugin) takes the old path. Switching organization, the single-membership repair, sign-in and sign-out are unchanged.
93
+ - **`AuthClientSession.activeOrganizationId`** is a new optional member: the id better-auth's organization plugin puts on the session, or `null` when the session holds none.
94
+ - **The console waits only for the metadata the current route reads.** `AppContent` used to hold the splash until the object, dashboard, report and page lists had all loaded, on every route. It now waits for the lists the current route reads (an object page waits for objects; a dashboard for dashboards and objects). After the first screen, opening a route whose lists have not loaded shows a skeleton where the page will be, inside the console, instead of an empty list or a "not found". A route a host adds through `extraRoutes` still waits for all four lists.
95
+
96
+ ### Patch Changes
97
+
98
+ - d75f212: Published text no longer teaches the retired view-level `provider: 'api'` data source (objectui#12364). A view binds `provider: 'object'` or `provider: 'value'`; a metadata-declared API call is the page element's `dataSource` binding. Text only: no schema accepts or refuses anything new, and no renderer behaves differently.
99
+
100
+ - **`@object-ui/types`:** the `object-tree` `data` description drops the `api` provider from the providers it names.
101
+ - **`@object-ui/plugin-grid` / `@object-ui/plugin-map`:** the `data` input descriptions drop the `api` provider.
102
+ - **`@object-ui/plugin-calendar` / `@object-ui/plugin-gantt` / `@object-ui/plugin-map`:** the error thrown when no `DataSource` is available now reads `DataSource required for the object provider` (was `DataSource required for object/api providers`). It fires in exactly the same conditions as before.
103
+ - **`@object-ui/react` / `@object-ui/auth`:** the README and docblocks describe `SchemaRendererProvider`'s `apiFetch` as what it is now: the host's authenticated fetch the renderers use for their own reads of the host's API (chart metadata, dataset dimension labels, record-permission probes). It is not a channel for a view's data source.
104
+ - **`@object-ui/app-shell`:** comment wording only in `ConsoleShell`, `useConsoleActionRuntime` and `ObjectView` (these comments ship in the built JS): no behaviour change.
105
+ - **`@object-ui/core`:** docblock wording only on `normalizeListViewSchema` and `resolveRecordSourceObjectName` (these ship in `dist/*.d.ts` and the built JS): no behaviour change.
106
+ - 6e141d9: After a reload, the console no longer keeps the previous organization's id once the server says the session has no active organization (objectui#12698). better-auth clears a session's active organization before it refuses a `set-active` to an organization the user is not a member of. On the next boot `AuthProvider` asked the server, heard "none", and left the old id in `ActiveOrganizationStorage`, so `X-Tenant-ID` and the metadata seed cache kept naming that organization. `refreshOrganizations` now clears the stored id whenever the server answers with no active organization, including when the single-membership repair (ADR-0081) is refused. When the repair succeeds, the server is re-stamped and the stored id follows the server's answer, as before. Nothing is added to or removed from the package entry.
107
+
108
+ The console header (`WorkspaceSwitcher`, `CurrentOrganizationIndicator`) now names only the organization the server reports as active, and shows no organization at all when there is none, instead of falling back to the first membership.
109
+ - 5d69b0e: `AuthProvider` no longer asks `get-session` a second time when the console has just validated the stored session before rendering. The validated answer becomes the provider's first state, once per page load; signing in or out, switching organisation and refreshing the session read it from the server as before.
110
+
111
+ ## 17.7.0
112
+
113
+ ### Minor Changes
114
+
115
+ - b57107d: fix(auth,app-shell,console): a browser that changes hands no longer keeps the previous account's UI language
116
+
117
+ Both device language slots (`objectui-locale` and `objectui-locale-seed`) hold the
118
+ language of whoever last signed in on the browser. A boot resolves its language from
119
+ them before anyone is signed in. When the session then turns out to belong to someone
120
+ else (an SSO redirect back into the console, or a sign-in in another window),
121
+ `SessionUserScope.adopt` already purged both slots (objectui#5664). But the live
122
+ language had already been derived from them, and nothing re-derived it. A new account
123
+ with no `sys_user.locale` of its own therefore read the previous account's language
124
+ for its whole first session.
125
+
126
+ - `@object-ui/auth` (new exports, hence minor): `getSessionOwnerChangeCount()` and
127
+ `subscribeSessionOwnerChange(listener)` report that the change-of-owner purge ran
128
+ in this page-load. They are held in memory, with no new storage key.
129
+ `SessionUserScope.adopt` now returns `true` when it purged a previous owner.
130
+ - `@object-ui/app-shell`: `useSignedInUserLocale` resets the UI language on an owner
131
+ change, the same way a boot of the swept storage would resolve it. It then
132
+ re-reads the new owner's `sys_user.locale`, which applies after the reset.
133
+ - `@object-ui/console`: `LocalizationFetchProvider` and the boot-time seed fetch no
134
+ longer write an answer that was requested before an owner change. Such an answer
135
+ describes the previous owner.
136
+ - `@object-ui/i18n` (docs only): the seed was documented as the "tenant's" locale.
137
+ It is the server's resolved locale for the signed-in caller: their own
138
+ `sys_user.locale`, else `Accept-Language`, else the deployment default. The docs
139
+ now say so, and declare what a visitor who has not signed in reads: the last
140
+ owner's language, until someone else signs in.
141
+ - 7ea8118: fix(auth, console): a registration started from an invitation link comes back to the invitation after email verification
142
+
143
+ better-auth builds the verification mail's link server-side from the
144
+ `/sign-up/email` request's `callbackURL`, defaulting it to `/`. The console kept
145
+ `?redirect=/accept-invitation/ID` from the login page through `/register` to the
146
+ "check your inbox" screen, but `createAuthClient().signUp` never sent a
147
+ `callbackURL`, so the mail read `callbackURL=/` and the invitee verified onto the
148
+ workspace picker ("Create workspace") instead of the invitation.
149
+
150
+ `@object-ui/auth` (additive):
151
+
152
+ - `SignUpData.callbackURL` is forwarded verbatim to `/sign-up/email`. Absent, the
153
+ key stays off the wire and the server default is untouched.
154
+ - `useAuth().signUp(name, email, password, callbackURL?)` takes it as an optional
155
+ fourth argument.
156
+ - `RegisterForm` gains a `verificationCallbackURL` prop, forwarded to `signUp`.
157
+
158
+ `@object-ui/console`:
159
+
160
+ - `RegisterPage` passes a safe `?redirect=` as the verification callback, and the
161
+ verify-email prompt's "Resend" sends the same value. Before, Resend sent the
162
+ bare router path, which the server redirects to at the ORIGIN root, outside
163
+ the `/_console` mount.
164
+ - Both resolve the route through the new `withConsoleBaseRootRelative`. The server
165
+ never sees `<base href>`, and better-auth refuses a document-relative `./…`
166
+ callback — what `withConsoleBase` answers in the embedded build — with
167
+ `403 INVALID_CALLBACK_URL`, failing the whole sign-up.
168
+ - 328abeb: Four Console surfaces that read English under a zh-CN session now read the session's
169
+ language (objectui#10900). English stays the default.
170
+
171
+ - **The generic action success toast.** When an action declares no `successMessage`, the
172
+ runner falls back to "Action completed successfully".
173
+ That fallback now goes through a translator: `ActionRunner.setTranslator(translate)` is
174
+ new in `@object-ui/core`, and `<ActionProvider>` and `useActionRunner` in
175
+ `@object-ui/react` install the session's `t` on the runner they build. The pack key is
176
+ `actions.completedSuccessfully`. An author's `successMessage` still reaches the toast
177
+ untranslated, and a runner with no translator installed still toasts the English sentence.
178
+ - **The social sign-in buttons on the login and sign-up pages.** `SocialSignInButtons`
179
+ takes a new `buttonText` prop, a template whose `{provider}` is replaced with the
180
+ provider's display name; unset, the buttons keep "Continue with {provider}" and
181
+ "Sign up with {provider}". `LoginForm` and `RegisterForm` take a new `socialButton`
182
+ label and pass it, with their existing `orText` label, to the buttons. `orText` was
183
+ documented as the divider label but rendered nowhere; it now sets the divider under the
184
+ buttons, and its documented default is corrected from "or" to "or continue with email",
185
+ the text that divider has always shown. The console's login and sign-up pages and
186
+ `@object-ui/app-shell`'s `DefaultLoginPage` / `DefaultRegisterPage` pass the new
187
+ `auth.login.*` and `auth.register.*` `socialButton` / `orText` keys. The provider's display
188
+ name is inserted as-is: the component's own label for the branded providers it knows,
189
+ otherwise the name the server reports.
190
+ - **Build Doctor.** The build conversation's Build Doctor button (its accessible name and
191
+ both tooltips) and the title of the drawer it opens read `console.ai.buildDoctor`,
192
+ `console.ai.buildDoctorTitle` and `console.ai.buildDoctorDisabledTitle`.
193
+ - **Setup → marketplace.** On `system/marketplace` and the pages under it, the breadcrumb
194
+ segment after System reads `console.breadcrumb.marketplace` instead of the humanized URL
195
+ slug; other `system/*` segments are unchanged. The zh marketplace search placeholder
196
+ reads 「按名称或标识搜索应用…」 instead of 「按名称或 manifest ID 搜索应用…」. The search
197
+ itself is unchanged: it matches the display name, the identifier and the description.
198
+
199
+ All ten locale packs carry the nine new keys; no existing `en` value changes.
200
+
201
+ **Clause-②: yes** — besides the nine pack keys, the public surface widens by four optional
202
+ members: `ActionRunner.setTranslator`, `SocialSignInButtonsProps.buttonText`,
203
+ `LoginFormLabels.socialButton` and `RegisterFormLabels.socialButton`. Nothing is removed,
204
+ renamed or narrowed, no accept set changes, and `@object-ui/react` exports nothing new.
205
+ - 0ea7054: Remove 37 runtime dependencies that no file in the declaring package consumes, and gate
206
+ the direction so the next one cannot land (objectui#8198).
207
+
208
+ `check:phantom-deps` judges imports that are not declared; nothing judged the reverse,
209
+ so a declaration could outlive its last consumer indefinitely. That is what happened to
210
+ `recharts` in `@object-ui/components` after objectui#7397 deleted its only importer — it
211
+ was removed by hand on objectui#7625, and nothing would have reported the next one. The
212
+ new `pnpm check:unused-deps` asks the reverse question over `dependencies` and
213
+ `optionalDependencies` of every released package.
214
+
215
+ **Potentially breaking, for consumers relying on hoisting.** Nothing these packages ship
216
+ changes: their Vite `external` predicates are path-based and never read `dependencies`,
217
+ so no built artifact moves. What changes is the install graph — a project that imports
218
+ one of the removed packages while depending only on the ObjectUI package that used to
219
+ drag it in will no longer resolve it. Declare it directly; that is the correct
220
+ dependency edge in either case. The removals, by package:
221
+
222
+ - `@object-ui/plugin-designer`: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`, `@object-ui/fields`
223
+ - `@object-ui/plugin-chatbot`: `react-markdown`, `react-syntax-highlighter`, `remark-gfm` (and the orphaned `@types/react-syntax-highlighter`)
224
+ - `@object-ui/plugin-report`: `@object-ui/plugin-grid`, `clsx`, `react-i18next`, `tailwind-merge`
225
+ - `@object-ui/plugin-map`: `@objectstack/spec`, `lucide-react`, `zod`
226
+ - `@object-ui/runner`: `class-variance-authority`, `clsx`, `tailwind-merge`
227
+ - `@object-ui/core`: `lodash`, `zod`
228
+ - `@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
229
+ - `@object-ui/plugin-dashboard`: `clsx`, `tailwind-merge`, and the same `react-dom` defect
230
+ - `@object-ui/plugin-ai`: `@object-ui/react`, `clsx`, `tailwind-merge`
231
+ - `@object-ui/fields`: `clsx`, `tailwind-merge`
232
+ - `@object-ui/console`: `@object-ui/react-runtime`, `sucrase`
233
+ - `@object-ui/auth`: `@object-ui/types`
234
+ - `@object-ui/plugin-calendar`: `@object-ui/fields`
235
+ - `@object-ui/plugin-editor`, `@object-ui/plugin-markdown`: `@object-ui/react`
236
+ - `@object-ui/react`: `react-hook-form`
237
+
238
+ Every one was verified by a whole-package grep before removal — the name appeared nowhere
239
+ under the package but its own manifest and CHANGELOG — and the whole workspace builds,
240
+ type-checks and tests green afterwards.
241
+ - 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.
242
+ - c8da8b3: Stop showing an access-denied screen to a real administrator while their
243
+ adminship is still resolving.
244
+
245
+ `useIsWorkspaceAdmin()` returned a bare `boolean`, so "the inputs have not
246
+ arrived yet" and "resolved: not an admin" were the same answer. One of its three
247
+ sources — the active organization member row — is fetched some round trips after
248
+ the session (`listOrganizations` → `getActiveOrganization` → `getActiveMember`),
249
+ so an administrator whose adminship lives only in that row rendered at least
250
+ once as a non-admin, and every gate downstream acted on it: the two marketplace
251
+ surfaces painted `MarketplaceAccessDenied`, the console chrome dropped and
252
+ re-added its admin nav entry, and `AppContent` fired a `<Navigate to="/home"
253
+ replace>` that the later flip could not undo.
254
+
255
+ **Breaking (published API, hence `minor` per this repo's version policy):**
256
+ `useIsWorkspaceAdmin(): boolean` is replaced by
257
+ `useWorkspaceAdminStatus(): { isAdmin: boolean; isResolved: boolean }`. The old
258
+ name is removed rather than kept alongside, so a call site that ignores the
259
+ third state fails to compile instead of silently refusing an administrator.
260
+
261
+ -const isAdmin = useIsWorkspaceAdmin();
262
+ +const { isAdmin, isResolved } = useWorkspaceAdminStatus();
263
+
264
+ `AuthProvider` gains `isMembershipResolved` on its context — the organization /
265
+ member pipeline has reached a terminal state — because `organizations`,
266
+ `activeOrganization` and `activeMember` read `[]` / `null` / `null` both before
267
+ the pipeline starts and after it finds nothing.
268
+
269
+ No extra wait for administrators: `isResolved` is true the instant `isAdmin` is,
270
+ so an admin the session already identifies through `positions[]` never waits on
271
+ the member row.
272
+
273
+ ### Patch Changes
274
+
275
+ - 639114c: Reconcile `@object-ui/app-shell` with `@objectstack/spec` 17.3.0 (objectui#7122).
276
+
277
+ `SchemaDiffEntryKind` gained an `unreachable` member in 17.3.0, and the external
278
+ datasource validation panel labels those kinds through a map that is TOTAL over
279
+ the union on purpose — so an upstream addition fails the build rather than
280
+ rendering a blank cell. That mechanism fired: the package did not compile against
281
+ 17.3.0 until the kind was labelled. It now reads "Not checked — remote
282
+ unreachable", following the spec's own ruling that this kind asserts nothing
283
+ about the remote schema and must never be surfaced as "schema changed": it means
284
+ introspection could not complete, which is often transient, and labelling it like
285
+ a mismatch would tell an operator to repair a schema nobody has read.
286
+
287
+ Two internal flow-inspector types were renamed (`FlowNodeLike` →
288
+ `InspectorFlowNode` / `ScopeFlowNode`) because 17.3.0 began exporting its own
289
+ `FlowNodeLike`; neither name is in this package's published entry, so no consumer
290
+ import changes.
291
+
292
+ `@object-ui/auth`: a README sentence claiming the preview-mode prop aligns with
293
+ the spec's `PreviewModeConfig` is corrected — 17.3.0 removed that symbol. The
294
+ `previewMode` prop itself is host-supplied, unchanged, and unaffected.
295
+ - 9b1b105: `useWorkspaceAdminStatus` stops deriving platform-administrator standing from a NAME in the
296
+ session's `positions[]`, and reads the ADR-0095 posture rung (`user.isPlatformAdmin`) instead.
297
+
298
+ Framework objectstack#15948 (declared BREAKING) redefined that array: it used to be the
299
+ `sys_user.role` scalar split on commas, and it is now built by `resolveUserAuthzGrants` from
300
+ ADR-0057 D4 `sys_user_position` assignments. `sys_user_position` is `apiEnabled` with
301
+ unconstrained `position` values, so a caller holding tenant-administration authority can mint a
302
+ row spelling `platform_admin` for one of their own users — and the third leg of this hook, which
303
+ scanned `positions[]` for any admin-sounding name, answered `true` for them. The server was
304
+ already fixed (`platform-admin-gate.ts` dropped its positions leg and four evaluator sites moved
305
+ to the rung), so what such a principal got was a Console painting platform-administrator surfaces
306
+ while every underlying call was refused: misleading, in the direction that reveals platform
307
+ structure to a tenant-controlled account.
308
+
309
+ The array read is narrowed rather than deleted, name by name. `org_owner` and `org_admin` stay:
310
+ `resolveUserAuthzGrants` emits exactly those from the ACTIVE organization's `sys_member.role`, a
311
+ minted spelling of them claims authority inside the tenant that the minter already holds, and they
312
+ are what lets a stamped tenant administrator resolve on the first frame (objectui#5619) instead of
313
+ waiting for the member pipeline. `platform_admin` is dropped and replaced by the rung, which is
314
+ byte-for-byte what the server's own `hasPlatformAdminStanding` returns. `owner`, `admin`,
315
+ `super_admin`, `superadmin` and `system_admin` are dropped from the array read too — no server
316
+ derivation emits any of them into `positions[]` (the membership leg normalizes `owner`/`admin` to
317
+ `org_*` first), so a hit could only ever have been a tenant-written row. All five remain valid for
318
+ the two scalar legs, where they are the raw membership role and the stored `user.role`.
319
+
320
+ The docblock that argued AGAINST reading `user.isPlatformAdmin` — on the grounds that the server
321
+ computed it as `'platform_admin' in positions`, "two spellings for one fact" — is rewritten rather
322
+ than left standing. After objectstack#15948 the flag derives from the unscoped `admin_full_access` grant and
323
+ the array does not, so they can disagree and the rung is right.
324
+
325
+ No behaviour changes for a genuine administrator: a platform administrator on a single-tenant
326
+ deployment still resolves through the flag (objectui#5389 stays green), a tenant administrator
327
+ still resolves through the member row and through `org_owner`/`org_admin`, and the objectui#5619
328
+ `isResolved` semantics are unchanged. Pinned by `__tests__/workspaceAdminRung-8291.test.tsx`.
329
+ - ebfc3c2: `ActiveOrganizationStorage.clear()` now verifies that the persisted key is actually
330
+ gone and, when it is not, both reports the failure and stops `get()` answering from
331
+ the surviving value — instead of swallowing the failed removal (objectui#5731).
332
+
333
+ `clear()` nulled `_memoryValue` and then removed the persisted key inside a
334
+ `try`/`catch` that discarded any failure. Since objectui#5703 `get()` prefers a
335
+ NON-NULL `localStorage` read and only falls through to `_memoryValue`, so the two
336
+ halves of `clear()` were not equally strong: nulling memory always sticks, while a
337
+ removal that did not stick left the key readable and the read order preferred it.
338
+ Sign-out is one of `clear()`'s five callers, so the failure mode was "sign-out does
339
+ not stick", and it was silent — the cleared organization went back on the wire as
340
+ `X-Tenant-ID` on every subsequent request.
341
+
342
+ The removal is now judged by a READ-BACK rather than by catching the throw, which is
343
+ both narrower and wider in the right directions. Wider: a wrapped or proxied
344
+ `localStorage` whose `removeItem` is a silent no-op never throws and leaves identical
345
+ residue, and is now covered. Narrower: SSR and the partitioned-iframe browser where
346
+ every operation throws have nothing readable to resurrect, were already safe, and are
347
+ not reported as failures.
348
+
349
+ A key whose removal could not be verified is quarantined in memory for the rest of the
350
+ page-load: `get()` skips the persisted branch for it and answers from `_memoryValue`,
351
+ which `clear()` has just nulled and which a later `set()` refills with the value that
352
+ write was meant to persist. The quarantine is released as soon as a removal on that key
353
+ sticks. An unstamped `X-Tenant-ID` is a documented state of the edge contract
354
+ (objectui#5279); a re-stamped signed-out organization is not.
355
+
356
+ The failure is not thrown and not returned. All five call sites — sign-out's
357
+ `purgeSignedOutClientCaches`, `switchOrganization`, `deleteOrganization`,
358
+ `leaveOrganization`, and the session-user purge that runs on the SIGN-IN path — arrive
359
+ after the transition they follow up on has already happened, and none can act on a
360
+ storage failure; a `boolean` every caller ignores would read as handled when it is not.
361
+ So the invariant is restored inside `clear()` and the failure is reported to the
362
+ console.
363
+
364
+ A working `localStorage` behaves exactly as before: the removal sticks, nothing is
365
+ quarantined, nothing is reported, and a non-null persisted read is still authoritative.
366
+ `set()`'s swallowed write failure is deliberately untouched — that swallow is
367
+ objectui#5703's memory fallback, and it is the correct kind, because the memory copy
368
+ upholds `set()`'s postcondition where nulling memory could not uphold `clear()`'s.
369
+ - 9a5d669: `ActiveOrganizationStorage.get()` now prefers a non-null `localStorage` read and falls
370
+ back to the in-memory value otherwise, instead of returning the `localStorage` read
371
+ unconditionally — so the fallback is reachable in the browser state it was written for
372
+ (objectui#5703).
373
+
374
+ `set()` already swallowed a failed `localStorage.setItem` into `_memoryValue`, but
375
+ `get()` only consulted `_memoryValue` when the READ itself threw. There is a real
376
+ browser state where the read does not throw and the fallback is nonetheless the only
377
+ copy: `localStorage` present and readable but rejecting writes — Safari private
378
+ browsing, and any quota-exhausted origin, where `setItem` throws `QuotaExceededError`.
379
+ In that state the active org was stored and could not be read back, measured as
380
+ `_memoryValue = org-42` alongside `get() = null`.
381
+
382
+ The cost was silent and lasted the whole session rather than the documented first-boot
383
+ window: `createAuthenticatedFetch` reads `get()`, so `X-Tenant-ID` was never stamped on
384
+ any request. Per the edge contract documented on objectui#5279 that header is a routing
385
+ hint a reader falls through on — the framework scopes from the session — so no row
386
+ visibility rode on this; what was missing is the tenant-routing input, on every request.
387
+ `switchOrganization` also appeared to succeed while the client-side stamp never
388
+ followed.
389
+
390
+ Sign-out is unaffected, and that is the half worth stating: the new fallback fires
391
+ exactly when the `localStorage` read is null, which is the state `clear()` leaves
392
+ behind. It answers `null` there because `clear()` nulls `_memoryValue` too. That
393
+ property is now pinned by test rather than relied upon, so a future `clear()` that only
394
+ removed the persisted key fails a test instead of quietly re-stamping a cleared org.
395
+
396
+ A non-null persisted read still wins over the memory value, so a working `localStorage`
397
+ behaves exactly as before.
398
+ - 32ef595: Restore platform-admin detection for permission-set-derived administrators.
399
+
400
+ `useIsWorkspaceAdmin` decides Setup app + Studio visibility, App Marketplace
401
+ gating and the "Build an app" CTAs. Its third source read `user.roles`, a key
402
+ the protocol-17 session face no longer emits (framework ADR-0090 D3 renamed it
403
+ to `positions`). An administrator whose adminship comes from the
404
+ `admin_full_access` permission set — the single-tenant deployment shape, where
405
+ there is no organization member row and the server deliberately no longer
406
+ overwrites `user.role` — matched none of the three sources and read as **not an
407
+ administrator**: Setup and Studio simply disappeared for them.
408
+
409
+ The hook now reads `user.positions[]`, the one spelling the session publishes.
410
+ Detection is restored for that path and unchanged everywhere else: an active
411
+ member row with an admin role, a stored `user.role` admin scalar, and
412
+ preview/no-auth mode all behave exactly as before, and nobody who was not an
413
+ administrator becomes one — pinned by four negative cases alongside the
414
+ positive one.
415
+
416
+ Also corrects the now-stale documentation that described the removed spelling:
417
+ the hook's own docblock, the `roles?: string[]` declaration on the client
418
+ `AuthUser` (kept for one remaining compile-time reader; see objectui#5424), and
419
+ two comments in `@object-ui/app-shell`'s Home page. No behaviour change from the
420
+ comment corrections.
421
+ - 343c598: The active organization id is now stored per user, and a change of session user drops the
422
+ previous user's client state wholesale (objectui#5664).
423
+
424
+ `auth-active-organization-id` was a single un-namespaced `localStorage` key while its
425
+ siblings were already user-scoped (`objectui-recent-items:u:`, `objectui-favorites:u:`,
426
+ `flow-palette-recents:u:`). On a browser handed from one account to another — a shared
427
+ machine, a kiosk, a handover, a support session — the arriving user's console read the
428
+ PREVIOUS user's organization id. The header workspace chip rendered the previous user's
429
+ workspace for a user whose `organization/list` was empty, and the consequence past the
430
+ cosmetics is the one worth stating: the polluted org context suppressed
431
+ `RequireOrganization`'s routing into the guided "Create your workspace" first-run flow, so
432
+ a brand-new user on that browser silently never got the new-user flow at all.
433
+
434
+ Nothing about row visibility rode on this. With the stale id the server answers
435
+ `403 USER_IS_NOT_A_MEMBER` on `get-full-organization` and `set-active`, and lists zero
436
+ environments; the damage was entirely in what the client believed about itself.
437
+
438
+ Three changes, and the third is the one that closes the class rather than the instance:
439
+
440
+ - The key is per-user (`auth-active-organization-id:u:$userId`), matching the convention
441
+ its siblings already use.
442
+ - It can no longer be written un-namespaced at all. Where no session user is known yet the
443
+ value lives in memory for that page-load only — a namespacing that kept a bare-key
444
+ fallback would re-open the defect the first time a write happened before the user id
445
+ resolved.
446
+ - **A change of session user drops the previous user's client state wholesale.** This is an
447
+ allowlist sweep of both `localStorage` and `sessionStorage`, not a list of known keys, so
448
+ the NEXT storage key someone adds without a `:u:` scope is covered before it is written.
449
+ Only device-scoped entries survive: the arriving session's own bearer token, the pointer
450
+ recording whose state the browser holds, and the UI theme.
451
+
452
+ Both properties objectui#5703 established are preserved and still pinned: `get()` prefers a
453
+ non-null `localStorage` read and falls back to the in-memory value, and the memory value is
454
+ nulled BEFORE storage is touched — by `clear()` as before, and now by the user-change purge
455
+ too, so the outgoing user's org id cannot outlive their persisted key on the sign-out-then-
456
+ sign-in path that never reloads the page.
457
+
458
+ Existing browsers are not migrated. A value sitting under the retired bare key is
459
+ unattributable — nothing recorded whose org id it is — so migrating it is precisely the
460
+ defect it would be migrating away from, and it is deleted instead. A signed-in user loses
461
+ nothing durable: the active organization is a server-owned fact that
462
+ `AuthProvider.refreshOrganizations` re-asks for whenever the list is non-empty and no
463
+ active org is held, including the ADR-0081 single-membership repair. One boot re-supplies
464
+ it; users with no organization land on the guided first-run flow, which is the outcome this
465
+ card is about.
466
+
467
+ `apps/console`'s pre-render auth preflight purges every spelling of the active-org key —
468
+ the retired bare one and each `:u:` scope — when it finds a dead bearer token, and
469
+ deliberately leaves the session-user pointer in place so the next sign-in can still tell
470
+ that the browser changed hands.
471
+ - 2dd9443: `purgeSignedOutClientCaches()` — the sweep that drops the signed-out user's
472
+ `objectui:metadata:*` seed cache on sign-out (objectui#5198) — now costs one key when a
473
+ single `removeItem` throws, instead of aborting the rest of the sweep (objectui#5777).
474
+
475
+ The `try` wrapped the WHOLE loop, not each removal. A `removeItem` that threw on key `n`
476
+ aborted the walk, so keys `n+1..end` were never swept, and the failure was swallowed —
477
+ `AuthProvider`'s `signOut` believed the purge had completed. The entries this sweeps are
478
+ the previous principal's org-scoped, PERMISSION-FILTERED app list — objectui#5198
479
+ classifies a surviving entry as a cross-principal disclosure on a shared browser, not
480
+ mere staleness — so a partial sweep here is the sharper half of the same defect class
481
+ objectui#5763 fixed on the sign-in path (`sweepStore` in `ActiveOrganizationStorage.ts`).
482
+
483
+ `Object.keys(sessionStorage)` — the reason a guard exists here at all — stays guarded on
484
+ its own; only the per-key guard is new. Same as `sweepStore`, a failed removal here is
485
+ not verified by read-back and not quarantined the way `ActiveOrganizationStorage.clear()`
486
+ (objectui#5731) quarantines a key: this function does not own reads for the metadata
487
+ seed cache (`MetadataProvider` in `@object-ui/app-shell` does), so there is no `get()` to
488
+ guard and nothing to quarantine — adding read-back verification would be a general
489
+ storage-error-handling refactor of the module, out of this card's scope. What is
490
+ mirrored is the reporting channel: a key whose `removeItem` throws is named in a
491
+ `console.warn`, the same channel `sweepStore` and `clear()` use, so a partial sweep is
492
+ discoverable instead of silent.
493
+
494
+ Adds a partial-failure test: a `sessionStorage` whose `removeItem` throws on one metadata
495
+ key, asserting every other metadata key on both sides of it is still swept and unrelated
496
+ non-matching keys are untouched, plus a control that the warning fires only on an actual
497
+ failure.
498
+ - ff2d547: `sweepStore()` — the walk that drops the previous user's `localStorage`/`sessionStorage`
499
+ state on a change of session user (objectui#5664 part 3) — now costs one key when a
500
+ single `removeItem` throws, instead of aborting the rest of the sweep (objectui#5763).
501
+
502
+ The `try` wrapped the WHOLE loop, not each removal. A `removeItem` that threw on key
503
+ `n` aborted the walk, so keys `n+1..end` were never swept, and the failure was
504
+ swallowed — `purgePreviousUserClientState()` returned normally and `SessionUserScope.adopt`
505
+ believed the sign-in purge had completed. This is an ALLOWLIST sweep precisely so the
506
+ next un-namespaced key — one nobody has written yet — cannot re-open the cross-user
507
+ pollution class #5664 fixed; a partial sweep is a partial allowlist, and which keys
508
+ survived depended on `Object.keys` iteration order rather than on anything bounded. The
509
+ previous user's org id, recents, favourites, or a `sessionStorage` metadata seed (their
510
+ permission-filtered app list, a cross-principal disclosure per objectui#5198) could all
511
+ land on the wrong side of the abort.
512
+
513
+ `Object.keys(store)` — the reason a guard exists here at all — stays guarded on its
514
+ own; only the per-key guard is new, so one uncooperative key now costs exactly that key.
515
+
516
+ Unlike `ActiveOrganizationStorage.clear()` (objectui#5731), a failed removal here is
517
+ NOT verified by read-back and NOT quarantined: `clear()` owns every future read of its
518
+ one key through `ActiveOrganizationStorage.get()`, so a "still readable" verdict and a
519
+ quarantine are what keep a failed `clear()` from handing the value straight back.
520
+ `sweepStore` walks keys it does not own reads for — another package's recents cache, a
521
+ metadata seed — so there is no `get()` here to guard and nothing to quarantine; adding
522
+ read-back verification for keys this function does not otherwise touch would be a
523
+ general storage-error-handling refactor of the module, which this card is scoped away
524
+ from. What IS mirrored is the reporting channel: a key whose `removeItem` throws is
525
+ named in a `console.warn`, the same channel `clear()` uses, so a partial sweep is
526
+ discoverable instead of silent. The caller (`SessionUserScope.adopt`, on the sign-in
527
+ path, inside an `AuthProvider` effect) still cannot act on the failure and must not
528
+ throw either.
529
+
530
+ A working `localStorage`/`sessionStorage` behaves exactly as before: every
531
+ non-device-scoped key is removed, nothing is reported, and the device-scoped allowlist
532
+ (`auth-session-token`, `auth-session-user-id`, `vite-ui-theme`) is unaffected.
533
+ - 36918bf: `switchOrganization` now re-resolves identity for the organization it just switched
534
+ to by its own explicit decision, instead of depending on an accidental
535
+ `TokenStorage` side effect to notice the switch (objectui#5750).
536
+
537
+ `AuthProvider.switchOrganization` has never called `loadSession()` itself. Identity
538
+ re-resolved across a switch only because `POST /organization/set-active` happens to
539
+ return the SIGNED `token.signature` spelling in `set-auth-token`, which differs from
540
+ the UNSIGNED `session.token` spelling `getSession()` normally stores — so
541
+ `TokenStorage.set` reads the flip as a rotation and the objectui#4467 subscription
542
+ calls `loadSession()` for it (measured and pinned in objectui#5749/#5719). That
543
+ signed spelling is deterministic on the raw session token, not on the organization:
544
+ two switches with no `get-session` landing in between produce the identical signed
545
+ value, so the SECOND `TokenStorage.set` sees no change, never notifies, and identity
546
+ is left answering for whichever organization the FIRST switch targeted even though
547
+ `activeOrganization` already reads as the new one.
548
+
549
+ Reachable in the console via `OrganizationLayout`'s slug-driven effect (the "Manage"
550
+ link on an org card, plus its own "Back to organizations" button) — ordinary
551
+ client-side navigation with no full-page reload and nothing debouncing repeat
552
+ switches. `WorkspaceSwitcher` and `OrganizationsPage`'s own card click were not
553
+ reachable paths for this: both force `window.location.href` immediately after a
554
+ successful switch, and the resulting fresh `AuthProvider` mount always performs an
555
+ authoritative `loadSession()` regardless of how the race above resolved.
556
+
557
+ `switchOrganization` now tracks the organization it last resolved to itself and
558
+ re-resolves explicitly whenever a switch's target differs from that, while
559
+ suppressing the (now redundant) rotation notification for its own `set-active`
560
+ call — so the common single-switch path still spends exactly one `get-session`, not
561
+ two. A generation guard discards a still-in-flight, now-superseded switch's answer
562
+ rather than let it clobber a later switch's fresher one.
563
+ - 934a532: Document the `X-Tenant-ID` edge contract that `createAuthenticatedFetch` stamps, and the
564
+ unstamped-first-request window in which it is not sent (objectui#5279). Documentation
565
+ only — no behaviour changes.
566
+
567
+ The header had no written contract anywhere, and the shape of the missing information was
568
+ actively misleading: its only non-CORS consumer lives in the **cloud** repository, so a
569
+ search confined to this repo and the framework (`objectstack`) returns zero readers and
570
+ reads as "nothing consumes this stamp". #5279 was filed on exactly that reading, and was
571
+ held until a cloud-side reading came back non-empty. Without the contract written down,
572
+ the next person to grep reaches the same false conclusion and deletes a live routing
573
+ input.
574
+
575
+ `packages/auth/README.md` gains "The `X-Tenant-ID` edge contract": what the header means
576
+ (a routing hint carrying the better-auth `activeOrganizationId` — not an identity claim,
577
+ not an authorization input, not what scopes rows), who stamps it and under exactly which
578
+ condition, who reads it, and what a reader may and may not assume. The framework half is
579
+ stated as a negative with its pin — `resolveAuthzContext` takes `tenantId` from the
580
+ API-key principal or `session.activeOrganizationId` and from no header — alongside
581
+ `plugin-sharing`'s record that trusting `x-tenant-id` as identity *was* a vulnerability.
582
+ The configuration half is quoted from the contract this package can actually resolve,
583
+ `TenantRoutingConfigSchema` in `@objectstack/spec/cloud`, where `X-Tenant-ID` is the
584
+ default of a configurable `tenantHeaderName` and `header` ranks second of six
585
+ identification sources behind `subdomain`.
586
+
587
+ The unstamped-first-request gap gets its own section: `ActiveOrganizationStorage` is
588
+ filled only after `AuthProvider`'s async `getSession` -> `listOrganizations` ->
589
+ `getActiveOrganization` chain resolves, so early-boot requests carry no tenant header at
590
+ all. What a reader observes is documented as **absent, never present-and-empty**, with the
591
+ five situations that open the window and the instruction to fall through to the next
592
+ identification source rather than fail closed. The gap is recorded, deliberately not
593
+ closed: the cloud readers observe today's behaviour, so changing when the header first
594
+ appears is its own decision.
595
+
596
+ Three cases in `createAuthenticatedFetch.test.tsx` pin the statements the prose makes
597
+ about the wire — no active organization means no header at all, the stamp is not gated on
598
+ `/api/` the way `Authorization` is, and the active organization overwrites a caller-set
599
+ `X-Tenant-ID` — so the documentation cannot drift away from the behaviour unnoticed.
600
+
3
601
  ## 17.6.0
4
602
 
5
603
  ### Minor Changes