@object-ui/auth 17.3.0 → 17.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +213 -0
- package/README.md +184 -13
- package/dist/ActiveOrganizationStorage.d.ts +168 -0
- package/dist/ActiveOrganizationStorage.d.ts.map +1 -0
- package/dist/ActiveOrganizationStorage.js +515 -0
- package/dist/AuthContext.d.ts +34 -1
- package/dist/AuthContext.d.ts.map +1 -1
- package/dist/AuthGuard.d.ts +8 -1
- package/dist/AuthGuard.d.ts.map +1 -1
- package/dist/AuthGuard.js +16 -3
- package/dist/AuthProvider.d.ts +1 -1
- package/dist/AuthProvider.d.ts.map +1 -1
- package/dist/AuthProvider.js +359 -32
- package/dist/ForgotPasswordForm.d.ts +1 -1
- package/dist/ForgotPasswordForm.d.ts.map +1 -1
- package/dist/ForgotPasswordForm.js +5 -5
- package/dist/LoginForm.d.ts +1 -1
- package/dist/LoginForm.d.ts.map +1 -1
- package/dist/LoginForm.js +4 -4
- package/dist/PreviewBanner.js +1 -1
- package/dist/RegisterForm.d.ts +1 -1
- package/dist/RegisterForm.d.ts.map +1 -1
- package/dist/RegisterForm.js +3 -3
- package/dist/SocialSignInButtons.js +2 -2
- package/dist/UserMenu.js +2 -2
- package/dist/createAuthClient.d.ts +23 -1
- package/dist/createAuthClient.d.ts.map +1 -1
- package/dist/createAuthClient.js +112 -25
- package/dist/createAuthenticatedFetch.d.ts +9 -9
- package/dist/createAuthenticatedFetch.d.ts.map +1 -1
- package/dist/createAuthenticatedFetch.js +82 -37
- package/dist/index.d.ts +20 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -17
- package/dist/invitation-status.d.ts +59 -0
- package/dist/invitation-status.d.ts.map +1 -0
- package/dist/invitation-status.js +32 -0
- package/dist/types.d.ts +52 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/useAuth.d.ts +1 -1
- package/dist/useAuth.d.ts.map +1 -1
- package/dist/useAuth.js +6 -1
- package/dist/useWorkspaceAdminStatus.d.ts +85 -0
- package/dist/useWorkspaceAdminStatus.d.ts.map +1 -0
- package/dist/useWorkspaceAdminStatus.js +102 -0
- package/package.json +5 -5
- package/dist/useIsWorkspaceAdmin.d.ts +0 -21
- package/dist/useIsWorkspaceAdmin.d.ts.map +0 -1
- package/dist/useIsWorkspaceAdmin.js +0 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,217 @@
|
|
|
1
1
|
# @object-ui/auth
|
|
2
2
|
|
|
3
|
+
## 17.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1b21b1a: `AuthInvitation.status` is the closed four-member union it always documented, enforced at the auth client's wire boundary.
|
|
8
|
+
|
|
9
|
+
The field was declared `status: string` while its own doc comment enumerated
|
|
10
|
+
`'pending' | 'accepted' | 'rejected' | 'canceled'`, and `createAuthClient` cast
|
|
11
|
+
better-auth's `any` responses straight to `AuthInvitation[]` with no check. The
|
|
12
|
+
enumeration was therefore advisory: any value a backend stored reached the
|
|
13
|
+
console untouched, where the invitations screen coloured it through a
|
|
14
|
+
`default: 'secondary'` badge arm and printed it with
|
|
15
|
+
`defaultValue: inv.status` — the raw wire string rendered as interface copy, in
|
|
16
|
+
all ten packs (objectui#3879). Nobody could trigger it today, because the four
|
|
17
|
+
are what better-auth writes; it was the contract that was loose, and a loose
|
|
18
|
+
consumer is where an unexpected value would have hidden.
|
|
19
|
+
|
|
20
|
+
`AuthInvitationStatus` now carries the union and **binds better-auth's own
|
|
21
|
+
`InvitationStatus`** rather than restating it, the way `org-roles.ts` binds the
|
|
22
|
+
spec's `BUILTIN_MEMBERSHIP_ROLES` — a member-for-member copy is the state one
|
|
23
|
+
upstream release away from drifting silently. The runtime list
|
|
24
|
+
(`AUTH_INVITATION_STATUSES`) is derived from a total map keyed by that union, so
|
|
25
|
+
a fifth status upstream is a build failure here rather than a gap in the guard.
|
|
26
|
+
`isAuthInvitationStatus` is exported alongside, and all four invitation-returning
|
|
27
|
+
client methods (`listInvitations`, `listUserInvitations`, `getInvitation`,
|
|
28
|
+
`inviteMember`) narrow their wire rows through it.
|
|
29
|
+
|
|
30
|
+
Behaviour change, stated because it is one: an invitation whose `status` is
|
|
31
|
+
outside the set now **fails loudly** — the call rejects with a message naming the
|
|
32
|
+
value it refused and the four it expected — instead of resolving into a badge.
|
|
33
|
+
Both call paths already render a rejection with a retry, so the throw lands on a
|
|
34
|
+
designed surface. Degrading quietly was the alternative and was deliberately not
|
|
35
|
+
taken: a neutral label is how the raw value shipped in the first place, and
|
|
36
|
+
dropping the row would delete an invitation from an administrative ledger without
|
|
37
|
+
saying so. The console's badge switch is exhaustive over the union now and has no
|
|
38
|
+
`default:` arm, so if better-auth ever adds a member the type-check gate stops the
|
|
39
|
+
build at the one place a human has to choose a colour.
|
|
40
|
+
|
|
41
|
+
Consumers assigning an arbitrary string to `AuthInvitation.status` (a hand-built
|
|
42
|
+
fixture, a mock) will need one of the four members.
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- 5458414: Publish relative import specifiers with explicit `.js` extensions so these six packages load under plain Node ESM.
|
|
47
|
+
|
|
48
|
+
Node's ESM resolver does not extension-search relative specifiers and `tsc` never rewrites them, so an extensionless `./Foo` in the source shipped as an extensionless `./Foo` in `dist` and importing the package entry outside a bundler failed with `ERR_MODULE_NOT_FOUND`. Bundled consumers were unaffected. Unbundled consumers — plain Node ESM, an SSR host importing the package directly, anyone running the published tarball without a build step — can now import these entries, and so can the downstream `@object-ui/plugin-*` packages that evaluate through `mobile`, `permissions` and `providers`.
|
|
49
|
+
- 61b097c: Sign-out now drops the client-side caches that belonged to the session it ends,
|
|
50
|
+
and the metadata seed cache is keyed by session identity.
|
|
51
|
+
|
|
52
|
+
`sessionStorage` is per-tab, not per-session, and no sign-out call site reloads
|
|
53
|
+
the page — so the `objectui:metadata:*` entries (the app list the server
|
|
54
|
+
permission-filters per session) and the active-organization id survived into
|
|
55
|
+
whatever happened next in that tab. A second person signing in on a shared,
|
|
56
|
+
kiosk or handover browser was seeded with the previous user's filtered app list.
|
|
57
|
+
Organization scoping did not close this: two users in the same organization
|
|
58
|
+
computed the same cache key.
|
|
59
|
+
|
|
60
|
+
`AuthProvider.signOut()` now purges the `objectui:metadata:` entries and clears
|
|
61
|
+
`ActiveOrganizationStorage` (and the in-memory organization block) on both the
|
|
62
|
+
success and failure paths, and `MetadataProvider` keys each entry by a
|
|
63
|
+
fingerprint of the session token, so an entry that escapes the purge is
|
|
64
|
+
unreadable by the next principal rather than merely undeleted. Entries left by
|
|
65
|
+
another principal are deleted the first time a console mounts.
|
|
66
|
+
- a9e17b4: `auth.forgotPassword.successDescription`'s address hole is now spelled with
|
|
67
|
+
single braces (`{email}`) instead of i18next's double braces (`{{email}}`),
|
|
68
|
+
in all ten locale packs.
|
|
69
|
+
|
|
70
|
+
This is a spelling-only change — rendered output is byte-identical in every
|
|
71
|
+
language, because the hole was never filled by i18next in the first place:
|
|
72
|
+
`ForgotPasswordForm` substitutes the address itself once the user submits
|
|
73
|
+
the form (the label renders before the address exists, so `t()` cannot do
|
|
74
|
+
it). `{{email}}` and a genuinely unfilled i18next hole were indistinguishable
|
|
75
|
+
at the call site, and passing `email` as an interpolation argument — the
|
|
76
|
+
natural "fix" for what looks like a missing argument — would let i18next
|
|
77
|
+
consume the hole and cause the address to be appended a second time
|
|
78
|
+
(objectui#4135).
|
|
79
|
+
|
|
80
|
+
Converging on `{x}` for every hole a component fills downstream of `t()`
|
|
81
|
+
(the convention `resendOtpCountdownText`'s `{seconds}` already used) puts
|
|
82
|
+
this hole outside i18next's `{{…}}` syntax entirely, so the ambiguity is
|
|
83
|
+
gone by construction rather than fenced by an exemption. Accordingly,
|
|
84
|
+
`scripts/check-i18n-call-site-keys.mjs`'s `EXTERNALLY_INTERPOLATED_HOLES`
|
|
85
|
+
registry entry for this key is retired — the gate needs no exemption for a
|
|
86
|
+
hole i18next was never going to touch.
|
|
87
|
+
|
|
88
|
+
`ForgotPasswordForm.tsx`'s replacement marker and its own built-in default
|
|
89
|
+
label move to the same spelling in the same change, as does the inline
|
|
90
|
+
`defaultValue` at `apps/console/src/pages/auth/ForgotPasswordPage.tsx`'s
|
|
91
|
+
call site.
|
|
92
|
+
- Updated dependencies [88085e3]
|
|
93
|
+
- Updated dependencies [279fb13]
|
|
94
|
+
- Updated dependencies [1184192]
|
|
95
|
+
- Updated dependencies [a2a9747]
|
|
96
|
+
- Updated dependencies [af5e292]
|
|
97
|
+
- Updated dependencies [7f96b10]
|
|
98
|
+
- Updated dependencies [f1d4748]
|
|
99
|
+
- Updated dependencies [578e025]
|
|
100
|
+
- Updated dependencies [598c89a]
|
|
101
|
+
- Updated dependencies [b8b9af4]
|
|
102
|
+
- Updated dependencies [97abb24]
|
|
103
|
+
- Updated dependencies [deb157a]
|
|
104
|
+
- Updated dependencies [d2ce342]
|
|
105
|
+
- Updated dependencies [9695da7]
|
|
106
|
+
- Updated dependencies [58b8346]
|
|
107
|
+
- Updated dependencies [3cf4de0]
|
|
108
|
+
- Updated dependencies [c9dc811]
|
|
109
|
+
- Updated dependencies [a0b9e91]
|
|
110
|
+
- Updated dependencies [99bd015]
|
|
111
|
+
- @object-ui/types@17.6.0
|
|
112
|
+
|
|
113
|
+
## 17.5.0
|
|
114
|
+
|
|
115
|
+
### Minor Changes
|
|
116
|
+
|
|
117
|
+
- 5cc847c: The data lane now honors `set-auth-token`, so impersonation takes effect at all (#4467).
|
|
118
|
+
|
|
119
|
+
The console injects the same localStorage bearer from two lanes: the AUTH lane
|
|
120
|
+
(`createBearerFetch` inside `createAuthClient`) and the DATA lane
|
|
121
|
+
(`createAuthenticatedFetch` — the adapter, `provider: 'api'` data sources, and every
|
|
122
|
+
metadata `type: 'api'` action). better-auth's server-side bearer plugin hands a ROTATED
|
|
123
|
+
session token back in the `set-auth-token` response header on whichever lane the call
|
|
124
|
+
arrived over, and only the auth lane read it. A rotation issued to a data-lane call was
|
|
125
|
+
discarded and the browser kept sending the old token.
|
|
126
|
+
|
|
127
|
+
`POST /auth/admin/impersonate-user` is exactly such a call — an ordinary metadata action.
|
|
128
|
+
The impersonated session token was dropped on the floor while the server's bearer plugin
|
|
129
|
+
kept overwriting the impersonation cookie with the admin bearer the console kept sending,
|
|
130
|
+
so impersonation was a complete no-op in the console rather than merely an invisible one.
|
|
131
|
+
Support staff believed they were seeing a user's view while acting entirely as themselves.
|
|
132
|
+
|
|
133
|
+
Published behaviour that moves: a data-lane response carrying `set-auth-token` now
|
|
134
|
+
replaces the stored session token, on any API call this lane authenticated (untrusted
|
|
135
|
+
targets remain the `sameOriginOnly` option's job — it short-circuits before any header
|
|
136
|
+
work). The accepted cost, recorded on the card: while impersonating, the administrator's
|
|
137
|
+
own token is replaced in localStorage for the duration, and a client that misses the stop
|
|
138
|
+
rotation is stranded until re-login.
|
|
139
|
+
|
|
140
|
+
Also in this release, all additive:
|
|
141
|
+
|
|
142
|
+
- `AuthContextValue.refreshSession()` re-resolves `user`/`session` from the server in
|
|
143
|
+
place, without raising `isLoading` — the transitions that change WHO the session is
|
|
144
|
+
without going through `signIn`/`signOut`.
|
|
145
|
+
- `TokenStorage.subscribeRotation()` notifies when a token already in hand is replaced by
|
|
146
|
+
a different one. First store, `clear()`, and re-storing the same value stay silent:
|
|
147
|
+
those transitions have an owner that updates identity itself.
|
|
148
|
+
- `AuthClientSession.impersonatedBy?: string` — optional, set by better-auth's admin
|
|
149
|
+
plugin for the life of an impersonated session.
|
|
150
|
+
|
|
151
|
+
### Patch Changes
|
|
152
|
+
|
|
153
|
+
- 58bebf6: Organization & invitation console: translate the English holdouts a zh session was left reading (#4474)
|
|
154
|
+
|
|
155
|
+
Three families of string, one sweep over `console/organizations/`:
|
|
156
|
+
|
|
157
|
+
- **Role names** now come from the single shared `ORG_ROLE_LABELS` map at every
|
|
158
|
+
site. The role badges on the members and invitations pages were rendering the
|
|
159
|
+
raw server identifier (`owner`) under a CSS `capitalize` that made it look like
|
|
160
|
+
a label in English and left it untranslated everywhere else; the accept page
|
|
161
|
+
did the same in its role row and inside its otherwise-translated sentence. The
|
|
162
|
+
map's four `organization.roles.*` keys existed in no locale pack, so even the
|
|
163
|
+
dropdown that did consult it fell through to English — all ten packs now carry
|
|
164
|
+
them. An unrecognized role renders verbatim rather than blank.
|
|
165
|
+
- **Server-echoed errors** are mapped by better-auth's stable `code`, never by
|
|
166
|
+
matching its English text. `createAuthClient` was dropping that code for every
|
|
167
|
+
`organization.*` call while preserving it for sign-in/sign-up, so the console
|
|
168
|
+
had nothing to key on; all sixteen organization methods now go through the same
|
|
169
|
+
`toAuthError` helper. Messages are unchanged — the code simply stops being
|
|
170
|
+
thrown away. An unmapped code still shows the server's own sentence.
|
|
171
|
+
- **Icon-only `aria-label`s** (member actions, copy invitation link, cancel
|
|
172
|
+
invitation, and a fourth on the share-link copy button) are translated — for an
|
|
173
|
+
icon-only control this is the only name a screen reader gets.
|
|
174
|
+
|
|
175
|
+
- 564252c: `features.passkeys` and `features.magicLink` are documented as reserved, so enabling them no longer implies a login-page entry point that does not exist
|
|
176
|
+
|
|
177
|
+
`GET /api/v1/auth/config` advertises eight login-surface capability flags. Six of them gate something real — `sso` gates the "Sign in with SSO" button, `phoneNumberOtp` the verification-code mode, `deviceAuthorization` the device-approval page — and each carries a doc comment saying what it gates. Two did not gate anything: `passkeys` and `magicLink` existed only as two undocumented lines in `AuthPublicConfig.features`, consumed by no component, no route and no metadata. A deployer who turned one on got a flag that changed nothing, with nothing anywhere to say so. That is the mirror of the defect the audit was looking for (framework#2874 P2②): not UI advertising a capability the backend lacks, but a backend advertising a capability the UI lacks.
|
|
178
|
+
|
|
179
|
+
Both flags are now marked reserved at the two places a deployer meets them. The declarations in `packages/auth/src/types.ts` carry doc comments — which ship in the published `.d.ts`, so the warning appears on hover — stating that enabling the flag adds no entry point and naming the follow-up card. `packages/auth/README.md` gains a "Server Feature Flags" section documenting what the `features` map is for and a "Reserved flags" table saying the same thing in prose. Per the maintainer's ruling on objectui#2514, the UI is deliberately not built here; it is filed as objectui#4179 and left unscheduled.
|
|
180
|
+
|
|
181
|
+
No runtime behaviour changes: nothing read these flags before and nothing reads them now. What changes is that the published types and docs stop being silent about it.
|
|
182
|
+
|
|
183
|
+
The three artifacts are pinned together by `packages/auth/src/__tests__/reserved-auth-features.test.ts`, which asserts the declarations still exist, that each carries a reserved marking naming the card, that the README section says the same, and — the inverse direction — that no source file under `packages/*/src`, `apps/*/src` or `examples/*/src` references either identifier. The sweep covers `.json` as well as `.ts`/`.tsx` because `AppContent` hands the whole `features` object to `ExpressionProvider`, so authored metadata can reach these flags without any source file naming them. Two calibration cases keep the pin from rotting into a vacuous pass: one asserts the sweep reads a non-trivial number of files, the other that it can still find a flag that _is_ consumed. When the UI is eventually built, the pin fails and its docblock spells out the retirement checklist — the doc comments, the README section and the pin itself come out in the same PR.
|
|
184
|
+
|
|
185
|
+
`features.twoFactor` is untouched and explicitly excluded: two-factor is implemented in this package (`enableTwoFactor` / `verifyTwoFactor`) and its challenge is driven by server-side remediation rather than by the flag, so `LoginForm` not reading it is the design rather than a gap. The README says so, and the pin asserts `twoFactor` never appears in the reserved table.
|
|
186
|
+
|
|
187
|
+
- Updated dependencies [f650253]
|
|
188
|
+
- Updated dependencies [3d9769a]
|
|
189
|
+
- Updated dependencies [92876f0]
|
|
190
|
+
- Updated dependencies [d9d3463]
|
|
191
|
+
- Updated dependencies [2a40f69]
|
|
192
|
+
- Updated dependencies [bec3e14]
|
|
193
|
+
- Updated dependencies [1f9b905]
|
|
194
|
+
- Updated dependencies [abb0f81]
|
|
195
|
+
- Updated dependencies [38ab505]
|
|
196
|
+
- Updated dependencies [7e4f0e5]
|
|
197
|
+
- Updated dependencies [c1d939f]
|
|
198
|
+
- Updated dependencies [bb68488]
|
|
199
|
+
- Updated dependencies [ab04728]
|
|
200
|
+
- @object-ui/types@17.5.0
|
|
201
|
+
|
|
202
|
+
## 17.4.0
|
|
203
|
+
|
|
204
|
+
### Patch Changes
|
|
205
|
+
|
|
206
|
+
- Updated dependencies [d229dfa]
|
|
207
|
+
- Updated dependencies [c2fd122]
|
|
208
|
+
- Updated dependencies [48132f7]
|
|
209
|
+
- Updated dependencies [7e5bb5d]
|
|
210
|
+
- Updated dependencies [e6fdbdc]
|
|
211
|
+
- Updated dependencies [7e2b7e9]
|
|
212
|
+
- Updated dependencies [c1e1e6b]
|
|
213
|
+
- @object-ui/types@17.4.0
|
|
214
|
+
|
|
3
215
|
## 17.3.0
|
|
4
216
|
|
|
5
217
|
### Patch Changes
|
|
@@ -868,6 +1080,7 @@
|
|
|
868
1080
|
no visible effect — the user/session were nulled but the UI stayed authenticated.
|
|
869
1081
|
|
|
870
1082
|
Changes:
|
|
1083
|
+
|
|
871
1084
|
- **`@object-ui/auth`** — added `isAuthEnabled: boolean` to `AuthContextValue`
|
|
872
1085
|
(`true` only when real auth is in use, `false` for guest/preview modes).
|
|
873
1086
|
- **`@object-ui/app-shell`** — `AppHeader` and `AppSidebar` now hide the "Log out"
|
package/README.md
CHANGED
|
@@ -21,7 +21,6 @@ npm install @object-ui/auth
|
|
|
21
21
|
|
|
22
22
|
**Peer Dependencies:**
|
|
23
23
|
- `react` ^18.0.0 || ^19.0.0
|
|
24
|
-
- `react-dom` ^18.0.0 || ^19.0.0
|
|
25
24
|
|
|
26
25
|
## Quick Start
|
|
27
26
|
|
|
@@ -138,6 +137,190 @@ const authedFetch = createAuthenticatedFetch();
|
|
|
138
137
|
const apiProviderFetch = createAuthenticatedFetch({ sameOriginOnly: true });
|
|
139
138
|
```
|
|
140
139
|
|
|
140
|
+
## The `X-Tenant-ID` edge contract
|
|
141
|
+
|
|
142
|
+
`createAuthenticatedFetch` stamps `X-Tenant-ID` on the requests it wraps. This section is
|
|
143
|
+
that header's contract: what it means, who stamps it, who reads it, what a reader may
|
|
144
|
+
assume, and the one window in which it is not sent at all.
|
|
145
|
+
|
|
146
|
+
It is written down because **a grep cannot answer any of those questions from this
|
|
147
|
+
repository.** The header's only non-CORS consumer lives in the **cloud** repository, so a
|
|
148
|
+
search confined to this repo and the framework (`objectstack`) finds zero readers and
|
|
149
|
+
reads as *"nothing consumes this stamp"*. That reading is what
|
|
150
|
+
[objectui#5279](https://github.com/objectstack-ai/objectui/issues/5279) was filed on, and
|
|
151
|
+
it is false. Do not act on it.
|
|
152
|
+
|
|
153
|
+
### What it means
|
|
154
|
+
|
|
155
|
+
A **routing hint** for the hosting edge — *this request belongs to tenant `<id>`*:
|
|
156
|
+
|
|
157
|
+
```http
|
|
158
|
+
X-Tenant-ID: <better-auth activeOrganizationId>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The value is the active **organization** id, the same id the session carries as
|
|
162
|
+
`session.activeOrganizationId`. The header is **not** an identity claim, **not** an
|
|
163
|
+
authorization input, and **not** what scopes rows.
|
|
164
|
+
|
|
165
|
+
### Who stamps it
|
|
166
|
+
|
|
167
|
+
| | |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| Stamped by | `createAuthenticatedFetch` ([`src/createAuthenticatedFetch.ts`](src/createAuthenticatedFetch.ts)) |
|
|
170
|
+
| Value read from | `ActiveOrganizationStorage` ([`src/ActiveOrganizationStorage.ts`](src/ActiveOrganizationStorage.ts)) — `localStorage`, key `auth-active-organization-id:u:$userId` (per-user since objectui#5664; the bare key is the retired pre-#5664 spelling) |
|
|
171
|
+
| Condition | that storage holds a non-empty value |
|
|
172
|
+
| *Not* conditioned on | the URL being an `/api/` call. `Authorization` and `Accept-Language` are; this is not |
|
|
173
|
+
| Suppressed by | `createAuthenticatedFetch({ sameOriginOnly: true })` for cross-origin URLs — it returns before any header work |
|
|
174
|
+
| Precedence | overwrites an `X-Tenant-ID` the caller passed in `init.headers` |
|
|
175
|
+
|
|
176
|
+
`ActiveOrganizationStorage` has exactly one writer, [`AuthProvider`](src/AuthProvider.tsx):
|
|
177
|
+
|
|
178
|
+
| Event | Effect |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| `refreshOrganizations` — after `getSession` → `listOrganizations` → `getActiveOrganization` resolves (including the ADR-0081 single-membership repair) | set |
|
|
181
|
+
| `switchOrganization` — the org switcher | set, or clear when the server returns no org |
|
|
182
|
+
| `deleteOrganization` / `leaveOrganization`, when the active org is the one going away | clear |
|
|
183
|
+
| sign-out | clear |
|
|
184
|
+
|
|
185
|
+
### Who reads it
|
|
186
|
+
|
|
187
|
+
**The cloud edge — yes.** The non-test readers are recorded on objectui#5279:
|
|
188
|
+
`packages/service-tenant/src/tenant-context.ts` (header resolution) and
|
|
189
|
+
`packages/tenant-router/src/spec/turso-multi-tenant.zod.ts`, both in the `cloud`
|
|
190
|
+
repository. That repository is not readable from this one, so those paths are cited as the
|
|
191
|
+
recorded reading rather than re-derived here.
|
|
192
|
+
|
|
193
|
+
**Its configuration contract, though, IS readable from here**, because this package
|
|
194
|
+
depends on `@objectstack/spec`. `TenantRoutingConfigSchema` (`@objectstack/spec/cloud`) is
|
|
195
|
+
what configures a tenant resolver; parsing an empty config on the version this package
|
|
196
|
+
resolves today (17.1.0) yields:
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
enabled: false
|
|
200
|
+
identificationSources: ["subdomain", "header", "jwt_claim"]
|
|
201
|
+
tenantHeaderName: "X-Tenant-ID"
|
|
202
|
+
jwtOrganizationClaim: "organizationId"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Two consequences matter to a client author:
|
|
206
|
+
|
|
207
|
+
- **The header name is configurable.** `X-Tenant-ID` is its default, not a constant.
|
|
208
|
+
- **The header is one of six identification sources** (`subdomain`, `custom_domain`,
|
|
209
|
+
`header`, `jwt_claim`, `session`, `default`), and in the default precedence it ranks
|
|
210
|
+
**second, behind `subdomain`**. On a subdomain-routed deployment it is not the thing
|
|
211
|
+
that picks the tenant, and a deployment may leave it out of `identificationSources`
|
|
212
|
+
entirely.
|
|
213
|
+
|
|
214
|
+
**The framework (`objectstack`) — no.** `resolveAuthzContext`
|
|
215
|
+
(`packages/core/src/security/resolve-authz-context.ts`) derives `tenantId` from the
|
|
216
|
+
API-key principal or from `session.activeOrganizationId`, and from no header;
|
|
217
|
+
`packages/verify/src/harness.org-context.test.ts` pins it — *"`session.activeOrganizationId`
|
|
218
|
+
is the ONE field `resolveAuthzContext` reads into `tenantId`"*. Environment and kernel
|
|
219
|
+
routing read the hostname and `X-Environment-Id`. The framework's only other mentions are
|
|
220
|
+
the CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS`, whose comment describes
|
|
221
|
+
`X-Tenant-ID` / `X-Environment-Id` as what routes "a request to its environment") and
|
|
222
|
+
`plugin-sharing`, which records that trusting `x-tenant-id` as identity **was a
|
|
223
|
+
vulnerability**: its secure default stopped reading identity from headers because doing so
|
|
224
|
+
let a client forge attribution and enumerate or revoke other users' links.
|
|
225
|
+
|
|
226
|
+
### What a reader may assume
|
|
227
|
+
|
|
228
|
+
A reader **may**:
|
|
229
|
+
|
|
230
|
+
- use it to select the tenant database or environment to route to, subject to its own
|
|
231
|
+
`identificationSources` precedence;
|
|
232
|
+
- treat it as a hint that may be absent, stale, or contradicted by the session.
|
|
233
|
+
|
|
234
|
+
A reader **may not**:
|
|
235
|
+
|
|
236
|
+
- treat it as authenticated identity, or as an authorization decision. It is
|
|
237
|
+
client-controlled — any caller can send any value, and `plugin-sharing` is the recorded
|
|
238
|
+
precedent for what happens to a server that trusts it;
|
|
239
|
+
- assume the row scoping it sees downstream came from this header. It did not: the
|
|
240
|
+
framework scopes from the session;
|
|
241
|
+
- assume the header is present. See below.
|
|
242
|
+
|
|
243
|
+
### The unstamped-first-request gap
|
|
244
|
+
|
|
245
|
+
The stamp reads storage that `AuthProvider` fills only **after** `getSession` →
|
|
246
|
+
`listOrganizations` → `getActiveOrganization` resolves. Every request that leaves
|
|
247
|
+
before that chain completes carries **no** `X-Tenant-ID` at all.
|
|
248
|
+
|
|
249
|
+
The window opens in five situations:
|
|
250
|
+
|
|
251
|
+
1. a browser that has never signed in, or whose site data was cleared, including a private
|
|
252
|
+
window;
|
|
253
|
+
2. the page load in which the user signs in — from the sign-in response until the
|
|
254
|
+
organization chain resolves;
|
|
255
|
+
3. after sign-out, which clears the storage, until a new organization resolves;
|
|
256
|
+
4. outside a browser (SSR, tests, a worker), where `localStorage` is unavailable and the
|
|
257
|
+
storage falls back to a per-process in-memory value that starts empty on every cold
|
|
258
|
+
start;
|
|
259
|
+
5. a browser where `localStorage` exists but **rejects writes** (Safari private browsing,
|
|
260
|
+
quota exhaustion). There the window never closes — the in-memory fallback is written
|
|
261
|
+
but never read back, because `get()` only falls back when reading itself *throws*, and
|
|
262
|
+
a rejected write leaves reads working and returning `null`. Reported on objectui#5279
|
|
263
|
+
as a separate defect; it is not fixed here.
|
|
264
|
+
|
|
265
|
+
Case 1 is not theoretical in this codebase: `app-shell`'s `MetadataProvider` issues its
|
|
266
|
+
eager `app` metadata fetch inside exactly this window, which is why its first-boot cache
|
|
267
|
+
scope needed its own reasoning
|
|
268
|
+
([`packages/app-shell/src/providers/MetadataProvider.tsx`](../app-shell/src/providers/MetadataProvider.tsx),
|
|
269
|
+
pinned by `MetadataProvider.firstBootOrgScope.test.tsx`).
|
|
270
|
+
|
|
271
|
+
**What a reader observes in the window:** the header is **absent**, never
|
|
272
|
+
present-and-empty. A resolver must fall through to its next configured identification
|
|
273
|
+
source — `subdomain` already outranks it by default, with `jwt_claim`, `session` and
|
|
274
|
+
`defaultTenantId` behind it — rather than fail closed on the absence. It must also not
|
|
275
|
+
cache a routing decision taken inside the window as *the* tenant for the session: the very
|
|
276
|
+
next request will normally carry the header.
|
|
277
|
+
|
|
278
|
+
**Why the gap does not corrupt data scoping.** A response computed inside the window is
|
|
279
|
+
still computed for the right tenant, because the framework takes `tenantId` from the
|
|
280
|
+
session rather than from the header. The gap is a *routing-input* gap, not a scoping gap.
|
|
281
|
+
This is the same reasoning objectui#5243 relied on when it relabelled a metadata cache
|
|
282
|
+
entry that had been written in the window.
|
|
283
|
+
|
|
284
|
+
**Closing the gap is a separate decision, not an omission.** The cloud readers observe the
|
|
285
|
+
current behaviour, so changing when the header first appears changes what they see. If it
|
|
286
|
+
should be closed, it needs its own card.
|
|
287
|
+
|
|
288
|
+
## Server Feature Flags (`GET /auth/config`)
|
|
289
|
+
|
|
290
|
+
`createAuthClient().getConfig()` fetches the server's public auth configuration. The
|
|
291
|
+
`features` map on it tells the login surface which capabilities the deployment actually
|
|
292
|
+
has, so the UI never renders an entry point whose endpoint is not mounted — `features.sso`
|
|
293
|
+
gates the "Sign in with SSO" button, `features.phoneNumberOtp` gates the
|
|
294
|
+
verification-code mode, `features.deviceAuthorization` gates the device-approval page,
|
|
295
|
+
and so on.
|
|
296
|
+
|
|
297
|
+
### Reserved flags — advertised by the server, consumed by nothing
|
|
298
|
+
|
|
299
|
+
Two members of that map are **declared but deliberately not consumed** by this package:
|
|
300
|
+
|
|
301
|
+
| Flag | Status | What enabling it does in the UI today |
|
|
302
|
+
| --- | --- | --- |
|
|
303
|
+
| `features.passkeys` | **Reserved** | Nothing. There is no passkey sign-in or registration UI for it to gate. |
|
|
304
|
+
| `features.magicLink` | **Reserved** | Nothing. There is neither a magic-link request step nor a route that consumes the emailed token. |
|
|
305
|
+
|
|
306
|
+
They are typed so the `/auth/config` payload round-trips without loss, not because
|
|
307
|
+
anything reads them. **Turning either flag on server-side adds no entry point to the login
|
|
308
|
+
page.** If you are enabling one because you expect a button to appear, it will not — and
|
|
309
|
+
that is the whole reason this section exists.
|
|
310
|
+
|
|
311
|
+
Building the two flows is tracked as a separate, unscheduled feature card,
|
|
312
|
+
[objectui#4179](https://github.com/objectstack-ai/objectui/issues/4179). Documenting them
|
|
313
|
+
as reserved rather than building them now follows the maintainer's ruling on
|
|
314
|
+
[objectui#2514](https://github.com/objectstack-ai/objectui/issues/2514): mark them
|
|
315
|
+
reserved today, build the UI when the login surface gets roadmap time, at which point it
|
|
316
|
+
renders driven by these same flags. When that lands, this section retires with it.
|
|
317
|
+
|
|
318
|
+
`features.twoFactor` is **not** in this category, despite also not being read by
|
|
319
|
+
`LoginForm`. Two-factor authentication is implemented here (`enableTwoFactor` /
|
|
320
|
+
`verifyTwoFactor` on the auth client) and its challenge is driven by server-side
|
|
321
|
+
remediation rather than by the flag, so the login form ignoring the flag is the design,
|
|
322
|
+
not a gap.
|
|
323
|
+
|
|
141
324
|
## Preview Mode
|
|
142
325
|
|
|
143
326
|
Preview mode allows visitors (e.g. marketplace customers) to explore the platform without registering or logging in. The `AuthProvider` auto-authenticates with a simulated user identity and bypasses login/registration screens.
|
|
@@ -207,20 +390,8 @@ function MyComponent() {
|
|
|
207
390
|
|
|
208
391
|
> **⚠️ Security:** Preview mode should **never** be used in production environments.
|
|
209
392
|
|
|
210
|
-
<!-- release-metadata:v3.3.0 -->
|
|
211
|
-
|
|
212
|
-
## Compatibility
|
|
213
|
-
|
|
214
|
-
- **React:** 18.x or 19.x
|
|
215
|
-
- **Node.js:** ≥ 18
|
|
216
|
-
- **TypeScript:** ≥ 5.0 (strict mode)
|
|
217
|
-
- **`@objectstack/spec`:** ^3.3.0
|
|
218
|
-
- **`@objectstack/client`:** ^3.3.0
|
|
219
|
-
- **Tailwind CSS:** ≥ 3.4 (for packages with UI)
|
|
220
|
-
|
|
221
393
|
## Links
|
|
222
394
|
|
|
223
|
-
- 📚 [Documentation](https://www.objectui.org/docs/packages/auth)
|
|
224
395
|
- 📦 [npm package](https://www.npmjs.com/package/@object-ui/auth)
|
|
225
396
|
- 📝 [Changelog](./CHANGELOG.md)
|
|
226
397
|
- 🐛 [Report an issue](https://github.com/objectstack-ai/objectui/issues)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Drop the outgoing user's client state wholesale — part 3 of the fix.
|
|
10
|
+
*
|
|
11
|
+
* Order is load-bearing and mirrors `clear()`'s: the in-memory active org goes
|
|
12
|
+
* FIRST, because `ActiveOrganizationStorage.get()` falls through to
|
|
13
|
+
* `_memoryValue` whenever the persisted read is null, which is exactly the
|
|
14
|
+
* state this function leaves behind (objectui#5703). Sweeping storage first
|
|
15
|
+
* would leave the outgoing user's org id live in memory for the rest of the
|
|
16
|
+
* page-load — the SPA sign-out-then-sign-in path never reloads.
|
|
17
|
+
*
|
|
18
|
+
* Both stores are swept. `sessionStorage` is per-TAB, not per-session, so the
|
|
19
|
+
* previous user's `objectui:metadata:*` seed (their PERMISSION-FILTERED app
|
|
20
|
+
* list) and their `objectui-ctx-*` scope picks survive into whoever signs in
|
|
21
|
+
* next in that tab unless something removes them.
|
|
22
|
+
*/
|
|
23
|
+
export declare function purgePreviousUserClientState(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Which user's client state this browser holds, and the transition that drops
|
|
26
|
+
* the previous user's state.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SessionUserScope: {
|
|
29
|
+
/**
|
|
30
|
+
* Memory copy of the pointer. Read FIRST by {@link current}: once this
|
|
31
|
+
* page-load has adopted a user, that answer is authoritative for this tab
|
|
32
|
+
* even if another tab has since written a different id, and it is the only
|
|
33
|
+
* copy in a browser whose `localStorage` rejects writes.
|
|
34
|
+
*/
|
|
35
|
+
_userId: string | null;
|
|
36
|
+
/** The user whose state this browser holds, or `null` if it has never held one. */
|
|
37
|
+
current(): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Record `userId` as the owner of this browser's client state, dropping the
|
|
40
|
+
* previous owner's state wholesale when it changes.
|
|
41
|
+
*
|
|
42
|
+
* Idempotent: re-adopting the same user purges nothing. Called from
|
|
43
|
+
* `AuthProvider` for real sessions only — the synthetic `preview-user` /
|
|
44
|
+
* `guest` identities never reach it, so a preview mount cannot purge a real
|
|
45
|
+
* user's state.
|
|
46
|
+
*
|
|
47
|
+
* Because the decision reads the PERSISTED pointer, anything that deletes
|
|
48
|
+
* that pointer without deleting the state it describes would silently retire
|
|
49
|
+
* this invariant. `apps/console/src/lib/auth-preflight.ts` is the one place
|
|
50
|
+
* that sweeps auth keys before render, and it says so in its own comment:
|
|
51
|
+
* it removes every active-org spelling and leaves `auth-session-user-id`
|
|
52
|
+
* alone on purpose.
|
|
53
|
+
*/
|
|
54
|
+
adopt(userId: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Forget the in-memory pointer. Test seam only — production has no reason to
|
|
57
|
+
* un-know who is signed in, and sign-out deliberately KEEPS the persisted
|
|
58
|
+
* pointer so that the next sign-in by a DIFFERENT user still sees a previous
|
|
59
|
+
* owner to purge.
|
|
60
|
+
*/
|
|
61
|
+
_resetForTests(): void;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Get/set the active organization id that {@link createAuthenticatedFetch}
|
|
65
|
+
* stamps as `X-Tenant-ID`.
|
|
66
|
+
*
|
|
67
|
+
* `AuthProvider` is the only writer, at four moments: `refreshOrganizations`
|
|
68
|
+
* sets it once the `getSession` -> `listOrganizations` ->
|
|
69
|
+
* `getActiveOrganization` chain resolves (including the ADR-0081
|
|
70
|
+
* single-membership repair), `switchOrganization` sets or clears it,
|
|
71
|
+
* `deleteOrganization` / `leaveOrganization` clear it when the active org is
|
|
72
|
+
* the one going away, and sign-out clears it.
|
|
73
|
+
*
|
|
74
|
+
* Because the first of those is asynchronous, this reads EMPTY for the first
|
|
75
|
+
* stretch of a boot, and every request that leaves in that window carries no
|
|
76
|
+
* tenant header at all. That window is a documented part of the header's
|
|
77
|
+
* contract, not an accident to paper over — see the "unstamped-first-request
|
|
78
|
+
* gap" section of this package's README (objectui#5279).
|
|
79
|
+
*/
|
|
80
|
+
export declare const ActiveOrganizationStorage: {
|
|
81
|
+
_memoryValue: string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Keys whose removal by {@link clear} did not stick — the value was still
|
|
84
|
+
* readable afterwards (objectui#5731).
|
|
85
|
+
*
|
|
86
|
+
* {@link get} refuses to answer from the persisted layer for a key in here,
|
|
87
|
+
* which is the mechanism that keeps a cleared organization cleared. Held in
|
|
88
|
+
* memory, so it lasts exactly one page-load: a browser that starts fresh
|
|
89
|
+
* re-measures rather than inheriting a verdict.
|
|
90
|
+
*/
|
|
91
|
+
_unremovedKeys: Set<string>;
|
|
92
|
+
/**
|
|
93
|
+
* The active org id for the CURRENT user, or `null`.
|
|
94
|
+
*
|
|
95
|
+
* READ ORDER — a non-null `localStorage` read wins; anything else falls
|
|
96
|
+
* through to `_memoryValue`. Returning the `localStorage` read
|
|
97
|
+
* UNCONDITIONALLY (what this did before objectui#5703) left the fallback
|
|
98
|
+
* reachable only when the read THREW, and there is a real browser state
|
|
99
|
+
* where the read does not throw and the fallback is still the only copy:
|
|
100
|
+
* `localStorage` present and readable but REJECTING WRITES — Safari private
|
|
101
|
+
* browsing, and any quota-exhausted origin, where `setItem` throws
|
|
102
|
+
* `QuotaExceededError`. `set()` swallows that failure into `_memoryValue`
|
|
103
|
+
* (correctly — the fallback is right there), and `get()` then never
|
|
104
|
+
* consulted it: the value was stored and could not be read back, so
|
|
105
|
+
* `X-Tenant-ID` went unstamped for the whole session, not just the early
|
|
106
|
+
* requests.
|
|
107
|
+
*
|
|
108
|
+
* WHY FALLING BACK ON `null` DOES NOT RESURRECT A CLEARED ORG. After
|
|
109
|
+
* `clear()` the `localStorage` read is null by construction, so this
|
|
110
|
+
* fallback fires — and it must answer `null`, because sign-out is one of
|
|
111
|
+
* `clear()`'s callers. It does, because `clear()` nulls `_memoryValue` too.
|
|
112
|
+
* That property is what makes this read order safe rather than an
|
|
113
|
+
* incidental detail of `clear()`'s body, so it is pinned by test
|
|
114
|
+
* (`__tests__/activeOrgStorageFallback-5703.test.tsx`) instead of being
|
|
115
|
+
* re-derived by whoever edits `clear()` next.
|
|
116
|
+
*
|
|
117
|
+
* The same reasoning is why a change of session user purges through
|
|
118
|
+
* {@link purgePreviousUserClientState} rather than by removing keys: the
|
|
119
|
+
* outgoing user's id in `_memoryValue` would otherwise outlive their
|
|
120
|
+
* persisted key on the SPA sign-out-then-sign-in path (objectui#5664).
|
|
121
|
+
*/
|
|
122
|
+
get(): string | null;
|
|
123
|
+
/**
|
|
124
|
+
* With no session user known this writes to memory ONLY. That is not a
|
|
125
|
+
* degraded path to apologise for — it is the fix: a persisted write with no
|
|
126
|
+
* owner is the un-namespaced key this card retired.
|
|
127
|
+
*/
|
|
128
|
+
set(orgId: string): void;
|
|
129
|
+
/**
|
|
130
|
+
* Drop the active organization, from memory and from the persisted key.
|
|
131
|
+
*
|
|
132
|
+
* POSTCONDITION: {@link get} answers `null` afterwards. Sign-out is one of
|
|
133
|
+
* the callers, so that is a security-relevant guarantee rather than a
|
|
134
|
+
* convenience — and before objectui#5731 it was not one, because a
|
|
135
|
+
* `removeItem` that failed was swallowed and the surviving key won `get()`'s
|
|
136
|
+
* read order.
|
|
137
|
+
*
|
|
138
|
+
* ## Why a failed removal is neither thrown nor returned (objectui#5731)
|
|
139
|
+
*
|
|
140
|
+
* Every caller arrives here AFTER the transition it is following up on has
|
|
141
|
+
* already happened, and not one of them can act on a storage failure:
|
|
142
|
+
*
|
|
143
|
+
* - `AuthProvider`'s `purgeSignedOutClientCaches` — the session is already
|
|
144
|
+
* ended. Sign-out cannot be refused because a key would not delete.
|
|
145
|
+
* - `switchOrganization` — the server already switched or cleared the
|
|
146
|
+
* active org; a throw would report a successful switch as a failure and
|
|
147
|
+
* route the caller into its error branch.
|
|
148
|
+
* - `deleteOrganization` / `leaveOrganization` — the org is already deleted
|
|
149
|
+
* or already left.
|
|
150
|
+
* - {@link purgePreviousUserClientState}, via {@link SessionUserScope.adopt}
|
|
151
|
+
* — runs on the SIGN-IN path inside an `AuthProvider` effect, where a
|
|
152
|
+
* throw breaks the boot of the ARRIVING user.
|
|
153
|
+
*
|
|
154
|
+
* A `boolean` return only moves the problem one step: all five call sites
|
|
155
|
+
* would have nothing to write in the failure branch, and a return value that
|
|
156
|
+
* every caller ignores reads as handled when it is not. So the invariant is
|
|
157
|
+
* restored HERE, where it can be, and the failure is reported to the console,
|
|
158
|
+
* where a human can find it.
|
|
159
|
+
*
|
|
160
|
+
* The third shape considered and rejected was re-writing the key with an
|
|
161
|
+
* empty value. That relocates the fix into every consumer's truthiness test
|
|
162
|
+
* (`createAuthenticatedFetch` does `if (activeOrgId)`), which is the lenient
|
|
163
|
+
* consumer AGENTS.md #0.1 forbids, and it leaves a signed-out browser holding
|
|
164
|
+
* a live key.
|
|
165
|
+
*/
|
|
166
|
+
clear(): void;
|
|
167
|
+
};
|
|
168
|
+
//# sourceMappingURL=ActiveOrganizationStorage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ActiveOrganizationStorage.d.ts","sourceRoot":"","sources":["../src/ActiveOrganizationStorage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAyOH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,4BAA4B,IAAI,IAAI,CAInD;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB;IAC3B;;;;;OAKG;aACc,MAAM,GAAG,IAAI;IAE9B,mFAAmF;eACxE,MAAM,GAAG,IAAI;IAKxB;;;;;;;;;;;;;;;OAeG;kBACW,MAAM,GAAG,IAAI;IA0B3B;;;;;OAKG;sBACe,IAAI;CAGvB,CAAC;AAqBF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,yBAAyB;kBACd,MAAM,GAAG,IAAI;IAEnC;;;;;;;;OAQG;;IAGH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;WACI,MAAM,GAAG,IAAI;IAsBpB;;;;OAIG;eACQ,MAAM,GAAG,IAAI;IAOxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;aACM,IAAI;CAyCd,CAAC"}
|