@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.
Files changed (74) hide show
  1. package/README.md +827 -0
  2. package/README.react.md +348 -0
  3. package/dist/auth/claims-types.d.ts +89 -0
  4. package/dist/auth/claims.d.ts +125 -0
  5. package/dist/auth/cross-tab.d.ts +114 -0
  6. package/dist/auth/errors.d.ts +40 -0
  7. package/dist/auth/index.d.ts +18 -0
  8. package/dist/auth/index.js +5 -0
  9. package/dist/auth/index.js.map +1 -0
  10. package/dist/auth/oauth-state.d.ts +93 -0
  11. package/dist/auth/session-manager.d.ts +253 -0
  12. package/dist/auth/storage.d.ts +36 -0
  13. package/dist/auth/tenant-directory.d.ts +59 -0
  14. package/dist/auth/tenant-selection.d.ts +92 -0
  15. package/dist/chunk-7WAV52EO.js +621 -0
  16. package/dist/chunk-7WAV52EO.js.map +1 -0
  17. package/dist/chunk-AJWM5MDZ.js +410 -0
  18. package/dist/chunk-AJWM5MDZ.js.map +1 -0
  19. package/dist/chunk-EXPYHNPV.js +212 -0
  20. package/dist/chunk-EXPYHNPV.js.map +1 -0
  21. package/dist/chunk-GPWGOYCA.js +85 -0
  22. package/dist/chunk-GPWGOYCA.js.map +1 -0
  23. package/dist/chunk-GQJ3QQPH.js +339 -0
  24. package/dist/chunk-GQJ3QQPH.js.map +1 -0
  25. package/dist/chunk-K2ELAI2X.js +64 -0
  26. package/dist/chunk-K2ELAI2X.js.map +1 -0
  27. package/dist/chunk-LRM6JJ63.js +616 -0
  28. package/dist/chunk-LRM6JJ63.js.map +1 -0
  29. package/dist/chunk-XAXFIIRT.js +959 -0
  30. package/dist/chunk-XAXFIIRT.js.map +1 -0
  31. package/dist/client/core/client-factory.d.ts +61 -0
  32. package/dist/client/core/client.d.ts +144 -0
  33. package/dist/client/core/errors.d.ts +105 -0
  34. package/dist/client/core/index.d.ts +9 -0
  35. package/dist/client/core/middleware.d.ts +67 -0
  36. package/dist/client/core/types.d.ts +99 -0
  37. package/dist/client/graphql/client.d.ts +66 -0
  38. package/dist/client/graphql/factory.d.ts +84 -0
  39. package/dist/client/graphql/operation.d.ts +24 -0
  40. package/dist/client/graphql/types.d.ts +60 -0
  41. package/dist/client/graphql/ws-client.d.ts +116 -0
  42. package/dist/client/index.d.ts +17 -0
  43. package/dist/client/index.js +227 -0
  44. package/dist/client/index.js.map +1 -0
  45. package/dist/client/middlewares/admin-auth.d.ts +90 -0
  46. package/dist/client/middlewares/auth.d.ts +81 -0
  47. package/dist/client/middlewares/index.d.ts +12 -0
  48. package/dist/client/middlewares/logging.d.ts +102 -0
  49. package/dist/client/middlewares/retry.d.ts +138 -0
  50. package/dist/client/middlewares/tenant.d.ts +60 -0
  51. package/dist/client/middlewares/turnstile.d.ts +41 -0
  52. package/dist/client/peer-free.d.ts +25 -0
  53. package/dist/client/utils/url.d.ts +19 -0
  54. package/dist/identity/index.d.ts +85 -0
  55. package/dist/identity/index.js +6 -0
  56. package/dist/identity/index.js.map +1 -0
  57. package/dist/identity/types.d.ts +690 -0
  58. package/dist/identity/v0.d.ts +594 -0
  59. package/dist/index.d.ts +50 -0
  60. package/dist/index.js +24 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/react/context.d.ts +47 -0
  63. package/dist/react/hooks.d.ts +120 -0
  64. package/dist/react/index.d.ts +19 -0
  65. package/dist/react/index.js +308 -0
  66. package/dist/react/index.js.map +1 -0
  67. package/dist/react/provider.d.ts +68 -0
  68. package/dist/react/store.d.ts +85 -0
  69. package/dist/storage/index.d.ts +31 -0
  70. package/dist/storage/index.js +5 -0
  71. package/dist/storage/index.js.map +1 -0
  72. package/dist/storage/types.d.ts +107 -0
  73. package/dist/storage/v0.d.ts +120 -0
  74. 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