@marianmeres/ownsuite 2.0.0 → 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/AGENTS.md CHANGED
@@ -19,9 +19,12 @@ entry: "./src/mod.ts"
19
19
 
20
20
  Client-side helper library for **owner-scoped** UIs. Generic domain managers for CRUD over collections where every row is implicitly filtered to the authenticated subject by the server. Mirrors the shape of `@marianmeres/ecsuite` but applies to arbitrary owner-scoped collections instead of hard-coded e-commerce domains.
21
21
 
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
+
22
24
  Pairs with:
23
25
  - **`@marianmeres/collection`** — `ownerIdScope` route hook (read-side owner enforcement).
24
26
  - **`@marianmeres/stack-common`** — `ownsuiteOptions()` server helper for mounting `/me/*` routes.
27
+ - **`@marianmeres/stack-account`** — default adapters (`createStackAccountAuthAdapter`, `createStackAccountProfileAdapter`) target its REST surface.
25
28
 
26
29
  ## Architecture
27
30
 
@@ -70,17 +73,28 @@ src/
70
73
  ├── types/
71
74
  │ ├── mod.ts
72
75
  │ ├── state.ts # DomainState/Wrapper/Error, OwnsuiteContext, OwnedCollectionState
73
- │ ├── events.ts # OwnsuiteEventType, OwnsuiteEvent union, per-event interfaces
74
- └── adapter.ts # OwnedCollectionAdapter, OwnedListResult, OwnedRowResult
76
+ │ ├── events.ts # OwnsuiteEventType, OwnsuiteEvent union (incl. auth:* / profile:* / oauth:*)
77
+ ├── adapter.ts # OwnedCollectionAdapter, OwnedListResult, OwnedRowResult
78
+ │ └── auth.ts # AuthAdapter, ProfileAdapter, SessionState/Subject/Status, OAuth*
75
79
  ├── domains/
76
80
  │ ├── mod.ts
77
81
  │ ├── base.ts # BaseDomainManager abstract class (mirrors ecsuite)
78
- └── owned-collection.ts # OwnedCollectionManager<TRow, TCreate, TUpdate>
82
+ ├── owned-collection.ts # OwnedCollectionManager<TRow, TCreate, TUpdate>
83
+ │ ├── session.ts # SessionManager + pluggable SessionStorage resolver
84
+ │ ├── auth.ts # AuthManager (register/login/logout/OAuth/verify/delete)
85
+ │ └── profile.ts # ProfileManager (/me singleton)
86
+ ├── oauth/
87
+ │ └── popup.ts # openOAuthPopup + injectable PopupWindowHost for tests
79
88
  └── adapters/
80
89
  ├── mod.ts
81
- └── mock.ts # createMockOwnedCollectionAdapter for tests
90
+ ├── mock.ts # createMockOwnedCollectionAdapter
91
+ ├── mock-auth.ts # createMockAuthAdapter / createMockProfileAdapter / createMockAuthStore
92
+ └── stack-account.ts # createStackAccountAuthAdapter / createStackAccountProfileAdapter
82
93
  tests/
83
- └── ownsuite.test.ts
94
+ ├── ownsuite.test.ts # core suite + OwnedCollectionManager
95
+ ├── concurrency.test.ts # critical-invariant coverage (abort-supersede, rollback, etc.)
96
+ ├── auth.test.ts # AuthManager / ProfileManager / SessionManager
97
+ └── oauth-popup.test.ts # openOAuthPopup message / timeout / close / origin semantics
84
98
  ```
85
99
 
86
100
  ## Key Exports
@@ -110,8 +124,65 @@ export type {
110
124
  // Mock adapter (for tests)
111
125
  export { createMockOwnedCollectionAdapter } from "./adapters/mod.ts";
112
126
  export type { MockAdapterOptions } from "./adapters/mod.ts";
127
+
128
+ // Account lifecycle (optional — attached when adapters.auth is supplied)
129
+ export { SessionManager, AuthManager, ProfileManager } from "./domains/mod.ts";
130
+ export type {
131
+ AuthAdapter, ProfileAdapter, AuthTokenResult, ProfileResult,
132
+ SessionState, SessionSubject, SessionStatus,
133
+ SessionStorage, SessionStorageType,
134
+ OAuthConnection, OAuthProvider, OAuthInitOptions, OAuthAction,
135
+ } from "./types/mod.ts";
136
+
137
+ // OAuth popup helper
138
+ export { openOAuthPopup } from "./oauth/popup.ts";
139
+ export type {
140
+ OAuthPopupMessage, OAuthPopupLoginMessage, OAuthPopupLinkMessage,
141
+ OpenOAuthPopupOptions, PopupWindowHost, PopupWindowHandle,
142
+ } from "./oauth/popup.ts";
143
+
144
+ // Default stack-account adapters
145
+ export {
146
+ createStackAccountAuthAdapter,
147
+ createStackAccountProfileAdapter,
148
+ } from "./adapters/mod.ts";
149
+ export type { StackAccountAdapterOptions } from "./adapters/mod.ts";
150
+
151
+ // Mock auth adapter (for tests)
152
+ export {
153
+ createMockAuthAdapter, createMockProfileAdapter,
154
+ createMockAuthStore, verifyMockAccount,
155
+ } from "./adapters/mod.ts";
156
+ export type { MockAuthStore } from "./adapters/mod.ts";
113
157
  ```
