@marianmeres/ownsuite 2.2.2 → 2.3.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/AGENTS.md CHANGED
@@ -22,6 +22,7 @@ Client-side helper library for **owner-scoped** UIs. Generic domain managers for
22
22
  **Also (opt-in)** provides the blessed account-lifecycle surface for apps built on `@marianmeres/stack-account`: `suite.auth` (register / login / logout / OAuth init / password reset / delete account), `suite.profile` (`/me` CRUD + OAuth link list), `suite.session` (reactive JWT + subject, pluggable storage). Pass an `AuthAdapter` to `createOwnsuite({ adapters: { auth } })` to attach them.
23
23
 
24
24
  Pairs with:
25
+
25
26
  - **`@marianmeres/collection`** — `ownerIdScope` route hook (read-side owner enforcement).
26
27
  - **`@marianmeres/stack-common`** — `ownsuiteOptions()` server helper for mounting `/me/*` routes.
27
28
  - **`@marianmeres/stack-account`** — default adapters (`createStackAccountAuthAdapter`, `createStackAccountProfileAdapter`) target its REST surface.
@@ -101,9 +102,11 @@ tests/
101
102
 
102
103
  ```typescript
103
104
  // Main
104
- export { Ownsuite, createOwnsuite } from "./ownsuite.ts";
105
+ export { createOwnsuite, Ownsuite } from "./ownsuite.ts";
105
106
  export type {
106
- OwnsuiteConfig, OwnsuiteDomainConfig, SetContextOptions,
107
+ OwnsuiteConfig,
108
+ OwnsuiteDomainConfig,
109
+ SetContextOptions,
107
110
  } from "./ownsuite.ts";
108
111
 
109
112
  // Domain managers
@@ -112,13 +115,25 @@ export type { BaseDomainOptions, OwnedCollectionManagerOptions } from "./domains
112
115
 
113
116
  // Types
114
117
  export type {
115
- DomainError, DomainState, DomainStateWrapper,
116
- OwnsuiteContext, OwnedCollectionState,
117
- OwnsuiteEvent, OwnsuiteEventType, DomainName,
118
- StateChangedEvent, ErrorEvent, SyncedEvent,
119
- ListFetchedEvent, RowFetchedEvent,
120
- RowCreatedEvent, RowUpdatedEvent, RowDeletedEvent,
121
- OwnedCollectionAdapter, OwnedListResult, OwnedRowResult,
118
+ DomainError,
119
+ DomainName,
120
+ DomainState,
121
+ DomainStateWrapper,
122
+ ErrorEvent,
123
+ ListFetchedEvent,
124
+ OwnedCollectionAdapter,
125
+ OwnedCollectionState,
126
+ OwnedListResult,
127
+ OwnedRowResult,
128
+ OwnsuiteContext,
129
+ OwnsuiteEvent,
130
+ OwnsuiteEventType,
131
+ RowCreatedEvent,
132
+ RowDeletedEvent,
133
+ RowFetchedEvent,
134
+ RowUpdatedEvent,
135
+ StateChangedEvent,
136
+ SyncedEvent,
122
137
  } from "./types/mod.ts";
123
138
 
124
139
  // Mock adapter (for tests)
@@ -126,19 +141,32 @@ export { createMockOwnedCollectionAdapter } from "./adapters/mod.ts";
126
141
  export type { MockAdapterOptions } from "./adapters/mod.ts";
127
142
 
128
143
  // Account lifecycle (optional — attached when adapters.auth is supplied)
129
- export { SessionManager, AuthManager, ProfileManager } from "./domains/mod.ts";
144
+ export { AuthManager, ProfileManager, SessionManager } from "./domains/mod.ts";
130
145
  export type {
131
- AuthAdapter, ProfileAdapter, AuthTokenResult, ProfileResult,
132
- SessionState, SessionSubject, SessionStatus,
133
- SessionStorage, SessionStorageType,
134
- OAuthConnection, OAuthProvider, OAuthInitOptions, OAuthAction,
146
+ AuthAdapter,
147
+ AuthTokenResult,
148
+ OAuthAction,
149
+ OAuthConnection,
150
+ OAuthInitOptions,
151
+ OAuthProvider,
152
+ ProfileAdapter,
153
+ ProfileResult,
154
+ SessionState,
155
+ SessionStatus,
156
+ SessionStorage,
157
+ SessionStorageType,
158
+ SessionSubject,
135
159
  } from "./types/mod.ts";
136
160
 
137
161
  // OAuth popup helper
138
162
  export { openOAuthPopup } from "./oauth/popup.ts";
139
163
  export type {
140
- OAuthPopupMessage, OAuthPopupLoginMessage, OAuthPopupLinkMessage,
141
- OpenOAuthPopupOptions, PopupWindowHost, PopupWindowHandle,
164
+ OAuthPopupLinkMessage,
165
+ OAuthPopupLoginMessage,
166
+ OAuthPopupMessage,
167
+ OpenOAuthPopupOptions,
168
+ PopupWindowHandle,
169
+ PopupWindowHost,
142
170
  } from "./oauth/popup.ts";
143
171
 
144
172
  // Default stack-account adapters
@@ -150,8 +178,10 @@ export type { StackAccountAdapterOptions } from "./adapters/mod.ts";
150
178
 
151
179
  // Mock auth adapter (for tests)
152
180
  export {
153
- createMockAuthAdapter, createMockProfileAdapter,
154
- createMockAuthStore, verifyMockAccount,
181
+ createMockAuthAdapter,
182
+ createMockAuthStore,
183
+ createMockProfileAdapter,
184
+ verifyMockAccount,
155
185
  } from "./adapters/mod.ts";
156
186
  export type { MockAccount, MockAuthStore } from "./adapters/mod.ts";
157
187
 
@@ -211,7 +241,7 @@ Triggered by `initialize()`, `refresh()`, `create()`, `update()`, `delete()` on
211
241
 
212
242
  4. **`initialize()` never rejects.** Per-domain errors land in that domain's `error` state; the top-level promise resolves. Use `suite.hasErrors()` / `suite.errors()` to detect failed boots, or subscribe to `domain:error`.
213
243
 
214
- 5. **Optimistic updates roll back per-row on failure.** `update` mutates the single target row; on error that row reverts to its pre-call value. `delete` removes the target row; on error it is re-inserted at its original position (unless another op has since re-added it). `create` does NOT optimistically insert. Rollback reads the *live* store so an interleaved `refresh()` that brought new rows is preserved.
244
+ 5. **Optimistic updates roll back per-row on failure.** `update` mutates the single target row; on error that row reverts to its pre-call value. `delete` removes the target row; on error it is re-inserted at its original position (unless another op has since re-added it). `create` does NOT optimistically insert. Rollback reads the _live_ store so an interleaved `refresh()` that brought new rows is preserved.
215
245
 
216
246
  6. **`OwnsuiteContext.subjectId` is a hint, not authorization.** The server is authoritative. Setting it client-side has no security effect. When subject changes, call `suite.setContext(ctx, { replace: true, refresh: true })` to clear stale per-subject caches.
217
247
 
@@ -259,7 +289,7 @@ suite.domain("orders").subscribe((s) => {
259
289
 
260
290
  ```typescript
