@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 +88 -9
- package/API.md +332 -1
- package/README.md +65 -0
- package/dist/adapters/mock-auth.d.ts +38 -0
- package/dist/adapters/mock-auth.js +237 -0
- package/dist/adapters/mod.d.ts +2 -0
- package/dist/adapters/mod.js +2 -0
- package/dist/adapters/stack-account.d.ts +38 -0
- package/dist/adapters/stack-account.js +149 -0
- package/dist/domains/auth.d.ts +83 -0
- package/dist/domains/auth.js +211 -0
- package/dist/domains/mod.d.ts +3 -0
- package/dist/domains/mod.js +3 -0
- package/dist/domains/profile.d.ts +62 -0
- package/dist/domains/profile.js +170 -0
- package/dist/domains/session.d.ts +73 -0
- package/dist/domains/session.js +226 -0
- package/dist/mod.d.ts +1 -0
- package/dist/mod.js +1 -0
- package/dist/oauth/popup.d.ts +64 -0
- package/dist/oauth/popup.js +104 -0
- package/dist/ownsuite.d.ts +21 -0
- package/dist/ownsuite.js +86 -0
- package/dist/types/auth.d.ts +162 -0
- package/dist/types/auth.js +17 -0
- package/dist/types/events.d.ts +41 -2
- package/dist/types/mod.d.ts +1 -0
- package/dist/types/mod.js +1 -0
- package/dist/types/state.d.ts +7 -0
- package/package.json +1 -1
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
|
|
74
|
-
│
|
|
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
|
-
│
|
|
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
|
-
|
|
90
|
+
├── mock.ts # createMockOwnedCollectionAdapter
|
|
91
|
+
├── mock-auth.ts # createMockAuthAdapter / createMockProfileAdapter / createMockAuthStore
|
|
92
|
+
└── stack-account.ts # createStackAccountAuthAdapter / createStackAccountProfileAdapter
|
|
82
93
|
tests/
|
|
83
|
-
|
|
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 (
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
prevention, destroy semantics,
|
|
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 {};
|