114
158
 
159
+ ## Account lifecycle (optional)
160
+
161
+ When `adapters.auth` is supplied, `createOwnsuite` instantiates three extra managers and attaches them as readonly suite properties:
162
+
163
+ - **`suite.session: SessionManager`** — reactive `{ status, subject, jwt, expiresAt }` persisted via pluggable `SessionStorage` (`"local"` / `"session"` / `"memory"` / custom object). Hydrates on construction; discards expired sessions. Exposes `subscribe` (Svelte-compatible), `get()`, and state-mutation methods (`setAuthenticated`, `setUnverified`, `clear`, `patchSubject`). Writes are driven by `AuthManager`, not consumers directly.
164
+
165
+ - **`suite.auth: AuthManager`** — verbs only, no state. `register` / `login` / `logout` / `resendVerification` / `requestPasswordReset` / `changePassword` / `deleteAccount` / `initiateOAuth` (`mode: "popup" | "redirect"`) / `handleOAuthCallback` (redirect mode). Each call pipes the result into the session and fires an `onIdentityChanged` hook that resets + re-initializes every owner-scoped domain with the fresh context.
166
+
167
+ - **`suite.profile: ProfileManager`** — singleton (one-row) `/me` CRUD. `fetch` / `update` / `listOAuth` / `unlinkOAuth`. Every successful fetch/update patches the session subject in place so consumers reading `suite.session` see email / roles / verification / connections without a second fetch. Update emits `profile:updated`.
168
+
169
+ The session subscribes to its own store and propagates `ctx.jwt` + `ctx.subjectId` into every registered owner-scoped domain automatically — authentication changes propagate without any manual wiring from consumers.
170
+
171
+ ### Auth events (emitted on the shared pubsub)
172
+
173
+ - `auth:register` — `{ email, requiresVerification }`
174
+ - `auth:login` — `{ email }`
175
+ - `auth:logout` — `{ subjectId? }`
176
+ - `auth:session:changed` — `{ session: SessionState }`
177
+ - `auth:verification:required` — `{ email }` (fired when `status` transitions to `"unverified"`)
178
+ - `profile:updated` — `{ email }`
179
+ - `oauth:linked` — `{ connection }`
180
+ - `oauth:unlinked` — `{ provider }`
181
+
182
+ ### OAuth popup protocol
183
+
184
+ `suite.auth.initiateOAuth(provider, opts)` with `mode: "popup"` opens a popup at the server's `/oauth/{provider}/init` URL and awaits a `postMessage` from the server's callback page (`{ type: "oauth_login_success" | "oauth_link_success" | "oauth_error", ... }`). For `mode: "redirect"` the top window navigates and the app's callback page calls `suite.auth.handleOAuthCallback()` on mount.
185
+
115
186
  ## State Machine
116
187
 
117
188
  ```
@@ -290,13 +361,15 @@ dev:
290
361
  ## Testing
291
362
 
292
363
  ```bash
293
- deno task test # run all tests (26 tests across ownsuite.test.ts + concurrency.test.ts)
364
+ deno task test # run all tests (44 tests across 4 files)
294
365
  deno task test:watch # watch mode
295
366
  ```
296
367
 
297
- `tests/concurrency.test.ts` covers the critical invariants: concurrent
298
- mutations, abort-supersede, getOne-not-setting-error, phantom-row
299
- prevention, destroy semantics, and the errors()/hasErrors() helpers.
368
+ Coverage by file:
369
+ - `tests/ownsuite.test.ts` — core suite + `OwnedCollectionManager` CRUD, events, rollback.
370
+ - `tests/concurrency.test.ts` — critical invariants: concurrent mutations, abort-supersede, `getOne` not setting error, phantom-row prevention, destroy semantics, `errors()`/`hasErrors()` helpers.
371
+ - `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.
372
+ - `tests/oauth-popup.test.ts` — `openOAuthPopup` message / timeout / popup-closed / origin-mismatch semantics via injectable `PopupWindowHost`.
300
373
 