261
291
  suite.on("own:row:created", (e) => {/* e.rowId, e.domain, e.timestamp */});
262
- suite.on("domain:error", (e) => {/* e.error */});
292
+ suite.on("domain:error", (e) => {/* e.error */});
263
293
  suite.onAny(({ event, data }) => {/* wildcard envelope */});
264
294
  ```
265
295
 
@@ -295,7 +325,11 @@ import { HTTP_ERROR } from "@marianmeres/http-utils";
295
325
  const adapter: OwnedCollectionAdapter = {
296
326
  async list(ctx, query) {
297
327
  const url = new URL(`/api/shop/me/col/order/mod`, location.origin);
298
- if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v));
328
+ if (query) {
329
+ for (const [k, v] of Object.entries(query)) {
330
+ url.searchParams.set(k, String(v));
331
+ }
332
+ }
299
333
  const res = await fetch(url, { signal: ctx.signal }); // forward abort
300
334
  if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
301
335
  return await res.json(); // { data, meta }
@@ -309,10 +343,7 @@ Joy ships a reusable factory at `src/admin/packages/joy/src/routes/me/owned-coll
309
343
  ### Testing with the mock adapter
310
344
 
311
345
  ```typescript
312
- import {
313
- createMockOwnedCollectionAdapter,
314
- createOwnsuite,
315
- } from "@marianmeres/ownsuite";
346
+ import { createMockOwnedCollectionAdapter, createOwnsuite } from "@marianmeres/ownsuite";
316
347
 
317
348
  const adapter = createMockOwnedCollectionAdapter({
318
349
  seed: [{ model_id: "1", data: { label: "a" } }],
@@ -373,6 +404,7 @@ deno task test:watch # watch mode
373
404
  ```
374
405
 
375
406
  Coverage by file:
407
+
376
408
  - `tests/ownsuite.test.ts` — core suite + `OwnedCollectionManager` CRUD, events, rollback.
377
409
  - `tests/concurrency.test.ts` — critical invariants: concurrent mutations, abort-supersede, `getOne` not setting error, phantom-row prevention, destroy semantics, `errors()`/`hasErrors()` helpers.
378
410
  - `tests/auth.test.ts` — `AuthManager` / `ProfileManager` / `SessionManager`: register / login / logout / unverified gate / OAuth login (popup + redirect) / OAuth unlink / profile update patching session / deleteAccount / identity-change hook propagation.
