@marianmeres/ownsuite 2.2.3 → 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
  ```
@@ -373,7 +394,7 @@ interface OwnedCollectionState<TRow> {
373
394
 
374
395
  ```typescript
375
396
  interface DomainStateWrapper<T> {
376
- state: DomainState; // "initializing" | "ready" | "syncing" | "error"
397
+ state: DomainState; // "initializing" | "ready" | "syncing" | "error"
377
398
  data: T | null;
378
399
  error: DomainError | null;
379
400
  lastSyncedAt: number | null;
@@ -399,9 +420,9 @@ error → syncing (retry)
399
420
 
400
421
  ```typescript
401
422
  interface DomainError {
402
- code: string; // e.g. "SYNC_FAILED", "FETCH_FAILED"
423
+ code: string; // e.g. "SYNC_FAILED", "FETCH_FAILED"
403
424
  message: string;
404
- operation: string; // e.g. "create", "update", "delete"
425
+ operation: string; // e.g. "create", "update", "delete"
405
426
  originalError?: unknown;
406
427
  }
407
428
  ```
@@ -438,11 +459,11 @@ type OwnsuiteEvent =
438
459
  | StateChangedEvent
439
460
  | ErrorEvent
440
461
  | SyncedEvent
441
- | ListFetchedEvent // + count
442
- | RowFetchedEvent // + rowId
443
- | RowCreatedEvent // + rowId
444
- | RowUpdatedEvent // + rowId
445
- | RowDeletedEvent; // + rowId
462
+ | ListFetchedEvent // + count
463
+ | RowFetchedEvent // + rowId
464
+ | RowCreatedEvent // + rowId
465
+ | RowUpdatedEvent // + rowId
466
+ | RowDeletedEvent; // + rowId
446
467
  ```
447
468
 
448
469
  See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
@@ -453,7 +474,13 @@ See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
453
474
  interface MockAdapterOptions<TRow> {
454
475
  seed?: TRow[];
455
476
  delayMs?: number;
456
- 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
+ };
457
484
  getRowId?: (row: TRow) => string;
458
485
  newId?: () => string;
459
486
  /** Reject create payloads containing `model_id` (default: true). */
@@ -516,6 +543,7 @@ Attached automatically when `adapters.auth` is passed to `createOwnsuite`. See a
516
543
  Default `AuthAdapter` pointing at a conventional account REST surface (register / login / logout / OAuth / verify).
517
544
 
518
545
  **Parameters:**
546
+
519
547
  - `options` (`StackAccountAdapterOptions`, optional)
520
548
  - `options.baseUrl` (`string`, optional) — mount path. Default: `"/api/account"`.
521
549
  - `options.fetch` (`typeof fetch`, optional) — custom fetch (tests / SSR).
@@ -554,18 +582,19 @@ In-memory mock for tests and demos. `createMockAuthStore` builds a shared state
554
582
  **`verifyMockAccount(store, email)`** marks an account as verified — stands in for the user clicking the link in a real verification email.
555
583
 
556
584
  **Example:**
585
+
557
586
  ```typescript
558
587
  const store = createMockAuthStore({ requireVerifiedEmail: true });
559
588
  const suite = createOwnsuite({
560
- adapters: {
561
- auth: createMockAuthAdapter(store),
562
- profile: createMockProfileAdapter(store),
563
- },
589
+ adapters: {
590
+ auth: createMockAuthAdapter(store),
591
+ profile: createMockProfileAdapter(store),
592
+ },
564
593
  });
565
594
  await suite.auth!.register({
566
- email: "alice@example.com",
567
- password: "mysecretpassword",
568
- password_confirm: "mysecretpassword",
595
+ email: "alice@example.com",
596
+ password: "mysecretpassword",
597
+ password_confirm: "mysecretpassword",
569
598
  });
570
599
  // suite.session!.get().status === "unverified"
571
600
  verifyMockAccount(store, "alice@example.com");
@@ -611,17 +640,17 @@ Clear storage and in-memory state. Called by `suite.destroy()`.
611
640
 
612
641
  Verbs only. Attached as `suite.auth`. No state of its own — results flow into `SessionManager`.
613
642
 
614
- | Method | Purpose |
615
- |---|---|
616
- | `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. |
617
- | `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`). |
618
- | `logout()` | Best-effort server revoke + local clear. Idempotent. |
619
- | `resendVerification({ email, lang? })` | Trigger a fresh verification email. Anti-enumeration: always resolves. |
620
- | `requestPasswordReset({ email, lang? })` | Trigger a password-reset email. Anti-enumeration. |
621
- | `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset. |
622
- | `deleteAccount({ password?, confirm? })` | Irreversible server delete + local session clear + identity-changed hook. |
623
- | `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"`). |
624
- | `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. |
625
654
 
626
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.
627
656
 
@@ -631,14 +660,14 @@ Successful identity changes (register-with-autologin, login, OAuth login, logout
631
660
 
632
661
  Singleton `/me` manager. Attached as `suite.profile`.
633
662
 
634
- | Method | Purpose |
635
- |---|---|
636
- | `fetch()` | GET `/me`. Aborts any in-flight fetch (supersede). Patches the session subject in place on success. |
637
- | `update({ email?, current_password? })` | PUT `/me`. Updates the session subject in place; emits `profile:updated`. |
638
- | `listOAuth()` | List the account's linked OAuth providers. |
639
- | `unlinkOAuth(provider)` | DELETE a provider connection; emits `oauth:unlinked`; re-fetches the profile. |
640
- | `get()` / `subscribe(fn)` | Read / subscribe to `ProfileState` (`{ profile, loading, error }`). |
641
- | `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. |
642
671
 
