@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/AGENTS.md +206 -21
- package/API.md +410 -18
- package/README.md +86 -2
- package/dist/adapters/mock-auth.d.ts +38 -0
- package/dist/adapters/mock-auth.js +237 -0
- package/dist/adapters/mock.d.ts +10 -3
- package/dist/adapters/mock.js +79 -25
- 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/base.d.ts +66 -10
- package/dist/domains/base.js +165 -13
- package/dist/domains/mod.d.ts +3 -0
- package/dist/domains/mod.js +3 -0
- package/dist/domains/owned-collection.d.ts +29 -4
- package/dist/domains/owned-collection.js +240 -120
- 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 +58 -5
- package/dist/ownsuite.js +178 -10
- package/dist/types/adapter.d.ts +4 -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 +17 -0
- package/docs/future-improvements.md +81 -0
- package/package.json +15 -6
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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>(
|
|
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: (
|
|
412
|
-
getOne: (id,
|
|
413
|
-
create: (data,
|
|
414
|
-
update: (id, data,
|
|
415
|
-
delete: async (id,
|
|
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;
|
|
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)
|