@@ -400,6 +432,7 @@ The client-side scope assumes the server enforces owner-based filtering. This re
400
432
  3. **Auth middleware**: must populate `ctx.locals.subject` (typically via `@marianmeres/stack-common`'s `createJwtMiddleware`) before the collection routes handle the request.
401
433
 
402
434
  URL shape the default adapter helper expects:
435
+
403
436
  ```
404
437
  List GET {apiRoot}/{stack}/me/col/{entity}/mod
405
438
  Get GET {apiRoot}/{stack}/me/col/{entity}/mod/{id}
@@ -411,6 +444,7 @@ Delete DELETE {apiRoot}/{stack}/me/col/{entity}/mod/{id}
411
444
  ### Joy admin SPA pairing
412
445
 
413
446
  Joy ships:
447
+
414
448
  - `src/admin/packages/joy/src/components/layout/LayoutCustomer.svelte` — simplified chrome for `/me/*`.
415
449
  - `src/admin/packages/joy/src/routes/me/MeRouter.svelte` — route entry point.
416
450
  - `src/admin/packages/joy/src/routes/me/owned-collection-adapter.ts` — reusable adapter factory.
@@ -471,14 +505,14 @@ described in the "Account lifecycle (optional)" section above.
471
505
 
472
506
  ## Differences from `@marianmeres/ecsuite`
473
507
 
474
- | Aspect | ecsuite | ownsuite |
475
- |--------|---------|----------|
476
- | Domains | Fixed 6 (cart, wishlist, order, customer, payment, product) | Arbitrary, registered by name |
477
- | State shape | Domain-specific (cart items, orders list, etc.) | Generic `{ rows, meta }` per domain |
478
- | Persistence | localStorage for cart/wishlist | None (server is source of truth) |
479
- | Scoping | Customer-id hint in context | Server-enforced via `owner_id` |
480
- | Optimistic create | Yes (cart items) | No (no client-assigned id) |
481
- | Optimistic update/delete | Yes | Yes |
482
- | Event namespaces | `cart:*`, `order:*`, etc. | `own:list:*`, `own:row:*` |
508
+ | Aspect | ecsuite | ownsuite |
509
+ | ------------------------ | ----------------------------------------------------------- | ----------------------------------- |
510
+ | Domains | Fixed 6 (cart, wishlist, order, customer, payment, product) | Arbitrary, registered by name |
511
+ | State shape | Domain-specific (cart items, orders list, etc.) | Generic `{ rows, meta }` per domain |
512
+ | Persistence | localStorage for cart/wishlist | None (server is source of truth) |
513
+ | Scoping | Customer-id hint in context | Server-enforced via `owner_id` |
514
+ | Optimistic create | Yes (cart items) | No (no client-assigned id) |
515
+ | Optimistic update/delete | Yes | Yes |
516
+ | Event namespaces | `cart:*`, `order:*`, etc. | `own:list:*`, `own:row:*` |
483
517
 
484
518
  Consumers that already use ecsuite can compose both suites in the same app.
package/API.md CHANGED
@@ -7,11 +7,13 @@
7
7
  Convenience factory mirroring the ecsuite `createECSuite` convention. Equivalent to `new Ownsuite(config)`.
8
8
 
9
9
  **Parameters:**
10
+
10
11
  - `config` (`OwnsuiteConfig`, optional) — see [`OwnsuiteConfig`](#ownsuiteconfig).
11
12
 
12
13
  **Returns:** `Ownsuite`
13
14
 
14
15
  **Example:**
16
+
15
17
  ```typescript
16
18
  import { createOwnsuite } from "@marianmeres/ownsuite";
17
19
 
@@ -32,6 +34,7 @@ await suite.initialize();
32
34
  In-memory mock adapter for tests. Stores rows in a local `Map` keyed by `model_id`. Applies optional latency and can inject failures per operation — useful for exercising the optimistic-update rollback path deterministically.
33
35
 
34
36
  **Parameters:**
37
+
35
38
  - `options` (`MockAdapterOptions<TRow>`, optional)
36
39
  - `options.seed` (`TRow[]`, optional) — initial rows
37
40
  - `options.delayMs` (`number`, optional) — artificial latency per call. Default: `0`
@@ -44,6 +47,7 @@ In-memory mock adapter for tests. Stores rows in a local `Map` keyed by `model_i
44
47
  The returned adapter exposes an extra `_rows()` method for test assertions that doesn't exist on production adapters.
45
48
 
46
49
  **Example:**
50
+
47
51
  ```typescript
48
52
  const adapter = createMockOwnedCollectionAdapter({
49
53
  seed: [{ model_id: "1", data: { label: "a" } }],
@@ -72,6 +76,7 @@ Readonly properties pointing at the account-lifecycle managers. Populated only w
72
76
  Register a new domain after construction. Throws if `name` is already registered.
73
77
 
74
78
  **Parameters:**
79
+
75
80
  - `name` (`string`) — unique domain label
76
81
  - `cfg` (`OwnsuiteDomainConfig<TRow, TCreate, TUpdate>`)
77
82
  - `cfg.adapter` (`OwnedCollectionAdapter`) — required
@@ -98,6 +103,7 @@ List registered domain names.
98
103
  Initialize all registered domains (or a subset). Runs in parallel. Individual domain errors land in that domain's error state and **do not** reject the returned promise.
99
104
 
100
105
  **Parameters:**
106
+
101
107
  - `names` (`string[]`, optional) — domain names to initialize. Default: all registered domains.
102
108
 
103
109
  **Returns:** `Promise<void>`
@@ -107,12 +113,14 @@ Initialize all registered domains (or a subset). Runs in parallel. Individual do
107
113
  Update the shared context and propagate to every registered domain manager.
108
114
 
109
115
  **Parameters:**
116
+
110
117
  - `ctx` (`OwnsuiteContext`)
111
118
  - `options` (`SetContextOptions`, optional)
112
119
  - `options.replace` (`boolean`, default `false`) — replace the context wholesale instead of merging. Use this when the subject changes and previous per-subject keys must not leak into adapter calls.
113
120
  - `options.refresh` (`boolean`, default `false`) — fire-and-forget `refresh()` on every domain after the context change. Recommended when `subjectId` changes so stale per-subject caches are cleared.
114
121
 
115
122
  **Example:**
123
+
116
124
  ```typescript
117
125
  // Subject change: drop old context + re-fetch every domain
118
126
  suite.setContext({ subjectId: newId }, { replace: true, refresh: true });
@@ -145,12 +153,14 @@ True after `destroy()` has been called.
145
153
  Subscribe to a specific event type.
146
154
 
147
155
  **Parameters:**
156
+
148
157
  - `type` (`OwnsuiteEventType`)
149
158
  - `subscriber` (`Subscriber`) — from `@marianmeres/pubsub`
150
159
 
151
160
  **Returns:** `Unsubscriber`
152
161
 
153
162
  **Example:**
163
+
154
164
  ```typescript
155
165
  const unsub = suite.on("own:row:created", (e) => {
156
166
  console.log("created row", e.rowId, "in domain", e.domain);
@@ -178,6 +188,7 @@ Generic manager for a single owner-scoped collection domain. One instance per co
178
188
  Typically created via `Ownsuite.registerDomain()` — manual construction is possible but bypasses the shared pubsub.
179
189
 
180
190
  **Parameters:**
191
+
181
192
  - `domainName` (`string`) — label (informational, used in event payloads and logs)
182
193
  - `options` (`OwnedCollectionManagerOptions<TRow, TCreate, TUpdate>`, optional)
183
194
  - `options.adapter` (`OwnedCollectionAdapter`, optional)
@@ -204,6 +215,7 @@ Fetch the list from the server. Populates `data.rows` + `data.meta` and transiti
204
215
  Re-fetch the list. Same as `initialize` but re-entrant; accepts an adapter-specific query object.
205
216
 
206
217
  **Parameters:**
218
+
207
219
  - `query` (`Record<string, unknown>`, optional) — forwarded to `adapter.list(ctx, query)`
208
220
 
209
221
  #### `manager.getOne(id): Promise<TRow | null>`
@@ -217,6 +229,7 @@ Callers that need error detail should wrap this method and inspect the adapter e
217
229
  Create a new row. On success, prepends the server-returned row to the list. On failure, the list is unchanged and the manager transitions to `error`.
218
230
 
219
231
  **Parameters:**
232
+
220
233
  - `data` (`TCreate`) — creation payload. The server stamps `owner_id` — do not set it client-side.
221
234
 
222
235
  **Returns:** the server-returned row, or `null` on failure.
@@ -228,6 +241,7 @@ Update a row. Optimistically merges `data` into the existing row; on server fail
228
241
  If `id` is **not** in the current cached list (filtered out by an active query, or not loaded), the optimistic step is a no-op AND the successful server response is **not** inserted — call `refresh()` if you want the row to appear. The `own:row:updated` event is emitted regardless.
229
242
 
230
243
  **Parameters:**
244
+
231
245
  - `id` (`string`)
232
246
  - `data` (`TUpdate`)
233
247
 
@@ -289,8 +303,8 @@ interface OwnsuiteConfig {
289
303
  profile?: ProfileAdapter;
290
304
  };
291
305
  session?: {
292
- storage?: SessionStorageType; // "local" | "session" | "memory" | SessionStorage
293
- storageKey?: string; // default: "ownsuite:session"
306
+ storage?: SessionStorageType; // "local" | "session" | "memory" | SessionStorage
307
+ storageKey?: string; // default: "ownsuite:session"
294
308
  };
295
309
  }
296
310
  ```
@@ -315,8 +329,8 @@ interface OwnsuiteDomainConfig<TRow, TCreate, TUpdate> {
315
329
 
316
330
  ```typescript
317
331
  interface SetContextOptions {
318
- replace?: boolean; // default: false — merge into existing context
319
- refresh?: boolean; // default: false — fire refresh() on every domain
332
+ replace?: boolean; // default: false — merge into existing context
333
+ refresh?: boolean; // default: false — fire refresh() on every domain
320
334
  }
321
335
  ```
322
336
 
@@ -325,7 +339,7 @@ interface SetContextOptions {
325
339
  ```typescript
326
340
  interface OwnsuiteContext {
327
341
  subjectId?: string;
328
- signal?: AbortSignal; // manager-injected, per-call
342
+ signal?: AbortSignal; // manager-injected, per-call
329
343
  [key: string]: unknown;
330
344
  }
331
345
  ```
@@ -336,10 +350,17 @@ Context passed to adapters. **`subjectId` is a hint only** — the server author
336
350
 
337
351
  ```typescript
338
352
  interface OwnedCollectionAdapter<TRow, TCreate = unknown, TUpdate = unknown> {
339
- list(ctx: OwnsuiteContext, query?: Record<string, unknown>): Promise<OwnedListResult<TRow>>;
353
+ list(
354
+ ctx: OwnsuiteContext,
355
+ query?: Record<string, unknown>,
356
+ ): Promise<OwnedListResult<TRow>>;
340
357
  getOne(id: string, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
341
358
  create(data: TCreate, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
342
- update(id: string, data: TUpdate, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
359
+ update(
360
+ id: string,
361
+ data: TUpdate,
362
+ ctx: OwnsuiteContext,
363
+ ): Promise<OwnedRowResult<TRow>>;
343
364
  delete(id: string, ctx: OwnsuiteContext): Promise<boolean>;
344
365
  }
345
366
  ```
@@ -360,8 +381,6 @@ interface OwnedRowResult<TRow> {
360
381
  }
361
382
  ```
362
383
 
363
- Matches `@marianmeres/collection`'s REST envelope.
364
-
365
384
  ### `OwnedCollectionState<TRow>`
366
385
 
367
386
  ```typescript
@@ -375,7 +394,7 @@ interface OwnedCollectionState<TRow> {
375
394
 
376
395
  ```typescript
377
396
  interface DomainStateWrapper<T> {
378
- state: DomainState; // "initializing" | "ready" | "syncing" | "error"
397
+ state: DomainState; // "initializing" | "ready" | "syncing" | "error"
379
398
  data: T | null;
380
399
  error: DomainError | null;
381
400
  lastSyncedAt: number | null;
@@ -401,9 +420,9 @@ error → syncing (retry)
401
420
 
402
421
  ```typescript
403
422
  interface DomainError {
404
- code: string; // e.g. "SYNC_FAILED", "FETCH_FAILED"
423
+ code: string; // e.g. "SYNC_FAILED", "FETCH_FAILED"
405
424
  message: string;
406
- operation: string; // e.g. "create", "update", "delete"
425
+ operation: string; // e.g. "create", "update", "delete"
407
426
  originalError?: unknown;
408
427
  }
409
428
  ```
@@ -440,11 +459,11 @@ type OwnsuiteEvent =
440
459
  | StateChangedEvent
441
460
  | ErrorEvent
442
461
  | SyncedEvent
443
- | ListFetchedEvent // + count
444
- | RowFetchedEvent // + rowId
445
- | RowCreatedEvent // + rowId
446
- | RowUpdatedEvent // + rowId
447
- | RowDeletedEvent; // + rowId
462
+ | ListFetchedEvent // + count
463
+ | RowFetchedEvent // + rowId
464
+ | RowCreatedEvent // + rowId
465
+ | RowUpdatedEvent // + rowId
466
+ | RowDeletedEvent; // + rowId
448
467
  ```
449
468
 
450
469
  See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
@@ -455,7 +474,13 @@ See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
455
474
  interface MockAdapterOptions<TRow> {
456
475
  seed?: TRow[];
457
476
  delayMs?: number;
458
- failOn?: { list?: boolean; getOne?: boolean; create?: boolean; update?: boolean; delete?: boolean };
477
+ failOn?: {
478
+ list?: boolean;
479
+ getOne?: boolean;
480
+ create?: boolean;
481
+ update?: boolean;
482
+ delete?: boolean;
483
+ };
459
484
  getRowId?: (row: TRow) => string;
460
485
  newId?: () => string;
461
486
  /** Reject create payloads containing `model_id` (default: true). */
@@ -515,9 +540,10 @@ Attached automatically when `adapters.auth` is passed to `createOwnsuite`. See a
515
540
 
516
541
  ### `createStackAccountAuthAdapter(options?)`
517
542
 
518
- Default `AuthAdapter` pointing at the `@marianmeres/stack-account` REST surface.
543
+ Default `AuthAdapter` pointing at a conventional account REST surface (register / login / logout / OAuth / verify).
519
544
 
520
545
  **Parameters:**
546
+
521
547
  - `options` (`StackAccountAdapterOptions`, optional)
522
548
  - `options.baseUrl` (`string`, optional) — mount path. Default: `"/api/account"`.
523
549
  - `options.fetch` (`typeof fetch`, optional) — custom fetch (tests / SSR).
@@ -556,18 +582,19 @@ In-memory mock for tests and demos. `createMockAuthStore` builds a shared state
556
582
  **`verifyMockAccount(store, email)`** marks an account as verified — stands in for the user clicking the link in a real verification email.
557
583
 
558
584
  **Example:**
585
+
559
586
  ```typescript
560
587
  const store = createMockAuthStore({ requireVerifiedEmail: true });
561
588
  const suite = createOwnsuite({
562
- adapters: {
563
- auth: createMockAuthAdapter(store),
564
- profile: createMockProfileAdapter(store),
565
- },
589
+ adapters: {
590
+ auth: createMockAuthAdapter(store),
591
+ profile: createMockProfileAdapter(store),
592
+ },
566
593
  });
567
594
  await suite.auth!.register({
568
- email: "alice@example.com",
569
- password: "mysecretpassword",
570
- password_confirm: "mysecretpassword",
595
+ email: "alice@example.com",
596
+ password: "mysecretpassword",
597
+ password_confirm: "mysecretpassword",
571
598
  });
572
599
  // suite.session!.get().status === "unverified"
573
600
  verifyMockAccount(store, "alice@example.com");
@@ -613,17 +640,17 @@ Clear storage and in-memory state. Called by `suite.destroy()`.
613
640
 
614
641
  Verbs only. Attached as `suite.auth`. No state of its own — results flow into `SessionManager`.
615
642
 
616
- | Method | Purpose |
617
- |---|---|
618
- | `register({ email, password, password_confirm, roles?, extras? }, options?)` | Create account. Returns an `AuthTokenResult`. When the server's verification gate is on, the result carries `requiresVerification: true` and the session flips to `"unverified"` — no JWT yet. `options.remember` pins the resulting session to `localStorage` (`true`) or `sessionStorage` (`false`); omit to use the `SessionManager` default. |
619
- | `login({ email, password }, options?)` | Exchange credentials for a JWT. Session flips to `"authenticated"` on success, `"unverified"` if the server reports the gate. `options.remember` selects per-login storage (see `register`). |
620
- | `logout()` | Best-effort server revoke + local clear. Idempotent. |
621
- | `resendVerification({ email, lang? })` | Trigger a fresh verification email. Anti-enumeration: always resolves. |
622
- | `requestPasswordReset({ email, lang? })` | Trigger a password-reset email. Anti-enumeration. |
623
- | `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset. |
624
- | `deleteAccount({ password?, confirm? })` | Irreversible server delete + local session clear + identity-changed hook. |
625
- | `initiateOAuth(provider, opts)` | Start an OAuth flow. `mode: "popup"` (default) resolves with the auth result from the popup's `postMessage`; `mode: "redirect"` navigates the top window. `opts.remember` pins the resulting session's storage backend (only meaningful for `action: "login"`). |
626
- | `handleOAuthCallback(options?)` | For `mode: "redirect"` apps, call from your callback route to extract the result from the URL (delegated to `adapter.handleOAuthCallback`). `options.remember` pins the resulting session's storage — pass the same value the user picked before the redirect. |
643
+ | Method | Purpose |
644
+ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
645
+ | `register({ email, password, password_confirm, roles?, extras? }, options?)` | Create account. Returns an `AuthTokenResult`. When the server's verification gate is on, the result carries `requiresVerification: true` and the session flips to `"unverified"` — no JWT yet. `options.remember` pins the resulting session to `localStorage` (`true`) or `sessionStorage` (`false`); omit to use the `SessionManager` default. |
646
+ | `login({ email, password }, options?)` | Exchange credentials for a JWT. Session flips to `"authenticated"` on success, `"unverified"` if the server reports the gate. `options.remember` selects per-login storage (see `register`). |
647
+ | `logout()` | Best-effort server revoke + local clear. Idempotent. |
648
+ | `resendVerification({ email, lang? })` | Trigger a fresh verification email. Anti-enumeration: always resolves. |
649
+ | `requestPasswordReset({ email, lang? })` | Trigger a password-reset email. Anti-enumeration. |
650
+ | `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset. |
651
+ | `deleteAccount({ password?, confirm? })` | Irreversible server delete + local session clear + identity-changed hook. |
652
+ | `initiateOAuth(provider, opts)` | Start an OAuth flow. `mode: "popup"` (default) resolves with the auth result from the popup's `postMessage`; `mode: "redirect"` navigates the top window. `opts.remember` pins the resulting session's storage backend (only meaningful for `action: "login"`). |
653
+ | `handleOAuthCallback(options?)` | For `mode: "redirect"` apps, call from your callback route to extract the result from the URL (delegated to `adapter.handleOAuthCallback`). `options.remember` pins the resulting session's storage — pass the same value the user picked before the redirect. |
627
654
 
628
655
  Successful identity changes (register-with-autologin, login, OAuth login, logout, deleteAccount) fire the orchestrator's `onIdentityChanged` hook, which resets every owner-scoped domain and re-initializes them with the new context.
629
656
 
@@ -633,14 +660,14 @@ Successful identity changes (register-with-autologin, login, OAuth login, logout
633
660
 
634
661
  Singleton `/me` manager. Attached as `suite.profile`.
635
662
 
636
- | Method | Purpose |
637
- |---|---|
638
- | `fetch()` | GET `/me`. Aborts any in-flight fetch (supersede). Patches the session subject in place on success. |
639
- | `update({ email?, current_password? })` | PUT `/me`. Updates the session subject in place; emits `profile:updated`. |
640
- | `listOAuth()` | List the account's linked OAuth providers. |
641
- | `unlinkOAuth(provider)` | DELETE a provider connection; emits `oauth:unlinked`; re-fetches the profile. |
642
- | `get()` / `subscribe(fn)` | Read / subscribe to `ProfileState` (`{ profile, loading, error }`). |
643
- | `reset()` / `destroy()` | Abort in-flight fetch; drop state. |
663
+ | Method | Purpose |
664
+ | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
665
+ | `fetch()` | GET `/me`. Aborts any in-flight fetch (supersede). Patches the session subject in place on success. |
666
+ | `update({ email?, current_password? })` | PUT `/me`. Updates the session subject in place; emits `profile:updated`. |
667
+ | `listOAuth()` | List the account's linked OAuth providers. |
668
+ | `unlinkOAuth(provider)` | DELETE a provider connection; emits `oauth:unlinked`; re-fetches the profile. |
669
+ | `get()` / `subscribe(fn)` | Read / subscribe to `ProfileState` (`{ profile, loading, error }`). |
670
+ | `reset()` / `destroy()` | Abort in-flight fetch; drop state. |
644
671
 
645
672
  ---
646
673
 
@@ -649,6 +676,7 @@ Singleton `/me` manager. Attached as `suite.profile`.
649
676
  Open an OAuth popup and await the server's `postMessage`.
650
677
 
651
678
  **Parameters:**
679
+
652
680
  - `url` (`string`) — server OAuth init URL.
653
681
  - `options` (`OpenOAuthPopupOptions`, optional)
654
682
  - `options.host` (`PopupWindowHost`, optional) — shim for tests. Default: `globalThis`.
@@ -667,10 +695,10 @@ Open an OAuth popup and await the server's `postMessage`.
667
695
 
668
696
  ```typescript
669
697
  interface SessionState {
670
- status: "anonymous" | "authenticated" | "unverified";
671
- subject: SessionSubject | null;
672
- jwt: string | null;
673
- expiresAt: number | null; // unix seconds
698
+ status: "anonymous" | "authenticated" | "unverified";
699
+ subject: SessionSubject | null;
700
+ jwt: string | null;
701
+ expiresAt: number | null; // unix seconds
674
702
  }
675
703
  ```
676
704
 
@@ -678,11 +706,11 @@ interface SessionState {
678
706
 
679
707
  ```typescript
680
708
  interface SessionSubject {
681
- id: string;
682
- email: string;
683
- roles: string[];
684
- isVerified: boolean;
685
- hasPassword: boolean; // OAuth-only accounts: false
709
+ id: string;
710
+ email: string;
711
+ roles: string[];
712
+ isVerified: boolean;
713
+ hasPassword: boolean; // OAuth-only accounts: false
686
714
  }
687
715
  ```
688
716
 
@@ -692,9 +720,9 @@ interface SessionSubject {
692
720
  type SessionStatus = "anonymous" | "authenticated" | "unverified";
693
721
 
694
722
  interface SessionStorage {
695
- get(key: string): string | null;
696
- set(key: string, value: string): void;
697
- del(key: string): void;
723
+ get(key: string): string | null;
724
+ set(key: string, value: string): void;
725
+ del(key: string): void;
698
726
  }
699
727
 
700
728
  type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
@@ -704,13 +732,13 @@ type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
704
732
 
705
733
  ```typescript
706
734
  interface AuthTokenResult {
707
- jwt?: string; // absent when requiresVerification is true
708
- email: string;
709
- roles: string[];
710
- isVerified?: boolean;
711
- validFrom?: number;
712
- validUntil?: number;
713
- requiresVerification?: boolean; // server declined auto-login pending verify
735
+ jwt?: string; // absent when requiresVerification is true
736
+ email: string;
737
+ roles: string[];
738
+ isVerified?: boolean;
739
+ validFrom?: number;
740
+ validUntil?: number;
741
+ requiresVerification?: boolean; // server declined auto-login pending verify
714
742
  }
715
743
  ```
716
744
 
@@ -718,11 +746,11 @@ interface AuthTokenResult {
718
746
 
719
747
  ```typescript
720
748
  interface ProfileResult {
721
- email: string;
722
- roles: string[];
723
- isVerified: boolean;
724
- hasPassword: boolean;
725
- oauthConnections: OAuthConnection[];
749
+ email: string;
750
+ roles: string[];
751
+ isVerified: boolean;
752
+ hasPassword: boolean;
753
+ oauthConnections: OAuthConnection[];
726
754
  }
727
755
  ```
728
756
 
@@ -733,25 +761,25 @@ type OAuthProvider = "google" | "facebook" | "apple" | "twitter";
733
761
  type OAuthAction = "login" | "link";
734
762
 
735
763
  interface OAuthInitOptions {
736
- action: OAuthAction;
737
- redirect?: string;
738
- lang?: string;
739
- mode?: "popup" | "redirect"; // default "popup"
740
- remember?: boolean; // same semantics as AuthActionOptions.remember
764
+ action: OAuthAction;
765
+ redirect?: string;
766
+ lang?: string;
767
+ mode?: "popup" | "redirect"; // default "popup"
768
+ remember?: boolean; // same semantics as AuthActionOptions.remember
741
769
  }
742
770
 
743
771
  interface AuthActionOptions {
744
- /** true → localStorage; false → sessionStorage; undefined → default.
745
- * Ignored when SessionManager was constructed with a custom
746
- * SessionStorage object. */
747
- remember?: boolean;
772
+ /** true → localStorage; false → sessionStorage; undefined → default.
773
+ * Ignored when SessionManager was constructed with a custom
774
+ * SessionStorage object. */
775
+ remember?: boolean;
748
776
  }
749
777
 
750
778
  interface OAuthConnection {
751
- provider: OAuthProvider;
752
- display_name?: string;
753
- avatar_url?: string;
754
- email?: string;
779
+ provider: OAuthProvider;
780
+ display_name?: string;
781
+ avatar_url?: string;
782
+ email?: string;
755
783
  }
756
784
  ```
757
785
 
@@ -759,15 +787,15 @@ interface OAuthConnection {
759
787
 
760
788
  ```typescript
761
789
  interface AuthAdapter {
762
- register(input, ctx): Promise<AuthTokenResult>;
763
- login(input, ctx): Promise<AuthTokenResult>;
764
- logout(ctx): Promise<void>;
765
- oauthInitUrl(provider, opts, ctx): string;
766
- handleOAuthCallback?(ctx): Promise<AuthTokenResult>;
767
- resendVerification(input, ctx): Promise<void>;
768
- requestPasswordReset(input, ctx): Promise<void>;
769
- changePassword(input, ctx): Promise<void>;
770
- deleteAccount(input, ctx): Promise<{ deleted: true }>;
790
+ register(input, ctx): Promise<AuthTokenResult>;
791
+ login(input, ctx): Promise<AuthTokenResult>;
792
+ logout(ctx): Promise<void>;
793
+ oauthInitUrl(provider, opts, ctx): string;
794
+ handleOAuthCallback?(ctx): Promise<AuthTokenResult>;
795
+ resendVerification(input, ctx): Promise<void>;
796
+ requestPasswordReset(input, ctx): Promise<void>;
797
+ changePassword(input, ctx): Promise<void>;
798
+ deleteAccount(input, ctx): Promise<{ deleted: true }>;
771
799
  }
772
800
  ```
773
801
 
@@ -777,10 +805,10 @@ Parameter shapes match [`src/types/auth.ts`](src/types/auth.ts). Implementations
777
805
 
778
806
  ```typescript
779
807
  interface ProfileAdapter {
780
- get(ctx): Promise<ProfileResult>;
781
- update(input: { email?, current_password? }, ctx): Promise<ProfileResult>;
782
- listOAuth(ctx): Promise<OAuthConnection[]>;
783
- unlinkOAuth(provider, ctx): Promise<void>;
808
+ get(ctx): Promise<ProfileResult>;
809
+ update(input: { email?; current_password? }, ctx): Promise<ProfileResult>;
810
+ listOAuth(ctx): Promise<OAuthConnection[]>;
811
+ unlinkOAuth(provider, ctx): Promise<void>;
784
812
  }
785
813
  ```
786
814
 
@@ -788,8 +816,8 @@ interface ProfileAdapter {
788
816
 
789
817
  ```typescript
790
818
  interface StackAccountAdapterOptions {
791
- baseUrl?: string; // default "/api/account"
792
- fetch?: typeof fetch;
819
+ baseUrl?: string; // default "/api/account"
820
+ fetch?: typeof fetch;
793
821
  }
794
822
  ```
795
823
 
@@ -801,18 +829,18 @@ See [src/oauth/popup.ts](src/oauth/popup.ts) for full definitions. `OAuthPopupMe
801
829
 
802
830
  ```typescript
803
831
  interface MockAccount {
804
- email: string;
805
- password: string; // plaintext — mock does no hashing
806
- roles: string[];
807
- isVerified: boolean;
808
- hasPassword: boolean;
809
- oauthConnections: OAuthConnection[];
832
+ email: string;
833
+ password: string; // plaintext — mock does no hashing
834
+ roles: string[];
835
+ isVerified: boolean;
836
+ hasPassword: boolean;
837
+ oauthConnections: OAuthConnection[];
810
838
  }
811
839
 
812
840
  interface MockAuthStore {
813
- accounts: Map<string, MockAccount>;
814
- requireVerifiedEmail: boolean;
815
- jwtsByEmail: Map<string, string>;
841
+ accounts: Map<string, MockAccount>;
842
+ requireVerifiedEmail: boolean;
843
+ jwtsByEmail: Map<string, string>;
816
844
  }
817
845
  ```
818
846
 
@@ -824,13 +852,13 @@ Fields are public by design so test code can peek at / mutate them directly.
824
852
 
825
853
  Emitted on the shared pubsub. Each payload has a `timestamp` (ms).
826
854
 
827
- | Event | Payload |
828
- |---|---|
829
- | `auth:register` | `{ email, requiresVerification }` |
830
- | `auth:login` | `{ email }` |
831
- | `auth:logout` | `{ subjectId? }` |
832
- | `auth:session:changed` | `{ session: SessionState }` |
855
+ | Event | Payload |
856
+ | ---------------------------- | ------------------------------------------------------- |
857
+ | `auth:register` | `{ email, requiresVerification }` |
858
+ | `auth:login` | `{ email }` |
859
+ | `auth:logout` | `{ subjectId? }` |
860
+ | `auth:session:changed` | `{ session: SessionState }` |
833
861
  | `auth:verification:required` | `{ email }` (fired when status flips to `"unverified"`) |
834
- | `profile:updated` | `{ email }` |
835
- | `oauth:linked` | `{ connection: OAuthConnection }` |
836
- | `oauth:unlinked` | `{ provider: OAuthProvider }` |
862
+ | `profile:updated` | `{ email }` |
863
+ | `oauth:linked` | `{ connection: OAuthConnection }` |
864
+ | `oauth:unlinked` | `{ provider: OAuthProvider }` |
package/README.md CHANGED
@@ -8,10 +8,7 @@ Client-side helper library for owner-scoped UIs. Generic domain managers with op
8
8
 
9
9
  ## What it does
10
10
 
11
- Ownsuite gives front-end applications a uniform way to read, create, update and delete records from owner-scoped REST endpoints (typically `/me/*`). Each row is implicitly scoped to the authenticated subject by the server — the client never sets `owner_id`. The library pairs with:
12
-
13
- - **@marianmeres/collection** — the `ownerIdScope` route hook enforces owner-based filtering on the server.
14
- - **@marianmeres/stack-common** — the `ownsuiteOptions()` helper wires the server mount.
11
+ Ownsuite gives front-end applications a uniform way to read, create, update and delete records from owner-scoped REST endpoints (typically `/me/*`). Each row is implicitly scoped to the authenticated subject by the server — the client never sets `owner_id`.
15
12
 
16
13
  ## Features
17
14
 
@@ -24,52 +21,52 @@ Ownsuite gives front-end applications a uniform way to read, create, update and
24
21
  - **Event system** — subscribe to list fetches, row CRUD, and lifecycle transitions
25
22
  - **Mock adapter** — in-memory fixture for tests, with configurable failure injection and latency
26
23
  - **Explicit lifecycle** — `suite.destroy()` aborts in-flight work and releases listeners cleanly
27
- - **Account lifecycle (opt-in)** — `suite.auth` / `suite.session` / `suite.profile` for register / login / OAuth / verify / logout / profile edit / delete account, wired to pair with `@marianmeres/stack-account` via the bundled default adapters
24
+ - **Account lifecycle (opt-in)** — `suite.auth` / `suite.session` / `suite.profile` for register / login / OAuth / verify / logout / profile edit / delete account, with bundled default adapters for a standard account REST surface
28
25
 
29
26
  ## Authentication (optional)
30
27
 
31
- Pass an `AuthAdapter` to `createOwnsuite` to attach the account-lifecycle managers. The default adapters target the `@marianmeres/stack-account` REST surface; apps with custom routes can write their own against the `AuthAdapter` / `ProfileAdapter` interfaces exported from this package.
28
+ Pass an `AuthAdapter` to `createOwnsuite` to attach the account-lifecycle managers. The bundled default adapters target a conventional account REST surface (register / login / logout / OAuth / verify / profile CRUD); apps with custom routes can write their own against the `AuthAdapter` / `ProfileAdapter` interfaces exported from this package.
32
29
 
33
30
  ```typescript
34
31
  import {
35
- createOwnsuite,
36
- createStackAccountAuthAdapter,
37
- createStackAccountProfileAdapter,
32
+ createOwnsuite,
33
+ createStackAccountAuthAdapter,
34
+ createStackAccountProfileAdapter,
38
35
  } from "@marianmeres/ownsuite";
39
36
 
40
37
  const suite = createOwnsuite({
41
- adapters: {
42
- auth: createStackAccountAuthAdapter({ baseUrl: "/api/account" }),
43
- profile: createStackAccountProfileAdapter({ baseUrl: "/api/account" }),
44
- },
45
- session: { storage: "local", storageKey: "myapp:session" },
46
- // Existing owner-scoped domains continue to work — their ctx.jwt is
47
- // populated automatically from the session and they re-initialize on
48
- // every login / logout.
49
- domains: {
50
- orders: { adapter: ordersAdapter },
51
- },
38
+ adapters: {
39
+ auth: createStackAccountAuthAdapter({ baseUrl: "/api/account" }),
40
+ profile: createStackAccountProfileAdapter({ baseUrl: "/api/account" }),
41
+ },
42
+ session: { storage: "local", storageKey: "myapp:session" },
43
+ // Existing owner-scoped domains continue to work — their ctx.jwt is
44
+ // populated automatically from the session and they re-initialize on
45
+ // every login / logout.
46
+ domains: {
47
+ orders: { adapter: ordersAdapter },
48
+ },
52
49
  });
53
50
 
54
51
  // Observable session — UI subscribes to this for logged-in state.
55
52
  suite.session!.subscribe(({ status, subject }) => {
56
- if (status === "authenticated") console.log("hi", subject!.email);
57
- if (status === "unverified") console.log("check your inbox");
58
- if (status === "anonymous") console.log("signed out");
53
+ if (status === "authenticated") console.log("hi", subject!.email);
54
+ if (status === "unverified") console.log("check your inbox");
55
+ if (status === "anonymous") console.log("signed out");
59
56
  });
60
57
 
61
- // Register → server requires email verification (default gate in stack-account)
58
+ // Register → server requires email verification by default
62
59
  await suite.auth!.register({
63
- email: "alice@example.com",
64
- password: "mysecretpassword",
65
- password_confirm: "mysecretpassword",
60
+ email: "alice@example.com",
61
+ password: "mysecretpassword",
62
+ password_confirm: "mysecretpassword",
66
63
  });
67
64
  // suite.session!.get().status === "unverified"
68
65
 
69
66
  // After the user clicks the email link and the server flips isVerified:
70
67
  await suite.auth!.login(
71
- { email: "alice@example.com", password: "mysecretpassword" },
72
- { remember: true }, // true → localStorage; false → sessionStorage (per-login override)
68
+ { email: "alice@example.com", password: "mysecretpassword" },
69
+ { remember: true }, // true → localStorage; false → sessionStorage (per-login override)
73
70
  );
74
71
  // suite.session!.get().status === "authenticated"
75
72
  // Every registered owner-scoped domain is re-initialized with the new JWT.
@@ -80,12 +77,12 @@ await suite.auth!.initiateOAuth("google", { action: "login" });
80
77
  // Profile edit — changing email resets isVerified server-side and dispatches
81
78
  // a new verification email. Session subject is patched in place.
82
79
  await suite.profile!.update({
83
- email: "renamed@example.com",
84
- current_password: "mysecretpassword",
80
+ email: "renamed@example.com",
81
+ current_password: "mysecretpassword",
85
82
  });
86
83
 
87
- // Logout — revokes JWT server-side (via stack-account's jti deletion) and
88
- // clears local session storage. Owner-scoped domains reset to initializing.
84
+ // Logout — revokes JWT server-side and clears local session storage.
85
+ // Owner-scoped domains reset to initializing.
89
86
  await suite.auth!.logout();
90
87
  ```
91
88
 
@@ -160,14 +157,11 @@ Each domain holds a single list of rows owned by the authenticated subject. List
160
157
  ## Testing with the mock adapter
161
158
 
162
159
  ```typescript
163
- import {
164
- createMockOwnedCollectionAdapter,
165
- createOwnsuite,
166
- } from "@marianmeres/ownsuite";
160
+ import { createMockOwnedCollectionAdapter, createOwnsuite } from "@marianmeres/ownsuite";
167
161
 
168
162
  const adapter = createMockOwnedCollectionAdapter({
169
163
  seed: [{ model_id: "1", data: { label: "hello" } }],
170
- failOn: { update: true }, // force update failures for rollback tests
164
+ failOn: { update: true }, // force update failures for rollback tests
171
165
  });
172
166
 
173
167
  const suite = createOwnsuite({ domains: { notes: { adapter } } });
@@ -65,9 +65,7 @@ async function requestJson(doFetch, url, init, ctx) {
65
65
  headers: {
66
66
  ...(init.headers ?? {}),
67
67
  ...authHeaders(ctx),
68
- ...(init.body
69
- ? { "Content-Type": "application/json" }
70
- : {}),
68
+ ...(init.body ? { "Content-Type": "application/json" } : {}),
71
69
  },
72
70
  signal: ctx.signal,
73
71
  });
@@ -82,6 +80,72 @@ async function requestJson(doFetch, url, init, ctx) {
82
80
  return undefined;
83
81
  return (await res.json());
84
82
  }
83
+ /**
84
+ * Coerce a server-provided validity marker to **epoch seconds**.
85
+ *
86
+ * `AuthTokenResult.validFrom` / `validUntil` are typed (and consumed
87
+ * downstream — notably the session-expiry check) as numeric epoch seconds, but
88
+ * the stack-account server sends ISO-8601 strings. Blind-casting the wire shape
89
+ * lands a string in a numeric field, which silently breaks `expiresAt * 1000`
90
+ * arithmetic (`NaN`, so an expired session never expires). This bridges the two
91
+ * tolerantly:
92
+ * - ISO-8601 string → parsed to epoch seconds
93
+ * - epoch-seconds num → returned as-is
94
+ * - epoch-ms num → divided down (heuristic: `> 1e12` ⇒ milliseconds)
95
+ * - null / undefined / unparseable → `undefined`
96
+ */
97
+ function toEpochSeconds(v) {
98
+ if (typeof v === "number" && Number.isFinite(v)) {
99
+ return v > 1e12 ? Math.floor(v / 1000) : v;
100
+ }
101
+ if (typeof v === "string") {
102
+ const ms = Date.parse(v);
103
+ return Number.isNaN(ms) ? undefined : Math.floor(ms / 1000);
104
+ }
105
+ return undefined;
106
+ }
107
+ /** Best-effort read of a JWT payload's numeric `exp` claim (epoch seconds).
108
+ * The signature is NOT verified — the server is authoritative; this only
109
+ * reads the expiry of an already-trusted token as a fallback when the
110
+ * server's `validUntil` is missing or unparseable. Returns `undefined` for a
111
+ * malformed / non-JWT string so callers degrade gracefully. */
112
+ function jwtExpSeconds(jwt) {
113
+ if (!jwt)
114
+ return undefined;
115
+ const parts = jwt.split(".");
116
+ if (parts.length < 2)
117
+ return undefined;
118
+ try {
119
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
120
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
121
+ const bytes = Uint8Array.from(atob(b64 + pad), (c) => c.charCodeAt(0));
122
+ const payload = JSON.parse(new TextDecoder().decode(bytes));
123
+ return toEpochSeconds(payload.exp);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /** Translate the stack-account wire payload into ownsuite's
130
+ * {@link AuthTokenResult}, converting `validFrom` / `validUntil` (ISO string
131
+ * or numeric) to epoch seconds. Prefers the server's `validUntil`; falls back
132
+ * to the JWT's own `exp` claim when `validUntil` is absent or unparseable, so
133
+ * a malformed validity field can never produce an immortal client session.
134
+ * Replaces the previous blind `r.data as AuthTokenResult` cast. */
135
+ function normalizeAuthResult(data) {
136
+ const validFrom = toEpochSeconds(data.validFrom);
137
+ const validUntil = toEpochSeconds(data.validUntil) ??
138
+ jwtExpSeconds(data.jwt);
139
+ return {
140
+ jwt: data.jwt,
141
+ email: data.email,
142
+ roles: data.roles ?? [],
143
+ isVerified: data.isVerified,
144
+ requiresVerification: data.requiresVerification,
145
+ ...(validFrom !== undefined ? { validFrom } : {}),
146
+ ...(validUntil !== undefined ? { validUntil } : {}),
147
+ };
148
+ }
85
149
  /** Build the default {@link AuthAdapter} for `@marianmeres/stack-account`.
86
150
  * Points at `{baseUrl}/auth/*` (register/login/logout/verify/password/
87
151
  * delete) and `{baseUrl}/oauth/*` (init + callback). */
@@ -91,11 +155,11 @@ export function createStackAccountAuthAdapter(opts = {}) {
91
155
  return {
92
156
  async register(input, ctx) {
93
157
  const r = await postJson(doFetch, join(base, "/register"), input, ctx);
94
- return r.data;
158
+ return normalizeAuthResult(r.data);
95
159
  },
96
160
  async login(input, ctx) {
97
161
  const r = await postJson(doFetch, join(base, "/login"), input, ctx);
98
- return r.data;
162
+ return normalizeAuthResult(r.data);
99
163
  },
100
164
  async logout(ctx) {
101
165
  await requestJson(doFetch, join(base, "/logout"), { method: "POST" }, ctx);
@@ -151,11 +151,16 @@ export class SessionManager {
151
151
  storage.del(this.#storageKey);
152
152
  return null;
153
153
  }
154
- if (parsed.expiresAt !== null &&
155
- parsed.expiresAt !== undefined &&
156
- parsed.expiresAt * 1000 <= Date.now()) {
157
- storage.del(this.#storageKey);
158
- return null;
154
+ if (parsed.expiresAt !== null && parsed.expiresAt !== undefined) {
155
+ // Fail-closed: `expiresAt` MUST be finite epoch seconds. A
156
+ // present-but-non-finite value (e.g. an ISO string laundered
157
+ // through a buggy adapter) is treated as expired and wiped,
158
+ // rather than yielding `NaN <= now === false` → immortal session.
159
+ const exp = typeof parsed.expiresAt === "number" ? parsed.expiresAt : NaN;
160
+ if (!Number.isFinite(exp) || exp * 1000 <= Date.now()) {
161
+ storage.del(this.#storageKey);
162
+ return null;
163
+ }
159
164
  }
160
165
  return parsed;
161
166
  }
@@ -18,7 +18,7 @@ const DEFAULT_FEATURES = "width=500,height=700,noopener=no,noreferrer=no";
18
18
  * message is an error, or if the optional timeout fires.
19
19
  */
20
20
  export function openOAuthPopup(url, options = {}) {
21
- const host = (options.host ?? globalThis);
21
+ const host = options.host ?? globalThis;
22
22
  if (typeof host.open !== "function" || typeof host.addEventListener !== "function") {
23
23
  return Promise.reject(new Error("openOAuthPopup: host window does not support popups"));
24
24
  }
package/dist/ownsuite.js CHANGED
@@ -211,9 +211,7 @@ export class Ownsuite {
211
211
  setContext(ctx, options = {}) {
212
212
  if (this.#destroyed)
213
213
  return;
214
- this.#context = options.replace
215
- ? { ...ctx }
216
- : { ...this.#context, ...ctx };
214
+ this.#context = options.replace ? { ...ctx } : { ...this.#context, ...ctx };
217
215
  for (const m of this.#domains.values()) {
218
216
  if (options.replace)
219
217
  m.replaceContext(this.#context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/ownsuite",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
@@ -22,9 +22,9 @@
22
22
  "author": "Marian Meres",
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "@marianmeres/clog": "^3.17.1",
26
- "@marianmeres/collection-types": "^1.37.0",
27
- "@marianmeres/http-utils": "^2.6.0",
25
+ "@marianmeres/clog": "^3.21.0",
26
+ "@marianmeres/collection-types": "^1.41.0",
27
+ "@marianmeres/http-utils": "^2.11.0",
28
28
  "@marianmeres/pubsub": "^3.0.0",
29
29
  "@marianmeres/store": "^3.0.1"
30
30
  },