@mosano-product-framework/sdk 0.2.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/README.md +827 -0
- package/README.react.md +348 -0
- package/dist/auth/claims-types.d.ts +89 -0
- package/dist/auth/claims.d.ts +125 -0
- package/dist/auth/cross-tab.d.ts +114 -0
- package/dist/auth/errors.d.ts +40 -0
- package/dist/auth/index.d.ts +18 -0
- package/dist/auth/index.js +5 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oauth-state.d.ts +93 -0
- package/dist/auth/session-manager.d.ts +253 -0
- package/dist/auth/storage.d.ts +36 -0
- package/dist/auth/tenant-directory.d.ts +59 -0
- package/dist/auth/tenant-selection.d.ts +92 -0
- package/dist/chunk-7WAV52EO.js +621 -0
- package/dist/chunk-7WAV52EO.js.map +1 -0
- package/dist/chunk-AJWM5MDZ.js +410 -0
- package/dist/chunk-AJWM5MDZ.js.map +1 -0
- package/dist/chunk-EXPYHNPV.js +212 -0
- package/dist/chunk-EXPYHNPV.js.map +1 -0
- package/dist/chunk-GPWGOYCA.js +85 -0
- package/dist/chunk-GPWGOYCA.js.map +1 -0
- package/dist/chunk-GQJ3QQPH.js +339 -0
- package/dist/chunk-GQJ3QQPH.js.map +1 -0
- package/dist/chunk-K2ELAI2X.js +64 -0
- package/dist/chunk-K2ELAI2X.js.map +1 -0
- package/dist/chunk-LRM6JJ63.js +616 -0
- package/dist/chunk-LRM6JJ63.js.map +1 -0
- package/dist/chunk-XAXFIIRT.js +959 -0
- package/dist/chunk-XAXFIIRT.js.map +1 -0
- package/dist/client/core/client-factory.d.ts +61 -0
- package/dist/client/core/client.d.ts +144 -0
- package/dist/client/core/errors.d.ts +105 -0
- package/dist/client/core/index.d.ts +9 -0
- package/dist/client/core/middleware.d.ts +67 -0
- package/dist/client/core/types.d.ts +99 -0
- package/dist/client/graphql/client.d.ts +66 -0
- package/dist/client/graphql/factory.d.ts +84 -0
- package/dist/client/graphql/operation.d.ts +24 -0
- package/dist/client/graphql/types.d.ts +60 -0
- package/dist/client/graphql/ws-client.d.ts +116 -0
- package/dist/client/index.d.ts +17 -0
- package/dist/client/index.js +227 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/middlewares/admin-auth.d.ts +90 -0
- package/dist/client/middlewares/auth.d.ts +81 -0
- package/dist/client/middlewares/index.d.ts +12 -0
- package/dist/client/middlewares/logging.d.ts +102 -0
- package/dist/client/middlewares/retry.d.ts +138 -0
- package/dist/client/middlewares/tenant.d.ts +60 -0
- package/dist/client/middlewares/turnstile.d.ts +41 -0
- package/dist/client/peer-free.d.ts +25 -0
- package/dist/client/utils/url.d.ts +19 -0
- package/dist/identity/index.d.ts +85 -0
- package/dist/identity/index.js +6 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/identity/types.d.ts +690 -0
- package/dist/identity/v0.d.ts +594 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/react/context.d.ts +47 -0
- package/dist/react/hooks.d.ts +120 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/index.js +308 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/provider.d.ts +68 -0
- package/dist/react/store.d.ts +85 -0
- package/dist/storage/index.d.ts +31 -0
- package/dist/storage/index.js +5 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/types.d.ts +107 -0
- package/dist/storage/v0.d.ts +120 -0
- package/package.json +99 -0
package/README.md
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
# @mosano-product-framework/sdk
|
|
2
|
+
|
|
3
|
+
TypeScript SDK for MPF services. Typed clients for **identity** (authentication,
|
|
4
|
+
tenants) and **storage** (files), plus a GraphQL client, a middleware pipeline,
|
|
5
|
+
and an auth layer covering token claims, tenant selection and session renewal.
|
|
6
|
+
|
|
7
|
+
React bindings live in a separate subpath — see [README.react.md](./README.react.md).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @mosano-product-framework/sdk
|
|
13
|
+
# or
|
|
14
|
+
pnpm add @mosano-product-framework/sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { createAuthClient } from '@mosano-product-framework/sdk/identity';
|
|
21
|
+
import { createTenantSelection } from '@mosano-product-framework/sdk/auth';
|
|
22
|
+
|
|
23
|
+
// A tenant selection is a mutable leaf: build it first, share it across clients.
|
|
24
|
+
const tenants = createTenantSelection();
|
|
25
|
+
|
|
26
|
+
const auth = createAuthClient({
|
|
27
|
+
baseUrl: 'https://api.example.com/auth',
|
|
28
|
+
tokenProvider: { getAccessToken: () => localStorage.getItem('access_token') },
|
|
29
|
+
tenantProvider: tenants,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const { access_token, refresh_token } = await auth.signInEmailPassword({
|
|
33
|
+
email: 'user@example.com',
|
|
34
|
+
password: 'secret123',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
localStorage.setItem('access_token', access_token);
|
|
38
|
+
|
|
39
|
+
// Every subsequent request carries the selected tenant. No re-login, no renewal.
|
|
40
|
+
tenants.set('tenant-uuid');
|
|
41
|
+
const profile = await auth.getMe();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Multi-tenancy
|
|
45
|
+
|
|
46
|
+
An access token carries **every** tenant the user belongs to, with their roles in
|
|
47
|
+
each. Which tenant a given request acts on is chosen **per request, by header**:
|
|
48
|
+
|
|
49
|
+
| Header | Meaning | When absent |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `X-MPF-Tenant` | selected tenant uuid | no tenant: the request runs under the token's default role (`drl`) |
|
|
52
|
+
| `X-MPF-Tenant-Role` | selected role within that tenant | the server applies that tenant's default role: its `dfr`, else `rls[0]` |
|
|
53
|
+
|
|
54
|
+
Consequences worth internalising:
|
|
55
|
+
|
|
56
|
+
- **Switching tenants requires no new token.** It is a header change. No renewal,
|
|
57
|
+
no network round trip, no re-login. The access token is byte-identical before
|
|
58
|
+
and after.
|
|
59
|
+
- **The header is a *selection*, not a grant.** It says "act on this tenant",
|
|
60
|
+
never "give me access to this tenant". The server verifies the token's
|
|
61
|
+
signature and checks the requested tenant and role against the token's claims
|
|
62
|
+
on every single request. Asking for a tenant the token does not hold is a 401,
|
|
63
|
+
not a privilege escalation.
|
|
64
|
+
- **All validation is server-side.** Nothing the client sends, and nothing this
|
|
65
|
+
SDK decodes, is trusted.
|
|
66
|
+
|
|
67
|
+
### Selecting a tenant
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { createTenantSelection, createDefaultStorage } from '@mosano-product-framework/sdk/auth';
|
|
71
|
+
|
|
72
|
+
// Persisted, so a page reload keeps the user in the tenant they chose.
|
|
73
|
+
const tenants = createTenantSelection({ storage: createDefaultStorage() });
|
|
74
|
+
|
|
75
|
+
tenants.set('tenant-uuid'); // select a tenant (default role)
|
|
76
|
+
tenants.set('tenant-uuid', 'admin'); // select a specific role
|
|
77
|
+
tenants.clear(); // back to the no-tenant path
|
|
78
|
+
|
|
79
|
+
tenants.get(); // { tenant: 'tenant-uuid', role: 'admin' }
|
|
80
|
+
|
|
81
|
+
const unsubscribe = tenants.subscribe((selection) => {
|
|
82
|
+
console.log('now acting on', selection.tenant);
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Pass the **same instance** to every client so identity, storage and GraphQL stay
|
|
87
|
+
in step:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
const auth = createAuthClient({ baseUrl, tokenProvider, tenantProvider: tenants });
|
|
91
|
+
const storage = createStorageClient({ baseUrl, tokenProvider, tenantProvider: tenants });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`createTenantSelection` deliberately imports nothing, so it can be constructed
|
|
95
|
+
before any client without an import cycle.
|
|
96
|
+
|
|
97
|
+
### Per-request override
|
|
98
|
+
|
|
99
|
+
`RequestOptions` gains `tenant`, `tenantRole` and `skipTenant`. `tenant` has
|
|
100
|
+
**three** meaningful states and the distinction matters:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
await auth.getMe(); // inherit the client-wide selection
|
|
104
|
+
await auth.getMe({ tenant: 'other-tenant' }); // this request only
|
|
105
|
+
await auth.getMe({ tenant: null }); // deliberately unscoped (no tenant header)
|
|
106
|
+
await auth.getMe({ skipTenant: true }); // send no tenant headers at all
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- `undefined` — "I did not say", so inherit the ambient selection.
|
|
110
|
+
- `null` — "I mean no tenant", so ignore the ambient selection for this call.
|
|
111
|
+
|
|
112
|
+
Without both states, a deliberately unscoped call would silently pick up whatever
|
|
113
|
+
tenant happened to be selected.
|
|
114
|
+
|
|
115
|
+
A per-request tenant does **not** inherit the client-wide role. Selecting tenant
|
|
116
|
+
B while tenant A's role is selected sends B with no role header, letting the
|
|
117
|
+
server apply B's default — sending A's role to B would be wrong.
|
|
118
|
+
|
|
119
|
+
The tenant header is **omitted entirely** when there is no tenant. An
|
|
120
|
+
empty-string header is rejected server-side by design, so a bug cannot
|
|
121
|
+
masquerade as the no-tenant path.
|
|
122
|
+
|
|
123
|
+
### The no-tenant (`drl`) path
|
|
124
|
+
|
|
125
|
+
With no tenant selected, a request runs under the token's default role. That is
|
|
126
|
+
the correct state for tenant-agnostic calls — `getMe()`, `listMyTenants()`, a
|
|
127
|
+
tenant switcher — and the wrong state for anything tenant-scoped, which will
|
|
128
|
+
return empty results or a 403 rather than another tenant's data.
|
|
129
|
+
|
|
130
|
+
Because that failure is quiet, the tenant middleware emits a **one-time
|
|
131
|
+
`console.warn`** the first time a request goes out with no tenant selected while
|
|
132
|
+
the token *does* hold tenants. It fires only for the unscoped-by-omission case
|
|
133
|
+
(`undefined`), never for an explicit `{ tenant: null }` — stating intent is not a
|
|
134
|
+
mistake, and warning on it would train you to ignore the warning.
|
|
135
|
+
|
|
136
|
+
### GraphQL
|
|
137
|
+
|
|
138
|
+
`GraphQLRequestOptions` carries the same fields, so tenant selection works
|
|
139
|
+
identically:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
await gql.query(MyDocument, variables, { tenant: 'tenant-uuid' });
|
|
143
|
+
await gql.query(TenantListDocument, undefined, { tenant: null });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Access token claims
|
|
147
|
+
|
|
148
|
+
The SDK can decode an access token to drive UI — render a tenant switcher, grey
|
|
149
|
+
out an unavailable option, decide when to renew.
|
|
150
|
+
|
|
151
|
+
> ### ⚠️ Decode, not verify
|
|
152
|
+
>
|
|
153
|
+
> This SDK **decodes** access tokens. It does **not verify** them.
|
|
154
|
+
>
|
|
155
|
+
> There is no signature check and no key material in this package. An unsigned
|
|
156
|
+
> token, a token signed with the wrong key, and a token whose payload was
|
|
157
|
+
> hand-edited in devtools all decode identically and successfully.
|
|
158
|
+
>
|
|
159
|
+
> A hostile client can therefore forge **any** value these functions return: any
|
|
160
|
+
> user id, any session id, any tenant, any role, any expiry.
|
|
161
|
+
>
|
|
162
|
+
> Every use must be UX-only. **Never gate a security-relevant branch on these
|
|
163
|
+
> values.** The server is the only authority: it verifies the signature and
|
|
164
|
+
> re-derives tenant and role from the token on every request, and a forged claim
|
|
165
|
+
> buys an attacker exactly nothing there.
|
|
166
|
+
>
|
|
167
|
+
> Verification would require the signing secret, and the signing secret must
|
|
168
|
+
> never reach a browser.
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import {
|
|
172
|
+
decodeAccessToken,
|
|
173
|
+
tryDecodeAccessToken,
|
|
174
|
+
listTenants,
|
|
175
|
+
rolesForTenant,
|
|
176
|
+
defaultRoleForTenant,
|
|
177
|
+
canSelect,
|
|
178
|
+
isExpired,
|
|
179
|
+
expiresAt,
|
|
180
|
+
clearClaimsCache,
|
|
181
|
+
} from '@mosano-product-framework/sdk/auth';
|
|
182
|
+
|
|
183
|
+
const claims = decodeAccessToken(access_token);
|
|
184
|
+
// { v: 1, uid, sid, iat, exp,
|
|
185
|
+
// tnts: [{ id: '<tenant>', rls: ['owner', 'member'], dfr: 'owner' }],
|
|
186
|
+
// drl: 'tenantless' }
|
|
187
|
+
// tnts is an ARRAY, always present ([] for a user with no tenants). `dfr` is
|
|
188
|
+
// OPTIONAL per entry and, when present, is always one of that entry's `rls`.
|
|
189
|
+
|
|
190
|
+
listTenants(claims); // ['tenant-a', 'tenant-b']
|
|
191
|
+
rolesForTenant(claims, 'tenant-a'); // ['owner', 'member']
|
|
192
|
+
defaultRoleForTenant(claims, 'tenant-a'); // that entry's `dfr`, else `rls[0]`
|
|
193
|
+
canSelect(claims, 'tenant-a', 'admin'); // false — UX hint only, NOT a permission check
|
|
194
|
+
isExpired(claims, 120); // will it die within 2 minutes?
|
|
195
|
+
expiresAt(claims); // Date
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Notes:
|
|
199
|
+
|
|
200
|
+
- `decodeAccessToken` **throws**; `tryDecodeAccessToken` returns `null`.
|
|
201
|
+
- Both **succeed on an expired token** by design — the renewal logic needs `exp`,
|
|
202
|
+
so expiry is the caller's question. Ask it with `isExpired`.
|
|
203
|
+
- `v` dispatches through a parser registry, so a future claim shape coexists with
|
|
204
|
+
this one. An unknown `v` throws `MPFUnsupportedClaimsVersionError` carrying the
|
|
205
|
+
version, which distinguishes "this app is older than the server" from "this
|
|
206
|
+
token is garbage". Unknown *keys* are tolerated so a server-side field addition
|
|
207
|
+
cannot brick older clients.
|
|
208
|
+
- Successful decodes are cached in a 5-entry LRU keyed on the token string.
|
|
209
|
+
`clearClaimsCache()` on logout.
|
|
210
|
+
- Roles are typed `MPFRole = string`, not a closed union: they are database rows,
|
|
211
|
+
so a newly seeded role must not require an SDK release.
|
|
212
|
+
- **Absent claims degrade where the empty state is meaningful.** `tnts` missing
|
|
213
|
+
or `null` decodes as `[]` (a brand-new user belongs to no tenants), and an
|
|
214
|
+
empty or absent `drl` becomes
|
|
215
|
+
`undefined` (a deployment may have no tenantless role). `v`, `exp`, `uid` and
|
|
216
|
+
`sid` stay hard-required — without those the token cannot be interpreted at
|
|
217
|
+
all. A claim of the wrong *type* always throws: absence is a legitimate state,
|
|
218
|
+
corruption is not.
|
|
219
|
+
|
|
220
|
+
## Sessions and renewal
|
|
221
|
+
|
|
222
|
+
Access tokens live **15 minutes**. The `SessionManager` keeps one alive across a
|
|
223
|
+
tab's lifetime.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { createSessionManager, createDefaultStorage } from '@mosano-product-framework/sdk/auth';
|
|
227
|
+
|
|
228
|
+
const session = createSessionManager({
|
|
229
|
+
renew: (refreshToken) => auth.renewSession({ refresh_token: refreshToken }),
|
|
230
|
+
storage: createDefaultStorage(),
|
|
231
|
+
onDeadSession: (error) => {
|
|
232
|
+
// A callback, never a navigation. Route however your app routes.
|
|
233
|
+
console.warn('session over:', error.message);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
session.setTokens({ access_token, refresh_token });
|
|
238
|
+
|
|
239
|
+
// Wire the client to the manager. getAccessToken renews first if needed.
|
|
240
|
+
const auth = createAuthClient({
|
|
241
|
+
baseUrl,
|
|
242
|
+
tokenProvider: {
|
|
243
|
+
getAccessToken: () => session.getAccessToken(),
|
|
244
|
+
onUnauthorized: async () => {
|
|
245
|
+
await session.renew();
|
|
246
|
+
return true; // retry the request once with the fresh token
|
|
247
|
+
},
|
|
248
|
+
onTokenExpired: () => redirectToLogin(),
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Only the **refresh token** is persisted. The access token stays in memory: at a
|
|
254
|
+
15-minute TTL, persisting it buys almost nothing and it is the more dangerous of
|
|
255
|
+
the two to leave in `localStorage`.
|
|
256
|
+
|
|
257
|
+
### The 120-second buffer
|
|
258
|
+
|
|
259
|
+
A token counts as stale 120 seconds before it expires, not at expiry.
|
|
260
|
+
|
|
261
|
+
60 seconds was tuned against a 6-hour token, where it was 0.3% of the lifetime.
|
|
262
|
+
At 900 seconds it is 6.7%, and the failure mode changes: a request *starting* at
|
|
263
|
+
`exp − 61s` passes a 60-second freshness check and then carries a token that dies
|
|
264
|
+
mid-flight — which large storage uploads routinely will. The buffer has to cover
|
|
265
|
+
renewal latency plus the longest in-flight request, and browser clock skew is
|
|
266
|
+
unbounded. 120s means renewing roughly every 13 minutes.
|
|
267
|
+
|
|
268
|
+
The buffer is **clamped per token** to just under half the token's lifetime. If
|
|
269
|
+
the buffer were ≥ half the lifetime, a freshly minted token would be "stale" the
|
|
270
|
+
instant it arrived and the manager would renew-loop. A 60-second token therefore
|
|
271
|
+
gets a 29-second buffer, not 120. A `minRenewIntervalMs` floor (default 5s) is a
|
|
272
|
+
second guard against a misconfigured server TTL turning this into a DoS on
|
|
273
|
+
`/sessions/renew`.
|
|
274
|
+
|
|
275
|
+
### Three layers of renewal
|
|
276
|
+
|
|
277
|
+
1. **Proactive** — a `setTimeout` fires shortly before the token goes stale, so
|
|
278
|
+
requests never pay for a renewal. Jittered ±30s so multiple tabs do not all
|
|
279
|
+
wake on the same millisecond.
|
|
280
|
+
2. **Wake** — `setTimeout` does **not** fire in frozen or discarded tabs (Chrome
|
|
281
|
+
tab freezing, iOS Safari), so a tab backgrounded for 20 minutes wakes holding
|
|
282
|
+
a guaranteed-dead token with its timer never having run. On
|
|
283
|
+
`visibilitychange` → visible and on `online`, the manager renews immediately
|
|
284
|
+
and publishes a gate that `getAccessToken()` awaits, so requests fired by the
|
|
285
|
+
same focus event queue behind the renewal instead of each eating a 401. **This
|
|
286
|
+
is the real safety net, not the timer.**
|
|
287
|
+
3. **Reactive** — a 401 on a request that carried a token triggers one renewal
|
|
288
|
+
and one retry. See [Auth middleware](#auth-middleware).
|
|
289
|
+
|
|
290
|
+
Renewals are **coalesced**: concurrent callers share one network call. The
|
|
291
|
+
refresh token is single-use, so two parallel renewals would kill each other.
|
|
292
|
+
|
|
293
|
+
### Multiple tabs
|
|
294
|
+
|
|
295
|
+
Every tab of one browser shares a single refresh token via origin-scoped
|
|
296
|
+
`localStorage`, and with proactive renewal all of them wake at roughly the same
|
|
297
|
+
moment. Coordination therefore has to be real:
|
|
298
|
+
|
|
299
|
+
- **`navigator.locks`** (Web Locks) provides a cross-tab mutex, with no
|
|
300
|
+
dependency on the primary path. Locks auto-release when a tab is destroyed, so
|
|
301
|
+
a closed tab cannot deadlock the others.
|
|
302
|
+
- **Double-checked locking** — a lock alone is insufficient: N tabs would queue
|
|
303
|
+
and then each renew in turn, which is the problem the lock was meant to solve.
|
|
304
|
+
Each tab re-reads the persisted refresh token *inside* the lock and adopts
|
|
305
|
+
instead of renewing when another tab got there first.
|
|
306
|
+
- **`BroadcastChannel`** fans the winner's fresh access token out to the other
|
|
307
|
+
tabs, in memory, so losers adopt with **no network call** while the access
|
|
308
|
+
token still never touches `localStorage`.
|
|
309
|
+
- **`AbortSignal` timeout** (5s) on the lock request, so a waiter cannot hang
|
|
310
|
+
behind a frozen tab holding the lock mid-renewal.
|
|
311
|
+
|
|
312
|
+
Everything no-ops when `window` is absent, so Node and server-side rendering are
|
|
313
|
+
unaffected.
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
import { createCrossTabCoordinator } from '@mosano-product-framework/sdk/auth';
|
|
317
|
+
|
|
318
|
+
const session = createSessionManager({
|
|
319
|
+
renew,
|
|
320
|
+
crossTab: createCrossTabCoordinator({ lockTimeoutMs: 5000 }),
|
|
321
|
+
});
|
|
322
|
+
// or `crossTab: null` to disable coordination entirely
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**Residual limitations, stated honestly:**
|
|
326
|
+
|
|
327
|
+
- `navigator.locks` and `BroadcastChannel` both need Safari 15.4+ and a secure
|
|
328
|
+
context. Where locks are missing you may supply a Web Locks polyfill via
|
|
329
|
+
`loadPolyfill`; without one, tabs run **uncoordinated** and may each renew.
|
|
330
|
+
That is survivable rather than correct — see the next point.
|
|
331
|
+
- If the lock times out, the manager **proceeds without it**. A duplicate
|
|
332
|
+
renewal is recoverable (the server returns `REFRESH_TOKEN_SUPERSEDED`, and the
|
|
333
|
+
losing tab adopts the winner's token instead of logging out), whereas hanging
|
|
334
|
+
would leave the app with no token at all.
|
|
335
|
+
- Where `BroadcastChannel` is unavailable, losing tabs fall back to renewing with
|
|
336
|
+
the rotated refresh token — correct, just an extra round trip.
|
|
337
|
+
- Separate **browsers, machines or devices** get separate sessions and separate
|
|
338
|
+
refresh tokens by design; none of this applies across them.
|
|
339
|
+
|
|
340
|
+
## Middlewares
|
|
341
|
+
|
|
342
|
+
### Auth middleware
|
|
343
|
+
|
|
344
|
+
Injects the `Authorization` header, and handles 401s with two independent guards.
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import { createAuthMiddleware } from '@mosano-product-framework/sdk/client';
|
|
348
|
+
|
|
349
|
+
const authMiddleware = createAuthMiddleware({
|
|
350
|
+
getAccessToken: () => session.getAccessToken(),
|
|
351
|
+
onUnauthorized: async () => {
|
|
352
|
+
await session.renew();
|
|
353
|
+
return true; // renewed -> retry once
|
|
354
|
+
},
|
|
355
|
+
onTokenExpired: () => redirectToLogin(), // the 401 was final
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Behaviour: **401 → renew → retry once → success.** Only a *second* 401 reaches
|
|
360
|
+
`onTokenExpired`.
|
|
361
|
+
|
|
362
|
+
Two guards make that safe:
|
|
363
|
+
|
|
364
|
+
- A 401 on a request that carried **no** token does nothing at all — no renewal,
|
|
365
|
+
no logout. This is the wrong-password guard: a failed sign-in must never log
|
|
366
|
+
you out of a session you do have. It belongs in the SDK rather than in every
|
|
367
|
+
consumer's app code.
|
|
368
|
+
- Renewal is capped at **one attempt per logical request**, set before awaiting,
|
|
369
|
+
so a retry loop cannot trigger a second renewal of a single-use refresh token.
|
|
370
|
+
|
|
371
|
+
`createAuthClient`/`createStorageClient` wire this automatically from
|
|
372
|
+
`tokenProvider`.
|
|
373
|
+
|
|
374
|
+
### Tenant middleware
|
|
375
|
+
|
|
376
|
+
Puts the selected tenant on the wire. Auto-wired by `createAuthClient` and
|
|
377
|
+
`createStorageClient` when you pass `tenantProvider`; wire it manually for a bare
|
|
378
|
+
client:
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
import { createTenantMiddleware, TENANT_HEADER, TENANT_ROLE_HEADER } from '@mosano-product-framework/sdk/client';
|
|
382
|
+
|
|
383
|
+
const tenantMiddleware = createTenantMiddleware({
|
|
384
|
+
tenantProvider: tenants,
|
|
385
|
+
// Optional, enables the one-time under-scoping warning described above.
|
|
386
|
+
getAccessToken: () => session.peekAccessToken(),
|
|
387
|
+
warnOnUnscoped: true,
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Retry middleware
|
|
392
|
+
|
|
393
|
+
Retries failed requests with exponential backoff and jitter.
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
import { createRetryMiddleware } from '@mosano-product-framework/sdk/client';
|
|
397
|
+
|
|
398
|
+
const retryMiddleware = createRetryMiddleware({
|
|
399
|
+
maxRetries: 3,
|
|
400
|
+
retryDelay: 1000,
|
|
401
|
+
retryOn: [408, 429, 500, 502, 503, 504],
|
|
402
|
+
backoffFactor: 2,
|
|
403
|
+
jitter: true,
|
|
404
|
+
onRetry: (attempt, error, delay) => console.warn(`retry ${attempt} in ${delay}ms`),
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Notes:
|
|
409
|
+
|
|
410
|
+
- `MPFClient.request()` caps every request at **4 attempts** total
|
|
411
|
+
(`MAX_REQUEST_ATTEMPTS`), independent of any middleware's `maxRetries`.
|
|
412
|
+
- A `ReadableStream` body cannot be re-sent and disables retrying. `FormData`
|
|
413
|
+
(storage uploads) is re-readable and does retry.
|
|
414
|
+
- Retry is **opt-in**: neither client factory registers it for you.
|
|
415
|
+
|
|
416
|
+
### Logging middleware
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
import { createLoggingMiddleware } from '@mosano-product-framework/sdk/client';
|
|
420
|
+
|
|
421
|
+
const loggingMiddleware = createLoggingMiddleware({
|
|
422
|
+
logBody: false, // default false: bodies may contain secrets
|
|
423
|
+
logHeaders: false, // Authorization/Cookie are always redacted when enabled
|
|
424
|
+
minDuration: 100, // only log requests slower than this
|
|
425
|
+
});
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Combining middlewares
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
const auth = createAuthClient({
|
|
432
|
+
baseUrl: 'https://api.example.com/auth',
|
|
433
|
+
middlewares: [
|
|
434
|
+
createLoggingMiddleware(),
|
|
435
|
+
createRetryMiddleware({ maxRetries: 3 }),
|
|
436
|
+
],
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Request middlewares run in registration order; response middlewares in reverse.
|
|
441
|
+
**Every** `onError` handler runs, in registration order, even though each signals
|
|
442
|
+
by throwing — so a handler registered after another cannot be silently skipped.
|
|
443
|
+
|
|
444
|
+
## Service clients
|
|
445
|
+
|
|
446
|
+
### Identity client
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
import { createAuthClient } from '@mosano-product-framework/sdk/identity';
|
|
450
|
+
|
|
451
|
+
const auth = createAuthClient({ baseUrl: 'https://api.example.com/auth' });
|
|
452
|
+
|
|
453
|
+
// Sign up / sign in
|
|
454
|
+
const tokens = await auth.signUpEmailPassword({
|
|
455
|
+
display_name: 'John Doe',
|
|
456
|
+
email: 'john@example.com',
|
|
457
|
+
password: 'securePassword123',
|
|
458
|
+
});
|
|
459
|
+
const session = await auth.signInEmailPassword({
|
|
460
|
+
email: 'john@example.com',
|
|
461
|
+
password: 'securePassword123',
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Profile
|
|
465
|
+
const me = await auth.getMe(); // UserProfileResponse
|
|
466
|
+
await auth.updateMe({ display_name: 'John Updated' });
|
|
467
|
+
|
|
468
|
+
// Sessions
|
|
469
|
+
const sessions = await auth.listSessions();
|
|
470
|
+
await auth.deleteSession('session-id');
|
|
471
|
+
const renewed = await auth.renewSession({ refresh_token });
|
|
472
|
+
await auth.revokeSession({ refresh_token }); // sign out
|
|
473
|
+
|
|
474
|
+
// Tenants
|
|
475
|
+
const tenant = await auth.createTenant({ name: 'My Organization', slug: 'my-org' });
|
|
476
|
+
const myTenants = await auth.listMyTenants(); // id, name, slug
|
|
477
|
+
const members = await auth.listTenantMembers(tenant.id);
|
|
478
|
+
await auth.inviteTenantMember(tenant.id, { email: 'member@example.com', role: 'member' });
|
|
479
|
+
await auth.acceptInvitation('invitation-token');
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
`signInEmailPassword` may return an MFA challenge rather than tokens — narrow it:
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
// These narrowing helpers live on the /identity subpath (the root barrel
|
|
486
|
+
// namespaces them as `AuthTypes.isMFARequired`).
|
|
487
|
+
import { isMFARequired } from '@mosano-product-framework/sdk/identity';
|
|
488
|
+
|
|
489
|
+
const result = await auth.signInEmailPassword({ email, password });
|
|
490
|
+
if (isMFARequired(result)) {
|
|
491
|
+
const tokens = await auth.verifyMFA({ mfa_ticket: result.mfa_ticket, totp_code: '123456' });
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
### Google OAuth — and why the `state` must round-trip
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
import { createAuthClient } from '@mosano-product-framework/sdk/identity';
|
|
499
|
+
import { createOAuthStateStore } from '@mosano-product-framework/sdk/auth';
|
|
500
|
+
|
|
501
|
+
const oauthState = createOAuthStateStore(); // sessionStorage by default
|
|
502
|
+
const auth = createAuthClient({ baseUrl, oauthStateStore: oauthState });
|
|
503
|
+
|
|
504
|
+
// 1. Starting the flow. The client persists `state` for you when a store is wired.
|
|
505
|
+
const { url } = await auth.getGoogleOAuthURL({ redirect_uri: 'https://app.test/oauth/callback' });
|
|
506
|
+
window.location.href = url;
|
|
507
|
+
|
|
508
|
+
// 2. On your callback route, after the redirect back:
|
|
509
|
+
const params = new URLSearchParams(window.location.search);
|
|
510
|
+
const state = oauthState.take(); // single-use: read and clear
|
|
511
|
+
|
|
512
|
+
if (!state) {
|
|
513
|
+
// No pending flow in this tab. Do NOT proceed — send the user back to sign-in.
|
|
514
|
+
throw new Error('No pending OAuth flow');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const tokens = await auth.handleGoogleOAuthCallback({
|
|
518
|
+
code: params.get('code')!,
|
|
519
|
+
redirect_uri: 'https://app.test/oauth/callback',
|
|
520
|
+
state,
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### Why this matters
|
|
525
|
+
|
|
526
|
+
**`state` is the CSRF defence for this flow, and dropping it is exploitable —
|
|
527
|
+
not theoretically, but confirmed end to end.**
|
|
528
|
+
|
|
529
|
+
Without it, a callback route accepts *any* `code` presented to it. An attacker
|
|
530
|
+
obtains an authorization code for **their own** account, then sends the victim to
|
|
531
|
+
`https://yourapp/oauth/callback?code=<attacker's code>`. The victim's browser
|
|
532
|
+
posts it, the server exchanges it, and the victim is now signed in **as the
|
|
533
|
+
attacker** — into an account the attacker still controls. Everything the victim
|
|
534
|
+
does next (entering card details, uploading documents, accepting an invitation)
|
|
535
|
+
happens inside the attacker's account, visible to them.
|
|
536
|
+
|
|
537
|
+
It is especially easy to get wrong because the exploit needs **no user
|
|
538
|
+
interaction**: a callback route that auto-submits `code` on page load does the
|
|
539
|
+
whole thing on navigation alone.
|
|
540
|
+
|
|
541
|
+
The state closes it. The server mints an unguessable value, records it, and
|
|
542
|
+
accepts the callback only if the same value comes back — which an attacker
|
|
543
|
+
cannot produce, because it lives in the victim's browser storage.
|
|
544
|
+
|
|
545
|
+
So:
|
|
546
|
+
|
|
547
|
+
- `state` is **required** on `GoogleOAuthCallbackRequest`. TypeScript rejects a
|
|
548
|
+
callback without it, and the SDK throws `MPFValidationError` **before** issuing
|
|
549
|
+
the request rather than sending one the server must reject.
|
|
550
|
+
- It must survive a **full-page redirect**, so it cannot live in memory, React
|
|
551
|
+
state, or a closure. `createOAuthStateStore()` puts it in `sessionStorage`.
|
|
552
|
+
- It is **single use**. `take()` reads and clears; the server also rejects reuse.
|
|
553
|
+
- Server-side rejection (mismatch, absent, expired, replayed) surfaces as a
|
|
554
|
+
normal typed SDK error carrying the server's code — see
|
|
555
|
+
[Error handling](#error-handling). Treat it as "start the flow again", never as
|
|
556
|
+
"retry the callback".
|
|
557
|
+
|
|
558
|
+
**Do not "simplify" the state away.** If a callback seems to work without it,
|
|
559
|
+
that is the vulnerability, not a shortcut.
|
|
560
|
+
|
|
561
|
+
Storage choice: `sessionStorage` is per-tab, which is right for a redirect flow —
|
|
562
|
+
a state minted in one tab cannot be consumed by another. The tradeoff is that a
|
|
563
|
+
callback landing in a *different* tab won't find it and will fail closed. If your
|
|
564
|
+
flow can land elsewhere, pass `createDefaultStorage()` (localStorage), accepting
|
|
565
|
+
that the state is then readable by every tab on the origin. A cookie is not
|
|
566
|
+
offered: one set from JavaScript cannot be `httpOnly`, so it is no less readable
|
|
567
|
+
than web storage while adding `SameSite` pitfalls on the cross-site return leg.
|
|
568
|
+
|
|
569
|
+
### Storage client
|
|
570
|
+
|
|
571
|
+
The storage API is signed-URL based, plus direct multipart upload.
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
import { createStorageClient } from '@mosano-product-framework/sdk/storage';
|
|
575
|
+
|
|
576
|
+
const storage = createStorageClient({
|
|
577
|
+
baseUrl: 'https://api.example.com/storage',
|
|
578
|
+
tokenProvider: { getAccessToken: () => session.getAccessToken() },
|
|
579
|
+
tenantProvider: tenants,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Direct multipart upload
|
|
583
|
+
const metadata = await storage.uploadFile(file);
|
|
584
|
+
|
|
585
|
+
// Or the signed-URL flow: get a URL, PUT to it yourself, then confirm
|
|
586
|
+
const { url, file_id } = await storage.getUploadSignedUrl({ /* ... */ });
|
|
587
|
+
await storage.confirmUpload(file_id);
|
|
588
|
+
|
|
589
|
+
// Download
|
|
590
|
+
const download = await storage.getDownloadSignedUrl(metadata.id);
|
|
591
|
+
|
|
592
|
+
// Batch variants exist for each operation
|
|
593
|
+
await storage.getBatchUploadSignedUrls({ /* ... */ });
|
|
594
|
+
await storage.batchUploadFiles(files);
|
|
595
|
+
await storage.batchDeleteFiles({ /* ... */ });
|
|
596
|
+
|
|
597
|
+
// Replace and delete
|
|
598
|
+
await storage.replaceFile(metadata.id, newFile);
|
|
599
|
+
await storage.deleteFile(metadata.id);
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
## Error handling
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
import {
|
|
606
|
+
MPFAPIError,
|
|
607
|
+
MPFNetworkError,
|
|
608
|
+
MPFAuthError,
|
|
609
|
+
MPFValidationError,
|
|
610
|
+
} from '@mosano-product-framework/sdk/client';
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
await auth.signInEmailPassword({ email, password: 'wrong' });
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (error instanceof MPFAuthError) {
|
|
616
|
+
// 401/403
|
|
617
|
+
console.error(error.message, error.authErrorType, error.serverCode);
|
|
618
|
+
} else if (error instanceof MPFValidationError) {
|
|
619
|
+
console.error('Validation failed:', error.fieldErrors);
|
|
620
|
+
} else if (error instanceof MPFNetworkError) {
|
|
621
|
+
console.error('No response:', error.message);
|
|
622
|
+
} else if (error instanceof MPFAPIError) {
|
|
623
|
+
console.error(`API error ${error.status}:`, error.message);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
`MPFAuthError.code` is derived from the error classification and collapses every
|
|
629
|
+
401 to `UNAUTHORIZED`. Use **`serverCode`** for the code the server actually sent
|
|
630
|
+
— `REFRESH_TOKEN_SUPERSEDED` being the one that matters, since it means "another
|
|
631
|
+
tab renewed first" and is recoverable rather than fatal.
|
|
632
|
+
|
|
633
|
+
## Modules
|
|
634
|
+
|
|
635
|
+
The package is cut into five subpaths. Import from the one that owns the name:
|
|
636
|
+
|
|
637
|
+
| Subpath | Contents |
|
|
638
|
+
|---|---|
|
|
639
|
+
| `@mosano-product-framework/sdk/client` | The generic transport layer: `MPFClient`, the middleware pipeline and the built-in middlewares (auth, admin-auth, tenant, retry, logging, turnstile), the error types, shared request/response types, the URL helpers, and the GraphQL clients (HTTP, WebSocket and hybrid). |
|
|
640
|
+
| `@mosano-product-framework/sdk/auth` | The auth layer: JWT claim types and decoding, session manager, cross-tab coordination, storage adapters, tenant selection, OAuth state. Framework-agnostic. |
|
|
641
|
+
| `@mosano-product-framework/sdk/identity` | The identity service client: signup, signin, forgot password, OAuth, token renewal — plus its request/response types. |
|
|
642
|
+
| `@mosano-product-framework/sdk/storage` | The storage service client and its types. |
|
|
643
|
+
| `@mosano-product-framework/sdk/react` | React bindings — provider, hooks, store. See [README.react.md](./README.react.md). |
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
import { createAuthClient } from '@mosano-product-framework/sdk/identity';
|
|
647
|
+
import { createStorageClient } from '@mosano-product-framework/sdk/storage';
|
|
648
|
+
import { decodeAccessToken } from '@mosano-product-framework/sdk/auth';
|
|
649
|
+
import { MPFAPIError } from '@mosano-product-framework/sdk/client';
|
|
650
|
+
import { MPFAuthProvider } from '@mosano-product-framework/sdk/react';
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### The root export is a namespace object
|
|
654
|
+
|
|
655
|
+
The root re-exports the four non-React modules as **namespaces**, not as flat
|
|
656
|
+
names:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
import { client, auth, identity, storage } from '@mosano-product-framework/sdk';
|
|
660
|
+
|
|
661
|
+
const api = identity.createAuthClient({ baseUrl });
|
|
662
|
+
const selection = auth.createTenantSelection();
|
|
663
|
+
if (error instanceof client.MPFAPIError) { /* ... */ }
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
A default export with the same shape is available too:
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
import sdk from '@mosano-product-framework/sdk';
|
|
670
|
+
|
|
671
|
+
const api = sdk.identity.createAuthClient({ baseUrl });
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Two things are deliberately **not** reachable from the root in any form — not
|
|
675
|
+
even as a namespace:
|
|
676
|
+
|
|
677
|
+
- **`react`.** It is an optional peer dependency. A static re-export from the
|
|
678
|
+
root would put `react` in every consumer's module graph, so merely importing
|
|
679
|
+
the package would require React to be installed — breaking plain-Node
|
|
680
|
+
consumers. Use the `/react` subpath.
|
|
681
|
+
- **`createGraphQLClient` / `createGraphQLWsTransport`.** Same reasoning for the
|
|
682
|
+
optional `graphql-ws` peer, which those two reach (through a dynamic
|
|
683
|
+
`import()`). The root's `client` namespace is the peer-free half of `/client`;
|
|
684
|
+
use the `/client` subpath for subscriptions.
|
|
685
|
+
|
|
686
|
+
`tests/module-boundaries.test.ts` and `tests/package-exports.test.ts` enforce
|
|
687
|
+
both rules.
|
|
688
|
+
|
|
689
|
+
## TypeScript
|
|
690
|
+
|
|
691
|
+
Request/response types come from the service subpath that owns them:
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
import type { UserProfileResponse, LoginRequest, TenantResponse } from '@mosano-product-framework/sdk/identity';
|
|
695
|
+
import type { FileMetadataResponse } from '@mosano-product-framework/sdk/storage';
|
|
696
|
+
|
|
697
|
+
const profile: UserProfileResponse = { /* ... */ };
|
|
698
|
+
const login: LoginRequest = { email, password };
|
|
699
|
+
const tenant: TenantResponse = { /* ... */ };
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
From the root they are reached through the namespace — `identity.LoginRequest`,
|
|
703
|
+
`storage.FileMetadataResponse`.
|
|
704
|
+
|
|
705
|
+
## ESM only
|
|
706
|
+
|
|
707
|
+
This package ships **ES modules only**. There is no CommonJS build.
|
|
708
|
+
|
|
709
|
+
`require()` from a CommonJS file fails with a message that reads more alarmingly
|
|
710
|
+
than the situation warrants:
|
|
711
|
+
|
|
712
|
+
```
|
|
713
|
+
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in
|
|
714
|
+
.../node_modules/@mosano-product-framework/sdk/package.json
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
That does **not** mean the package has no entry point — it means there is no
|
|
718
|
+
`require` condition in the exports map, because the package is ESM-only. From
|
|
719
|
+
CommonJS, use a dynamic import:
|
|
720
|
+
|
|
721
|
+
```javascript
|
|
722
|
+
const { identity } = await import('@mosano-product-framework/sdk');
|
|
723
|
+
const auth = identity.createAuthClient({ baseUrl });
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
Any bundler (Vite, webpack, esbuild, Rollup, Next.js) and any `"type": "module"`
|
|
727
|
+
package consume it directly with a static `import`.
|
|
728
|
+
|
|
729
|
+
## GraphQL subscriptions
|
|
730
|
+
|
|
731
|
+
Subscriptions go over `graphql-ws` and live on the `/client` subpath, behind
|
|
732
|
+
`createGraphQLClient({ mode: 'websocket' | 'hybrid' })`. `graphql-ws` is an
|
|
733
|
+
optional peer, loaded through a dynamic `import()` only when one of those modes
|
|
734
|
+
is used.
|
|
735
|
+
|
|
736
|
+
Two constraints come from Hasura and shape the API:
|
|
737
|
+
|
|
738
|
+
- Hasura authenticates a socket **once**, at `connection_init`. Headers come from
|
|
739
|
+
that payload and anything sent later is ignored, so a connection is pinned to
|
|
740
|
+
**one tenant for its entire lifetime**. Switching tenants in a subscribing UI
|
|
741
|
+
means tearing the socket down and reconnecting — hence the explicit
|
|
742
|
+
`reconnectWebSocket()` on the hybrid client.
|
|
743
|
+
- With auth caching disabled (`no-store`, the safe default), the socket's
|
|
744
|
+
authorization is never re-evaluated, so a connection **outlives the access
|
|
745
|
+
token** that opened it. Wire `reconnectWebSocket` to
|
|
746
|
+
`SessionManager.onTokensChanged`.
|
|
747
|
+
|
|
748
|
+
## Migrating to 0.2.0
|
|
749
|
+
|
|
750
|
+
Nothing is released yet, so this is a clean break rather than a deprecation.
|
|
751
|
+
|
|
752
|
+
### Package and module layout
|
|
753
|
+
|
|
754
|
+
1. **The package was renamed** `@mosano-product-framework/public-sdk` →
|
|
755
|
+
`@mosano-product-framework/sdk`. Its directory in the monorepo moved
|
|
756
|
+
`pkg/mpf-public-sdk/` → `pkg/js-sdk/`.
|
|
757
|
+
2. **The root export is a namespace object.** `import { MPFAPIError } from '...'`
|
|
758
|
+
no longer works; it is `import { client } from '...'` +
|
|
759
|
+
`client.MPFAPIError`, or the subpath `import { MPFAPIError } from
|
|
760
|
+
'.../client'`. See [Modules](#modules).
|
|
761
|
+
3. **The `./graphql` subpath is gone.** Everything it exported — the GraphQL
|
|
762
|
+
clients, `createGraphQLClient`, `createGraphQLWsTransport` — is on `./client`,
|
|
763
|
+
which is now the home of the whole generic transport layer (core client,
|
|
764
|
+
middlewares, errors, shared types).
|
|
765
|
+
4. **`AuthTypes` / `StorageTypes` are gone** as root namespaces. The identity and
|
|
766
|
+
storage types are exported directly from `./identity` and `./storage`, and
|
|
767
|
+
reachable from the root as `identity.*` / `storage.*`.
|
|
768
|
+
|
|
769
|
+
### API breaking changes
|
|
770
|
+
|
|
771
|
+
1. **`tenantId` removed** from `LoginRequest`, `ResetPasswordRequest`,
|
|
772
|
+
`RenewSessionRequest`, `FinishPasskeyLoginRequest`,
|
|
773
|
+
`GoogleOAuthCallbackRequest`, `MFAVerifyRequest` and
|
|
774
|
+
`MFASetupVerifyRequest`. Select a tenant with headers instead. This is
|
|
775
|
+
runtime-neutral: the SDK sent camelCase `tenantId` while the API only ever
|
|
776
|
+
read snake_case `tenant_id`, so the field never reached the server.
|
|
777
|
+
2. **`tenant_id` removed** from `getGoogleOAuthURL` params — the one path where
|
|
778
|
+
it did reach the server.
|
|
779
|
+
3. **Response `role` fields widened** to `MPFRole` (`string`) on
|
|
780
|
+
`MembershipResponse`, `PendingInvitationResponse` and
|
|
781
|
+
`IssuedInvitationResponse`. An exhaustive `switch` over the old union now
|
|
782
|
+
needs a `default`.
|
|
783
|
+
4. **Request `role` fields**: `'viewer'` → `'guest'`. `'viewer'` was never a
|
|
784
|
+
seeded database role.
|
|
785
|
+
5. **5xx responses now actually retry** when a retry middleware is registered.
|
|
786
|
+
Previously the retry middleware was dead code — it slept, set a flag, and
|
|
787
|
+
nothing ever read it. Non-idempotent requests may now reach the server more
|
|
788
|
+
than once.
|
|
789
|
+
6. New `/auth` and `/react` subpaths; `sideEffects: false`; React as an optional
|
|
790
|
+
peer dependency.
|
|
791
|
+
|
|
792
|
+
#### 7. Google OAuth `state` — a security fix, not a refactor
|
|
793
|
+
|
|
794
|
+
**This one is different from the rest of this list.** The others are type-level
|
|
795
|
+
tidying with no runtime consequence. This closes a **confirmed exploitable CSRF
|
|
796
|
+
vulnerability** in the OAuth callback flow, and code that does not adopt it stays
|
|
797
|
+
vulnerable. Migrate it deliberately, not mechanically.
|
|
798
|
+
|
|
799
|
+
- `getGoogleOAuthURL()` now resolves to **`{ url, state }`** instead of `{ url }`.
|
|
800
|
+
- `GoogleOAuthCallbackRequest` gains a **required** `state`.
|
|
801
|
+
- The server validates `state` before exchanging the code and rejects a mismatch,
|
|
802
|
+
absence, expiry or reuse.
|
|
803
|
+
|
|
804
|
+
```diff
|
|
805
|
+
- const { url } = await auth.getGoogleOAuthURL({ redirect_uri });
|
|
806
|
+
- window.location.href = url;
|
|
807
|
+
+ const oauthState = createOAuthStateStore();
|
|
808
|
+
+ const auth = createAuthClient({ baseUrl, oauthStateStore: oauthState });
|
|
809
|
+
+ const { url } = await auth.getGoogleOAuthURL({ redirect_uri });
|
|
810
|
+
+ window.location.href = url;
|
|
811
|
+
|
|
812
|
+
// callback route
|
|
813
|
+
- await auth.handleGoogleOAuthCallback({ code, redirect_uri });
|
|
814
|
+
+ const state = oauthState.take();
|
|
815
|
+
+ if (!state) throw new Error('No pending OAuth flow');
|
|
816
|
+
+ await auth.handleGoogleOAuthCallback({ code, redirect_uri, state });
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
TypeScript will point at every call site, since `state` is required. The failure
|
|
820
|
+
mode if you skip this is not a compile error in plain JavaScript, though — it is
|
|
821
|
+
a working-looking login that lets an attacker sign a user into the attacker's
|
|
822
|
+
account. See [Google OAuth](#google-oauth--and-why-the-state-must-round-trip) for
|
|
823
|
+
the full mechanism.
|
|
824
|
+
|
|
825
|
+
## License
|
|
826
|
+
|
|
827
|
+
MIT
|