643
672
  ---
644
673
 
@@ -647,6 +676,7 @@ Singleton `/me` manager. Attached as `suite.profile`.
647
676
  Open an OAuth popup and await the server's `postMessage`.
648
677
 
649
678
  **Parameters:**
679
+
650
680
  - `url` (`string`) — server OAuth init URL.
651
681
  - `options` (`OpenOAuthPopupOptions`, optional)
652
682
  - `options.host` (`PopupWindowHost`, optional) — shim for tests. Default: `globalThis`.
@@ -665,10 +695,10 @@ Open an OAuth popup and await the server's `postMessage`.
665
695
 
666
696
  ```typescript
667
697
  interface SessionState {
668
- status: "anonymous" | "authenticated" | "unverified";
669
- subject: SessionSubject | null;
670
- jwt: string | null;
671
- expiresAt: number | null; // unix seconds
698
+ status: "anonymous" | "authenticated" | "unverified";
699
+ subject: SessionSubject | null;
700
+ jwt: string | null;
701
+ expiresAt: number | null; // unix seconds
672
702
  }
673
703
  ```
674
704
 
@@ -676,11 +706,11 @@ interface SessionState {
676
706
 
677
707
  ```typescript
678
708
  interface SessionSubject {
679
- id: string;
680
- email: string;
681
- roles: string[];
682
- isVerified: boolean;
683
- 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
684
714
  }
685
715
  ```
686
716
 
@@ -690,9 +720,9 @@ interface SessionSubject {
690
720
  type SessionStatus = "anonymous" | "authenticated" | "unverified";
691
721
 
692
722
  interface SessionStorage {
693
- get(key: string): string | null;
694
- set(key: string, value: string): void;
695
- del(key: string): void;
723
+ get(key: string): string | null;
724
+ set(key: string, value: string): void;
725
+ del(key: string): void;
696
726
  }
697
727
 
698
728
  type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
@@ -702,13 +732,13 @@ type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
702
732
 
703
733
  ```typescript
704
734
  interface AuthTokenResult {
705
- jwt?: string; // absent when requiresVerification is true
706
- email: string;
707
- roles: string[];
708
- isVerified?: boolean;
709
- validFrom?: number;
710
- validUntil?: number;
711
- 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
712
742
  }
713
743
  ```
714
744
 
@@ -716,11 +746,11 @@ interface AuthTokenResult {
716
746
 
717
747
  ```typescript
718
748
  interface ProfileResult {
719
- email: string;
720
- roles: string[];
721
- isVerified: boolean;
722
- hasPassword: boolean;
723
- oauthConnections: OAuthConnection[];
749
+ email: string;
750
+ roles: string[];
751
+ isVerified: boolean;
752
+ hasPassword: boolean;
753
+ oauthConnections: OAuthConnection[];
724
754
  }
725
755
  ```
726
756
 
@@ -731,25 +761,25 @@ type OAuthProvider = "google" | "facebook" | "apple" | "twitter";
731
761
  type OAuthAction = "login" | "link";
732
762
 
733
763
  interface OAuthInitOptions {
734
- action: OAuthAction;
735
- redirect?: string;
736
- lang?: string;
737
- mode?: "popup" | "redirect"; // default "popup"
738
- 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
739
769
  }
740
770
 
741
771
  interface AuthActionOptions {
742
- /** true → localStorage; false → sessionStorage; undefined → default.
743
- * Ignored when SessionManager was constructed with a custom
744
- * SessionStorage object. */
745
- remember?: boolean;
772
+ /** true → localStorage; false → sessionStorage; undefined → default.
773
+ * Ignored when SessionManager was constructed with a custom
774
+ * SessionStorage object. */
775
+ remember?: boolean;
746
776
  }
747
777
 
748
778
  interface OAuthConnection {
749
- provider: OAuthProvider;
750
- display_name?: string;
751
- avatar_url?: string;
752
- email?: string;
779
+ provider: OAuthProvider;
780
+ display_name?: string;
781
+ avatar_url?: string;
782
+ email?: string;
753
783
  }
754
784
  ```
755
785
 
@@ -757,15 +787,15 @@ interface OAuthConnection {
757
787
 
758
788
  ```typescript
759
789
  interface AuthAdapter {
760
- register(input, ctx): Promise<AuthTokenResult>;
761
- login(input, ctx): Promise<AuthTokenResult>;
762
- logout(ctx): Promise<void>;
763
- oauthInitUrl(provider, opts, ctx): string;
764
- handleOAuthCallback?(ctx): Promise<AuthTokenResult>;
765
- resendVerification(input, ctx): Promise<void>;
766
- requestPasswordReset(input, ctx): Promise<void>;
767
- changePassword(input, ctx): Promise<void>;
768
- 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 }>;
769
799
  }
770
800
  ```
771
801
 
@@ -775,10 +805,10 @@ Parameter shapes match [`src/types/auth.ts`](src/types/auth.ts). Implementations
775
805
 
776
806
  ```typescript
777
807
  interface ProfileAdapter {
778
- get(ctx): Promise<ProfileResult>;
779
- update(input: { email?, current_password? }, ctx): Promise<ProfileResult>;
780
- listOAuth(ctx): Promise<OAuthConnection[]>;
781
- 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>;
782
812
  }
783
813
  ```
784
814
 
@@ -786,8 +816,8 @@ interface ProfileAdapter {
786
816
 
787
817
  ```typescript
788
818
  interface StackAccountAdapterOptions {
789
- baseUrl?: string; // default "/api/account"
790
- fetch?: typeof fetch;
819
+ baseUrl?: string; // default "/api/account"
820
+ fetch?: typeof fetch;
791
821
  }
792
822
  ```
793
823
 
@@ -799,18 +829,18 @@ See [src/oauth/popup.ts](src/oauth/popup.ts) for full definitions. `OAuthPopupMe
799
829
 
800
830
  ```typescript
801
831
  interface MockAccount {
802
- email: string;
803
- password: string; // plaintext — mock does no hashing
804
- roles: string[];
805
- isVerified: boolean;
806
- hasPassword: boolean;
807
- 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[];
808
838
  }
809
839
 
810
840
  interface MockAuthStore {
811
- accounts: Map<string, MockAccount>;
812
- requireVerifiedEmail: boolean;
813
- jwtsByEmail: Map<string, string>;
841
+ accounts: Map<string, MockAccount>;
842
+ requireVerifiedEmail: boolean;
843
+ jwtsByEmail: Map<string, string>;
814
844
  }
815
845
  ```
816
846
 
@@ -822,13 +852,13 @@ Fields are public by design so test code can peek at / mutate them directly.
822
852
 
823
853
  Emitted on the shared pubsub. Each payload has a `timestamp` (ms).
824
854
 
825
- | Event | Payload |
826
- |---|---|
827
- | `auth:register` | `{ email, requiresVerification }` |
828
- | `auth:login` | `{ email }` |
829
- | `auth:logout` | `{ subjectId? }` |
830
- | `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 }` |
831
861
  | `auth:verification:required` | `{ email }` (fired when status flips to `"unverified"`) |
832
- | `profile:updated` | `{ email }` |
833
- | `oauth:linked` | `{ connection: OAuthConnection }` |
834
- | `oauth:unlinked` | `{ provider: OAuthProvider }` |
862
+ | `profile:updated` | `{ email }` |
863
+ | `oauth:linked` | `{ connection: OAuthConnection }` |
864
+ | `oauth:unlinked` | `{ provider: OAuthProvider }` |
package/README.md CHANGED
@@ -29,44 +29,44 @@ Pass an `AuthAdapter` to `createOwnsuite` to attach the account-lifecycle manage
29
29
 
30
30
  ```typescript
31
31
  import {
32
- createOwnsuite,
33
- createStackAccountAuthAdapter,
34
- createStackAccountProfileAdapter,
32
+ createOwnsuite,
33
+ createStackAccountAuthAdapter,
34
+ createStackAccountProfileAdapter,
35
35
  } from "@marianmeres/ownsuite";
36
36
 
37
37
  const suite = createOwnsuite({
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
- },
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
+ },
49
49
  });
50
50
 
51
51
  // Observable session — UI subscribes to this for logged-in state.
52
52
  suite.session!.subscribe(({ status, subject }) => {
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");
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");
56
56
  });
57
57
 
58
58
  // Register → server requires email verification by default
59
59
  await suite.auth!.register({
60
- email: "alice@example.com",
61
- password: "mysecretpassword",
62
- password_confirm: "mysecretpassword",
60
+ email: "alice@example.com",
61
+ password: "mysecretpassword",
62
+ password_confirm: "mysecretpassword",
63
63
  });
64
64
  // suite.session!.get().status === "unverified"
65
65
 
66
66
  // After the user clicks the email link and the server flips isVerified:
67
67
  await suite.auth!.login(
68
- { email: "alice@example.com", password: "mysecretpassword" },
69
- { 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)
70
70
  );
71
71
  // suite.session!.get().status === "authenticated"
72
72
  // Every registered owner-scoped domain is re-initialized with the new JWT.
@@ -77,8 +77,8 @@ await suite.auth!.initiateOAuth("google", { action: "login" });
77
77
  // Profile edit — changing email resets isVerified server-side and dispatches
78
78
  // a new verification email. Session subject is patched in place.
79
79
  await suite.profile!.update({
80
- email: "renamed@example.com",
81
- current_password: "mysecretpassword",
80
+ email: "renamed@example.com",
81
+ current_password: "mysecretpassword",
82
82
  });
83
83
 
84
84
  // Logout — revokes JWT server-side and clears local session storage.
@@ -157,14 +157,11 @@ Each domain holds a single list of rows owned by the authenticated subject. List
157
157
  ## Testing with the mock adapter
158
158
 
159
159
  ```typescript
160
- import {
161
- createMockOwnedCollectionAdapter,
162
- createOwnsuite,
163
- } from "@marianmeres/ownsuite";
160
+ import { createMockOwnedCollectionAdapter, createOwnsuite } from "@marianmeres/ownsuite";
164
161
 
165
162
  const adapter = createMockOwnedCollectionAdapter({
166
163
  seed: [{ model_id: "1", data: { label: "hello" } }],
167
- failOn: { update: true }, // force update failures for rollback tests
164
+ failOn: { update: true }, // force update failures for rollback tests
168
165
  });
169
166
 
170
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.3",
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
  },