301
374
  ## Build & Publish
302
375
 
@@ -383,6 +456,12 @@ Non-breaking additions: `suite.destroy()`, `suite.errors()`,
383
456
  `suite.hasErrors()`, `suite.setContext(ctx, { replace, refresh })`,
384
457
  `manager.isDestroyed`, `manager.replaceContext(ctx)`.
385
458
 
459
+ **Account lifecycle managers (opt-in addition).** `suite.auth` /
460
+ `suite.session` / `suite.profile` attach automatically when
461
+ `adapters.auth` is passed to `createOwnsuite`. Existing owner-scoped
462
+ CRUD consumers see no change unless they opt in. Full surface is
463
+ described in the "Account lifecycle (optional)" section above.
464
+
386
465
  ## Differences from `@marianmeres/ecsuite`
387
466
 
388
467
  | Aspect | ecsuite | ownsuite |
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.
@@ -280,12 +284,23 @@ interface OwnsuiteConfig {
280
284
  context?: OwnsuiteContext;
281
285
  domains?: Record<string, OwnsuiteDomainConfig>;
282
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
+ };
283
295
  }
284
296
  ```
285
297
 
286
298
  - `context` — initial context passed to every adapter call.
287
299
  - `domains` — domain registry at construction time. Keys are arbitrary labels.
288
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.
289
304
 
290
305
  ### `OwnsuiteDomainConfig<TRow, TCreate, TUpdate>`
291
306
 
@@ -404,7 +419,16 @@ type OwnsuiteEventType =
404
419
  | "own:row:fetched"
405
420
  | "own:row:created"
406
421
  | "own:row:updated"
407
- | "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";
408
432
  ```
409
433
 
410
434
  ### `OwnsuiteEvent`
@@ -482,3 +506,310 @@ export function createRestAdapter(stack: string, entity: string): OwnedCollectio
482
506
  ```
483
507
 
484
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
@@ -24,6 +24,71 @@ Ownsuite gives front-end applications a uniform way to read, create, update and
24
24
  - **Event system** — subscribe to list fetches, row CRUD, and lifecycle transitions
25
25
  - **Mock adapter** — in-memory fixture for tests, with configurable failure injection and latency
26
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.
27
92
 
28
93
  ## Installation
29
94
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @module adapters/mock-auth
3
+ *
4
+ * In-memory mock implementations of AuthAdapter and ProfileAdapter.
5
+ * Drives the unit tests for AuthManager / ProfileManager / SessionManager
6
+ * without a real server. Consumers can also use it to build demos or
7
+ * storybooks that exercise the full suite.
8
+ *
9
+ * Deliberately small: everything is held in a shared `Store` object the
10
+ * adapters close over, so tests can peek at or mutate state directly.
11
+ */
12
+ import type { AuthAdapter, OAuthConnection, ProfileAdapter } from "../types/mod.js";
13
+ interface MockAccount {
14
+ email: string;
15
+ password: string;
16
+ roles: string[];
17
+ isVerified: boolean;
18
+ hasPassword: boolean;
19
+ oauthConnections: OAuthConnection[];
20
+ }
21
+ export interface MockAuthStore {
22
+ accounts: Map<string, MockAccount>;
23
+ /** If true, register/login return requiresVerification=true until the
24
+ * email is explicitly verified via `verifyMockAccount()`. */
25
+ requireVerifiedEmail: boolean;
26
+ /** Last-issued JWT per email (just a synthetic string). */
27
+ jwtsByEmail: Map<string, string>;
28
+ }
29
+ export declare function createMockAuthStore(init?: {
30
+ requireVerifiedEmail?: boolean;
31
+ seed?: MockAccount[];
32
+ }): MockAuthStore;
33
+ export declare function createMockAuthAdapter(store: MockAuthStore): AuthAdapter;
34
+ export declare function createMockProfileAdapter(store: MockAuthStore): ProfileAdapter;
35
+ /** Test helper — mark a mock account as verified as if the user had clicked
36
+ * the link in the verification email. */
37
+ export declare function verifyMockAccount(store: MockAuthStore, email: string): void;
38
+ export {};