@marianmeres/ownsuite 1.0.3 → 2.1.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/API.md CHANGED
@@ -63,6 +63,10 @@ Orchestrator that coordinates owner-scoped domain managers and provides a shared
63
63
 
64
64
  **Parameters:** same as `createOwnsuite`.
65
65
 
66
+ #### `suite.session`, `suite.auth`, `suite.profile`
67
+
68
+ Readonly properties pointing at the account-lifecycle managers. Populated only when `config.adapters.auth` was supplied — `null` otherwise. Full surface documented under [Account lifecycle (optional)](#account-lifecycle-optional).
69
+
66
70
  #### `suite.registerDomain(name, cfg)`
67
71
 
68
72
  Register a new domain after construction. Throws if `name` is already registered.
@@ -98,17 +102,44 @@ Initialize all registered domains (or a subset). Runs in parallel. Individual do
98
102
 
99
103
  **Returns:** `Promise<void>`
100
104
 
101
- #### `suite.setContext(ctx)`
105
+ #### `suite.setContext(ctx, options?)`
102
106
 
103
- Merge `ctx` into the shared context and propagate to every registered domain manager.
107
+ Update the shared context and propagate to every registered domain manager.
104
108
 
105
109
  **Parameters:**
106
110
  - `ctx` (`OwnsuiteContext`)
111
+ - `options` (`SetContextOptions`, optional)
112
+ - `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
+ - `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
+
115
+ **Example:**
116
+ ```typescript
117
+ // Subject change: drop old context + re-fetch every domain
118
+ suite.setContext({ subjectId: newId }, { replace: true, refresh: true });
119
+ ```
107
120
 
108
121
  #### `suite.getContext(): OwnsuiteContext`
109
122
 
110
123
  Snapshot of current shared context.
111
124
 
125
+ #### `suite.errors(): Record<string, DomainError>`
126
+
127
+ Map of currently-errored domains to their `DomainError`. Empty if none are in error state. Use after `initialize()` to detect silent boot failures.
128
+
129
+ #### `suite.hasErrors(): boolean`
130
+
131
+ True if any domain is currently in `error` state.
132
+
133
+ #### `suite.destroy()`
134
+
135
+ Dispose of the suite: destroys every registered domain (which aborts in-flight adapter requests), clears the domain map, and unsubscribes every listener attached to the internal pubsub. Safe to call multiple times.
136
+
137
+ Subsequent method calls are best-effort no-ops (e.g., `initialize()` returns immediately, `setContext()` ignores the call). `registerDomain()` throws after destroy.
138
+
139
+ #### `suite.isDestroyed: boolean`
140
+
141
+ True after `destroy()` has been called.
142
+
112
143
  #### `suite.on(type, subscriber)`
113
144
 
114
145
  Subscribe to a specific event type.
@@ -177,7 +208,9 @@ Re-fetch the list. Same as `initialize` but re-entrant; accepts an adapter-speci
177
208
 
178
209
  #### `manager.getOne(id): Promise<TRow | null>`
179
210
 
180
- Fetch a single row by id. Does **not** mutate the list. Returns `null` on error and transitions the manager to `error` state.
211
+ Fetch a single row by id. Does **not** mutate the list and does **not** transition the domain to `error` on failure — a 404 for an un-owned row or a network blip on a read shouldn't invalidate a healthy list view. Returns `null` on any failure (including missing adapter). Emits `own:row:fetched` on success.
212
+
213
+ Callers that need error detail should wrap this method and inspect the adapter error themselves.
181
214
 
182
215
  #### `manager.create(data): Promise<TRow | null>`
183
216
 
@@ -190,15 +223,19 @@ Create a new row. On success, prepends the server-returned row to the list. On f
190
223
 
191
224
  #### `manager.update(id, data): Promise<TRow | null>`
192
225
 
193
- Update a row. Optimistically merges `data` into the existing row; on server failure the list is rolled back to its pre-call state. On success, the server-returned row replaces the optimistic one.
226
+ Update a row. Optimistically merges `data` into the existing row; on server failure the single row reverts to its pre-call value (other rows are untouched — including any added by an interleaved `refresh()`). On success, the server-returned row replaces the optimistic one.
227
+
228
+ 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.
194
229
 
195
230
  **Parameters:**
196
231
  - `id` (`string`)
197
232
  - `data` (`TUpdate`)
198
233
 
234
+ Mutations serialize per-manager — a `create/update/delete` that starts while another is in-flight queues behind it.
235
+
199
236
  #### `manager.delete(id): Promise<boolean>`
200
237
 
201
- Delete a row. Optimistically removes it from the list; on server failure the list is rolled back.
238
+ Delete a row. Optimistically removes it from the list; on server failure the single row is re-inserted at its original position (unless another op has since re-added it).
202
239
 
203
240
  **Returns:** `true` on success, `false` on failure.
204
241
 
@@ -214,13 +251,21 @@ Find a row by id in the current list without hitting the server.
214
251
 
215
252
  Swap or inspect the adapter at runtime.
216
253
 
217
- #### `manager.setContext(ctx)` / `manager.getContext()`
254
+ #### `manager.setContext(ctx)` / `manager.replaceContext(ctx)` / `manager.getContext()`
218
255
 
219
- Per-manager context. `Ownsuite.setContext()` propagates to every manager.
256
+ Per-manager context. `setContext` merges into the existing context; `replaceContext` replaces it wholesale. `Ownsuite.setContext()` propagates to every manager (with the same `{ replace }` option).
220
257
 
221
258
  #### `manager.reset()`
222
259
 
223
- Reset to `initializing` state.
260
+ Reset to `initializing` state. Aborts any in-flight reads or mutations (their completions become no-ops) and emits `domain:state:changed`.
261
+
262
+ #### `manager.destroy()`
263
+
264
+ Abort in-flight operations, drop the adapter reference, and mark the manager as destroyed. Subsequent method calls are best-effort no-ops. Usually invoked via `Ownsuite.destroy()`, but safe to call directly.
265
+
266
+ #### `manager.isDestroyed: boolean`
267
+
268
+ True after `destroy()` has been called.
224
269
 
225
270
  ---
226
271
 
@@ -239,12 +284,23 @@ interface OwnsuiteConfig {
239
284
  context?: OwnsuiteContext;
240
285
  domains?: Record<string, OwnsuiteDomainConfig>;
241
286
  autoInitialize?: boolean;
287
+ adapters?: {
288
+ auth?: AuthAdapter;
289
+ profile?: ProfileAdapter;
290
+ };
291
+ session?: {
292
+ storage?: SessionStorageType; // "local" | "session" | "memory" | SessionStorage
293
+ storageKey?: string; // default: "ownsuite:session"
294
+ };
242
295
  }
243
296
  ```
244
297
 
245
298
  - `context` — initial context passed to every adapter call.
246
299
  - `domains` — domain registry at construction time. Keys are arbitrary labels.
247
300
  - `autoInitialize` — fire-and-forget `initialize()` in the constructor. Default: `false`.
301
+ - `adapters.auth` — when provided, the suite builds `SessionManager` / `AuthManager` / `ProfileManager` and exposes them as `suite.session` / `suite.auth` / `suite.profile`. Without it, those properties are `null`.
302
+ - `adapters.profile` — optional but recommended companion. Without it, login still succeeds but the subject is not hydrated from `/me`.
303
+ - `session.storage` / `session.storageKey` — persistence config for the session. Ignored when no `adapters.auth` is provided.
248
304
 
249
305
  ### `OwnsuiteDomainConfig<TRow, TCreate, TUpdate>`
250
306
 
@@ -255,16 +311,26 @@ interface OwnsuiteDomainConfig<TRow, TCreate, TUpdate> {
255
311
  }
256
312
  ```
257
313
 
314
+ ### `SetContextOptions`
315
+
316
+ ```typescript
317
+ interface SetContextOptions {
318
+ replace?: boolean; // default: false — merge into existing context
319
+ refresh?: boolean; // default: false — fire refresh() on every domain
320
+ }
321
+ ```
322
+
258
323
  ### `OwnsuiteContext`
259
324
 
260
325
  ```typescript
261
326
  interface OwnsuiteContext {
262
327
  subjectId?: string;
328
+ signal?: AbortSignal; // manager-injected, per-call
263
329
  [key: string]: unknown;
264
330
  }
265
331
  ```
266
332
 
267
- Context passed to adapters. **`subjectId` is a hint only** — the server authoritatively resolves the owner from the authenticated JWT. The context object is the extension point for passing host-app data (correlation ids, feature flags, tenants) through adapter calls.
333
+ Context passed to adapters. **`subjectId` is a hint only** — the server authoritatively resolves the owner from the authenticated JWT. **`signal` is injected by the manager** on every call; adapters should forward it to `fetch()` for cancellation on `reset()`/`destroy()`/read-supersede. The context object is also the extension point for passing host-app data (correlation ids, feature flags, tenants) through adapter calls.
268
334
 
269
335
  ### `OwnedCollectionAdapter<TRow, TCreate, TUpdate>`
270
336
 
@@ -353,7 +419,16 @@ type OwnsuiteEventType =
353
419
  | "own:row:fetched"
354
420
  | "own:row:created"
355
421
  | "own:row:updated"
356
- | "own:row:deleted";
422
+ | "own:row:deleted"
423
+ // Account lifecycle — only emitted when adapters.auth is wired
424
+ | "auth:register"
425
+ | "auth:login"
426
+ | "auth:logout"
427
+ | "auth:session:changed"
428
+ | "auth:verification:required"
429
+ | "profile:updated"
430
+ | "oauth:linked"
431
+ | "oauth:unlinked";
357
432
  ```
358
433
 
359
434
  ### `OwnsuiteEvent`
@@ -383,9 +458,13 @@ interface MockAdapterOptions<TRow> {
383
458
  failOn?: { list?: boolean; getOne?: boolean; create?: boolean; update?: boolean; delete?: boolean };
384
459
  getRowId?: (row: TRow) => string;
385
460
  newId?: () => string;
461
+ /** Reject create payloads containing `model_id` (default: true). */
462
+ rejectClientId?: boolean;
386
463
  }
387
464
  ```
388
465
 
466
+ The mock adapter forwards `ctx.signal` — `delayMs` waits can be aborted mid-sleep so tests that assert on abort-supersede semantics run deterministically.
467
+
389
468
  ---
390
469
 
391
470
  ## Implementing a real adapter
@@ -393,27 +472,33 @@ interface MockAdapterOptions<TRow> {
393
472
  Point the adapter at your server's owner-scoped mount (typically `/api/<stack>/me/col/<entity>/...`). The server is responsible for `owner_id` enforcement — the client only talks to `/me/*`.
394
473
 
395
474
  ```typescript
396
- import type { OwnedCollectionAdapter } from "@marianmeres/ownsuite";
475
+ import type { OwnedCollectionAdapter, OwnsuiteContext } from "@marianmeres/ownsuite";
397
476
  import { HTTP_ERROR } from "@marianmeres/http-utils";
398
477
 
399
478
  export function createRestAdapter(stack: string, entity: string): OwnedCollectionAdapter {
400
479
  const base = `/api/${stack}/me/col/${entity}`;
401
- const json = async <T>(method: string, url: string, body?: unknown): Promise<T> => {
480
+ const json = async <T>(
481
+ method: string,
482
+ url: string,
483
+ ctx: OwnsuiteContext,
484
+ body?: unknown,
485
+ ): Promise<T> => {
402
486
  const res = await fetch(url, {
403
487
  method,
404
488
  headers: { "content-type": "application/json" },
405
489
  body: body === undefined ? undefined : JSON.stringify(body),
490
+ signal: ctx.signal, // forward manager-injected abort signal
406
491
  });
407
492
  if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
408
493
  return await res.json();
409
494
  };
410
495
  return {
411
- list: (_ctx) => json("GET", `${base}/mod`),
412
- getOne: (id, _ctx) => json("GET", `${base}/mod/${id}`),
413
- create: (data, _ctx) => json("POST", `${base}/mod`, data),
414
- update: (id, data, _ctx) => json("PUT", `${base}/mod/${id}`, data),
415
- delete: async (id, _ctx) => {
416
- await json("DELETE", `${base}/mod/${id}`);
496
+ list: (ctx) => json("GET", `${base}/mod`, ctx),
497
+ getOne: (id, ctx) => json("GET", `${base}/mod/${id}`, ctx),
498
+ create: (data, ctx) => json("POST", `${base}/mod`, ctx, data),
499
+ update: (id, data, ctx) => json("PUT", `${base}/mod/${id}`, ctx, data),
500
+ delete: async (id, ctx) => {
501
+ await json("DELETE", `${base}/mod/${id}`, ctx);
417
502
  return true;
418
503
  },
419
504
  };
@@ -421,3 +506,310 @@ export function createRestAdapter(stack: string, entity: string): OwnedCollectio
421
506
  ```
422
507
 
423
508
  The `@marianmeres/joy` admin SPA ships a reusable factory — `createOwnedCollectionAdapter()` in `src/routes/me/owned-collection-adapter.ts` — that implements exactly this shape.
509
+
510
+ ---
511
+
512
+ ## Account lifecycle (optional)
513
+
514
+ Attached automatically when `adapters.auth` is passed to `createOwnsuite`. See also the example at the top of [README.md](README.md).
515
+
516
+ ### `createStackAccountAuthAdapter(options?)`
517
+
518
+ Default `AuthAdapter` pointing at the `@marianmeres/stack-account` REST surface.
519
+
520
+ **Parameters:**
521
+ - `options` (`StackAccountAdapterOptions`, optional)
522
+ - `options.baseUrl` (`string`, optional) — mount path. Default: `"/api/account"`.
523
+ - `options.fetch` (`typeof fetch`, optional) — custom fetch (tests / SSR).
524
+
525
+ **Returns:** `AuthAdapter`
526
+
527
+ Endpoints targeted:
528
+
529
+ ```
530
+ POST {baseUrl}/register
531
+ POST {baseUrl}/login
532
+ POST {baseUrl}/logout
533
+ POST {baseUrl}/verify/resend
534
+ POST {baseUrl}/password/reset
535
+ POST {baseUrl}/password/change
536
+ DELETE {baseUrl}/me
537
+ GET {baseUrl}/oauth/{provider}/init
538
+ ```
539
+
540
+ ### `createStackAccountProfileAdapter(options?)`
541
+
542
+ Default `ProfileAdapter`.
543
+
544
+ **Parameters:** same `StackAccountAdapterOptions` as above.
545
+
546
+ **Returns:** `ProfileAdapter`
547
+
548
+ Endpoints targeted: `GET/PUT {baseUrl}/me`, `GET {baseUrl}/me/oauth`, `DELETE {baseUrl}/me/oauth/{provider}`.
549
+
550
+ ### `createMockAuthStore(init?)` / `createMockAuthAdapter(store)` / `createMockProfileAdapter(store)` / `verifyMockAccount(store, email)`
551
+
552
+ In-memory mock for tests and demos. `createMockAuthStore` builds a shared state object; the adapter factories close over it.
553
+
554
+ **`createMockAuthStore(init?)`** returns `MockAuthStore`. `init.requireVerifiedEmail` (default `false`) toggles the email-verification gate; `init.seed` can pre-populate accounts.
555
+
556
+ **`verifyMockAccount(store, email)`** marks an account as verified — stands in for the user clicking the link in a real verification email.
557
+
558
+ **Example:**
559
+ ```typescript
560
+ const store = createMockAuthStore({ requireVerifiedEmail: true });
561
+ const suite = createOwnsuite({
562
+ adapters: {
563
+ auth: createMockAuthAdapter(store),
564
+ profile: createMockProfileAdapter(store),
565
+ },
566
+ });
567
+ await suite.auth!.register({
568
+ email: "alice@example.com",
569
+ password: "mysecretpassword",
570
+ password_confirm: "mysecretpassword",
571
+ });
572
+ // suite.session!.get().status === "unverified"
573
+ verifyMockAccount(store, "alice@example.com");
574
+ await suite.auth!.login({ email: "alice@example.com", password: "mysecretpassword" });
575
+ // suite.session!.get().status === "authenticated"
576
+ ```
577
+
578
+ ---
579
+
580
+ ### `SessionManager`
581
+
582
+ Reactive session state + persistence. No HTTP. Attached as `suite.session`.
583
+
584
+ #### `session.subscribe(listener)` / `session.get()`
585
+
586
+ Svelte-compatible store over [`SessionState`](#sessionstate).
587
+
588
+ #### `session.getJwt(): string | null`
589
+
590
+ Current JWT, or `null` when anonymous.
591
+
592
+ #### `session.isAuthenticated` / `session.isUnverified` / `session.isAnonymous` (boolean getters)
593
+
594
+ Shorthand reads.
595
+
596
+ #### `session.setAuthenticated({ jwt, subject, expiresAt? })`
597
+
598
+ Enter the `authenticated` state. Normally called by `AuthManager`, not consumers.
599
+
600
+ #### `session.setUnverified(email)` / `session.clear()` / `session.patchSubject(patch)`
601
+
602
+ Mutation helpers — typically driven by `AuthManager` / `ProfileManager`.
603
+
604
+ #### `session.destroy()`
605
+
606
+ Clear storage and in-memory state. Called by `suite.destroy()`.
607
+
608
+ ---
609
+
610
+ ### `AuthManager`
611
+
612
+ Verbs only. Attached as `suite.auth`. No state of its own — results flow into `SessionManager`.
613
+
614
+ | Method | Purpose |
615
+ |---|---|
616
+ | `register({ email, password, password_confirm, roles?, extras? })` | 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. |
617
+ | `login({ email, password })` | Exchange credentials for a JWT. Session flips to `"authenticated"` on success, `"unverified"` if the server reports the gate. |
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. |
624
+ | `handleOAuthCallback()` | For `mode: "redirect"` apps, call from your callback route to extract the result from the URL (delegated to `adapter.handleOAuthCallback`). |
625
+
626
+ 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
+
628
+ ---
629
+
630
+ ### `ProfileManager`
631
+
632
+ Singleton `/me` manager. Attached as `suite.profile`.
633
+
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. |
642
+
643
+ ---
644
+
645
+ ### `openOAuthPopup(url, options?)`
646
+
647
+ Open an OAuth popup and await the server's `postMessage`.
648
+
649
+ **Parameters:**
650
+ - `url` (`string`) — server OAuth init URL.
651
+ - `options` (`OpenOAuthPopupOptions`, optional)
652
+ - `options.host` (`PopupWindowHost`, optional) — shim for tests. Default: `globalThis`.
653
+ - `options.closedPollMs` (`number`, optional) — detect popup-closed-without-message. Default: `500`.
654
+ - `options.timeoutMs` (`number`, optional) — hard timeout. Default: `0` (disabled).
655
+ - `options.expectedOrigin` (`string`, optional) — restrict accepted message origins.
656
+ - `options.features` (`string`, optional) — popup window features string.
657
+
658
+ **Returns:** `Promise<OAuthPopupMessage>` — resolves with `{ type: "oauth_login_success", jwt, email, roles?, ... }` or `{ type: "oauth_link_success", provider }`. Rejects with `OAUTH_POPUP_BLOCKED` / `OAUTH_POPUP_CLOSED` / `OAUTH_POPUP_TIMEOUT` or the server-supplied error message.
659
+
660
+ ---
661
+
662
+ ## Account-lifecycle types
663
+
664
+ ### `SessionState`
665
+
666
+ ```typescript
667
+ interface SessionState {
668
+ status: "anonymous" | "authenticated" | "unverified";
669
+ subject: SessionSubject | null;
670
+ jwt: string | null;
671
+ expiresAt: number | null; // unix seconds
672
+ }
673
+ ```
674
+
675
+ ### `SessionSubject`
676
+
677
+ ```typescript
678
+ interface SessionSubject {
679
+ id: string;
680
+ email: string;
681
+ roles: string[];
682
+ isVerified: boolean;
683
+ hasPassword: boolean; // OAuth-only accounts: false
684
+ }
685
+ ```
686
+
687
+ ### `SessionStatus` / `SessionStorage` / `SessionStorageType`
688
+
689
+ ```typescript
690
+ type SessionStatus = "anonymous" | "authenticated" | "unverified";
691
+
692
+ interface SessionStorage {
693
+ get(key: string): string | null;
694
+ set(key: string, value: string): void;
695
+ del(key: string): void;
696
+ }
697
+
698
+ type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
699
+ ```
700
+
701
+ ### `AuthTokenResult`
702
+
703
+ ```typescript
704
+ 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
712
+ }
713
+ ```
714
+
715
+ ### `ProfileResult`
716
+
717
+ ```typescript
718
+ interface ProfileResult {
719
+ email: string;
720
+ roles: string[];
721
+ isVerified: boolean;
722
+ hasPassword: boolean;
723
+ oauthConnections: OAuthConnection[];
724
+ }
725
+ ```
726
+
727
+ ### `OAuthProvider` / `OAuthAction` / `OAuthInitOptions` / `OAuthConnection`
728
+
729
+ ```typescript
730
+ type OAuthProvider = "google" | "facebook" | "apple" | "twitter";
731
+ type OAuthAction = "login" | "link";
732
+
733
+ interface OAuthInitOptions {
734
+ action: OAuthAction;
735
+ redirect?: string;
736
+ lang?: string;
737
+ mode?: "popup" | "redirect"; // default "popup"
738
+ }
739
+
740
+ interface OAuthConnection {
741
+ provider: OAuthProvider;
742
+ display_name?: string;
743
+ avatar_url?: string;
744
+ email?: string;
745
+ }
746
+ ```
747
+
748
+ ### `AuthAdapter`
749
+
750
+ ```typescript
751
+ interface AuthAdapter {
752
+ register(input, ctx): Promise<AuthTokenResult>;
753
+ login(input, ctx): Promise<AuthTokenResult>;
754
+ logout(ctx): Promise<void>;
755
+ oauthInitUrl(provider, opts, ctx): string;
756
+ handleOAuthCallback?(ctx): Promise<AuthTokenResult>;
757
+ resendVerification(input, ctx): Promise<void>;
758
+ requestPasswordReset(input, ctx): Promise<void>;
759
+ changePassword(input, ctx): Promise<void>;
760
+ deleteAccount(input, ctx): Promise<{ deleted: true }>;
761
+ }
762
+ ```
763
+
764
+ Parameter shapes match [`src/types/auth.ts`](src/types/auth.ts). Implementations forward `ctx.jwt` as `Authorization: Bearer <jwt>` and `ctx.signal` to `fetch()`.
765
+
766
+ ### `ProfileAdapter`
767
+
768
+ ```typescript
769
+ interface ProfileAdapter {
770
+ get(ctx): Promise<ProfileResult>;
771
+ update(input: { email?, current_password? }, ctx): Promise<ProfileResult>;
772
+ listOAuth(ctx): Promise<OAuthConnection[]>;
773
+ unlinkOAuth(provider, ctx): Promise<void>;
774
+ }
775
+ ```
776
+
777
+ ### `StackAccountAdapterOptions`
778
+
779
+ ```typescript
780
+ interface StackAccountAdapterOptions {
781
+ baseUrl?: string; // default "/api/account"
782
+ fetch?: typeof fetch;
783
+ }
784
+ ```
785
+
786
+ ### `OpenOAuthPopupOptions` / `PopupWindowHost` / `OAuthPopupMessage`
787
+
788
+ See [src/oauth/popup.ts](src/oauth/popup.ts) for full definitions. `OAuthPopupMessage` is a union of `OAuthPopupLoginMessage` (`{ type: "oauth_login_success", jwt, email, roles?, ... }`) and `OAuthPopupLinkMessage` (`{ type: "oauth_link_success", provider }`). Errors posted by the server (`{ type: "oauth_error", error }`) cause the promise to reject.
789
+
790
+ ### `MockAuthStore`
791
+
792
+ ```typescript
793
+ interface MockAuthStore {
794
+ accounts: Map<string, MockAccount>;
795
+ requireVerifiedEmail: boolean;
796
+ jwtsByEmail: Map<string, string>;
797
+ }
798
+ ```
799
+
800
+ ---
801
+
802
+ ## Account-lifecycle events
803
+
804
+ Emitted on the shared pubsub. Each payload has a `timestamp` (ms).
805
+
806
+ | Event | Payload |
807
+ |---|---|
808
+ | `auth:register` | `{ email, requiresVerification }` |
809
+ | `auth:login` | `{ email }` |
810
+ | `auth:logout` | `{ subjectId? }` |
811
+ | `auth:session:changed` | `{ session: SessionState }` |
812
+ | `auth:verification:required` | `{ email }` (fired when status flips to `"unverified"`) |
813
+ | `profile:updated` | `{ email }` |
814
+ | `oauth:linked` | `{ connection: OAuthConnection }` |
815
+ | `oauth:unlinked` | `{ provider: OAuthProvider }` |
package/README.md CHANGED
@@ -16,11 +16,79 @@ Ownsuite gives front-end applications a uniform way to read, create, update and
16
16
  ## Features
17
17
 
18
18
  - **Generic domain managers** — register any owner-scoped collection by name; no hard-coded domain list
19
- - **Optimistic updates** — UI mutates immediately; the manager rolls back on server failure
19
+ - **Optimistic updates** with per-row rollback — UI mutates immediately; failed ops revert just the affected row
20
+ - **Race-safe concurrency** — mutations serialize; reads abort-supersede (a newer `refresh()` aborts an older one)
21
+ - **AbortSignal plumbing** — every adapter call receives a per-operation signal, wired to `destroy()` and route-change cancellation
20
22
  - **Svelte-compatible stores** — every domain exposes a `subscribe()` method
21
23
  - **Adapter pattern** — plug in any HTTP/WebSocket/mock transport
22
24
  - **Event system** — subscribe to list fetches, row CRUD, and lifecycle transitions
23
- - **Mock adapter** — in-memory fixture for tests, with configurable failure injection
25
+ - **Mock adapter** — in-memory fixture for tests, with configurable failure injection and latency
26
+ - **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
28
+
29
+ ## Authentication (optional)
30
+
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.
32
+
33
+ ```typescript
34
+ import {
35
+ createOwnsuite,
36
+ createStackAccountAuthAdapter,
37
+ createStackAccountProfileAdapter,
38
+ } from "@marianmeres/ownsuite";
39
+
40
+ 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
+ },
52
+ });
53
+
54
+ // Observable session — UI subscribes to this for logged-in state.
55
+ 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");
59
+ });
60
+
61
+ // Register → server requires email verification (default gate in stack-account)
62
+ await suite.auth!.register({
63
+ email: "alice@example.com",
64
+ password: "mysecretpassword",
65
+ password_confirm: "mysecretpassword",
66
+ });
67
+ // suite.session!.get().status === "unverified"
68
+
69
+ // After the user clicks the email link and the server flips isVerified:
70
+ await suite.auth!.login({ email: "alice@example.com", password: "mysecretpassword" });
71
+ // suite.session!.get().status === "authenticated"
72
+ // Every registered owner-scoped domain is re-initialized with the new JWT.
73
+
74
+ // OAuth popup flow — resolves when the callback page postMessages back
75
+ await suite.auth!.initiateOAuth("google", { action: "login" });
76
+
77
+ // Profile edit — changing email resets isVerified server-side and dispatches
78
+ // a new verification email. Session subject is patched in place.
79
+ await suite.profile!.update({
80
+ email: "renamed@example.com",
81
+ current_password: "mysecretpassword",
82
+ });
83
+
84
+ // Logout — revokes JWT server-side (via stack-account's jti deletion) and
85
+ // clears local session storage. Owner-scoped domains reset to initializing.
86
+ await suite.auth!.logout();
87
+ ```
88
+
89
+ Session state is persisted through a pluggable `SessionStorage` backend (`"local"` / `"session"` / `"memory"` / custom object with `get`/`set`/`del`). Expired stored sessions are discarded on construction so a reload after the JWT lapses starts anonymous.
90
+
91
+ Tests can use the in-memory mock adapters (`createMockAuthAdapter`, `createMockProfileAdapter`, `createMockAuthStore`, `verifyMockAccount`) and the injectable popup host for deterministic OAuth dances.
24
92
 
25
93
  ## Installation
26
94
 
@@ -65,6 +133,12 @@ suite.domain("orders").subscribe((s) => {
65
133
  await suite.domain("orders").create({ data: { total: 99 } });
66
134
  await suite.domain("orders").update(id, { data: { total: 120 } });
67
135
  await suite.domain("orders").delete(id);
136
+
137
+ // 6. Detect silent boot failures
138
+ if (suite.hasErrors()) console.warn("boot errors:", suite.errors());
139
+
140
+ // 7. Clean up on teardown (SPA unmount, tenant switch, test harness)
141
+ suite.destroy();
68
142
  ```
69
143
 
70
144
  ## Architecture at a glance
@@ -103,6 +177,16 @@ await suite.domain("notes").update("1", { data: { label: "new" } });
103
177
 
104
178
  See [API.md](API.md) for complete API documentation.
105
179
 
180
+ ## Breaking changes in 2.0.0
181
+
182
+ - `getOne()` no longer transitions the domain to `error` on failure — it returns `null` quietly.
183
+ - `update(id, ...)` for an id absent from the cached list no longer prepends a phantom row — the server update is still applied server-side (event emitted), but the list stays unchanged. Call `refresh()` to surface it.
184
+ - `createMockOwnedCollectionAdapter` rejects `create` payloads containing a client-supplied `model_id` by default (opt out with `rejectClientId: false`).
185
+ - Rollback on failed `update`/`delete` is now per-row, not whole-list. Interleaved refresh results are preserved.
186
+ - `reset()` now emits `domain:state:changed`.
187
+
188
+ See [AGENTS.md](AGENTS.md) "Breaking changes in 2.0.0" for the full list and migration notes.
189
+
106
190
  ## License
107
191
 
108
192
  [MIT](LICENSE)