@marianmeres/ownsuite 2.2.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +68 -34
- package/API.md +143 -115
- package/README.md +32 -38
- package/dist/adapters/stack-account.js +69 -5
- package/dist/domains/session.js +10 -5
- package/dist/oauth/popup.js +1 -1
- package/dist/ownsuite.js +1 -3
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -22,6 +22,7 @@ Client-side helper library for **owner-scoped** UIs. Generic domain managers for
|
|
|
22
22
|
**Also (opt-in)** provides the blessed account-lifecycle surface for apps built on `@marianmeres/stack-account`: `suite.auth` (register / login / logout / OAuth init / password reset / delete account), `suite.profile` (`/me` CRUD + OAuth link list), `suite.session` (reactive JWT + subject, pluggable storage). Pass an `AuthAdapter` to `createOwnsuite({ adapters: { auth } })` to attach them.
|
|
23
23
|
|
|
24
24
|
Pairs with:
|
|
25
|
+
|
|
25
26
|
- **`@marianmeres/collection`** — `ownerIdScope` route hook (read-side owner enforcement).
|
|
26
27
|
- **`@marianmeres/stack-common`** — `ownsuiteOptions()` server helper for mounting `/me/*` routes.
|
|
27
28
|
- **`@marianmeres/stack-account`** — default adapters (`createStackAccountAuthAdapter`, `createStackAccountProfileAdapter`) target its REST surface.
|
|
@@ -101,9 +102,11 @@ tests/
|
|
|
101
102
|
|
|
102
103
|
```typescript
|
|
103
104
|
// Main
|
|
104
|
-
export {
|
|
105
|
+
export { createOwnsuite, Ownsuite } from "./ownsuite.ts";
|
|
105
106
|
export type {
|
|
106
|
-
OwnsuiteConfig,
|
|
107
|
+
OwnsuiteConfig,
|
|
108
|
+
OwnsuiteDomainConfig,
|
|
109
|
+
SetContextOptions,
|
|
107
110
|
} from "./ownsuite.ts";
|
|
108
111
|
|
|
109
112
|
// Domain managers
|
|
@@ -112,13 +115,25 @@ export type { BaseDomainOptions, OwnedCollectionManagerOptions } from "./domains
|
|
|
112
115
|
|
|
113
116
|
// Types
|
|
114
117
|
export type {
|
|
115
|
-
DomainError,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
OwnedCollectionAdapter,
|
|
118
|
+
DomainError,
|
|
119
|
+
DomainName,
|
|
120
|
+
DomainState,
|
|
121
|
+
DomainStateWrapper,
|
|
122
|
+
ErrorEvent,
|
|
123
|
+
ListFetchedEvent,
|
|
124
|
+
OwnedCollectionAdapter,
|
|
125
|
+
OwnedCollectionState,
|
|
126
|
+
OwnedListResult,
|
|
127
|
+
OwnedRowResult,
|
|
128
|
+
OwnsuiteContext,
|
|
129
|
+
OwnsuiteEvent,
|
|
130
|
+
OwnsuiteEventType,
|
|
131
|
+
RowCreatedEvent,
|
|
132
|
+
RowDeletedEvent,
|
|
133
|
+
RowFetchedEvent,
|
|
134
|
+
RowUpdatedEvent,
|
|
135
|
+
StateChangedEvent,
|
|
136
|
+
SyncedEvent,
|
|
122
137
|
} from "./types/mod.ts";
|
|
123
138
|
|
|
124
139
|
// Mock adapter (for tests)
|
|
@@ -126,19 +141,32 @@ export { createMockOwnedCollectionAdapter } from "./adapters/mod.ts";
|
|
|
126
141
|
export type { MockAdapterOptions } from "./adapters/mod.ts";
|
|
127
142
|
|
|
128
143
|
// Account lifecycle (optional — attached when adapters.auth is supplied)
|
|
129
|
-
export {
|
|
144
|
+
export { AuthManager, ProfileManager, SessionManager } from "./domains/mod.ts";
|
|
130
145
|
export type {
|
|
131
|
-
AuthAdapter,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
OAuthConnection,
|
|
146
|
+
AuthAdapter,
|
|
147
|
+
AuthTokenResult,
|
|
148
|
+
OAuthAction,
|
|
149
|
+
OAuthConnection,
|
|
150
|
+
OAuthInitOptions,
|
|
151
|
+
OAuthProvider,
|
|
152
|
+
ProfileAdapter,
|
|
153
|
+
ProfileResult,
|
|
154
|
+
SessionState,
|
|
155
|
+
SessionStatus,
|
|
156
|
+
SessionStorage,
|
|
157
|
+
SessionStorageType,
|
|
158
|
+
SessionSubject,
|
|
135
159
|
} from "./types/mod.ts";
|
|
136
160
|
|
|
137
161
|
// OAuth popup helper
|
|
138
162
|
export { openOAuthPopup } from "./oauth/popup.ts";
|
|
139
163
|
export type {
|
|
140
|
-
|
|
141
|
-
|
|
164
|
+
OAuthPopupLinkMessage,
|
|
165
|
+
OAuthPopupLoginMessage,
|
|
166
|
+
OAuthPopupMessage,
|
|
167
|
+
OpenOAuthPopupOptions,
|
|
168
|
+
PopupWindowHandle,
|
|
169
|
+
PopupWindowHost,
|
|
142
170
|
} from "./oauth/popup.ts";
|
|
143
171
|
|
|
144
172
|
// Default stack-account adapters
|
|
@@ -150,8 +178,10 @@ export type { StackAccountAdapterOptions } from "./adapters/mod.ts";
|
|
|
150
178
|
|
|
151
179
|
// Mock auth adapter (for tests)
|
|
152
180
|
export {
|
|
153
|
-
createMockAuthAdapter,
|
|
154
|
-
createMockAuthStore,
|
|
181
|
+
createMockAuthAdapter,
|
|
182
|
+
createMockAuthStore,
|
|
183
|
+
createMockProfileAdapter,
|
|
184
|
+
verifyMockAccount,
|
|
155
185
|
} from "./adapters/mod.ts";
|
|
156
186
|
export type { MockAccount, MockAuthStore } from "./adapters/mod.ts";
|
|
157
187
|
|
|
@@ -211,7 +241,7 @@ Triggered by `initialize()`, `refresh()`, `create()`, `update()`, `delete()` on
|
|
|
211
241
|
|
|
212
242
|
4. **`initialize()` never rejects.** Per-domain errors land in that domain's `error` state; the top-level promise resolves. Use `suite.hasErrors()` / `suite.errors()` to detect failed boots, or subscribe to `domain:error`.
|
|
213
243
|
|
|
214
|
-
5. **Optimistic updates roll back per-row on failure.** `update` mutates the single target row; on error that row reverts to its pre-call value. `delete` removes the target row; on error it is re-inserted at its original position (unless another op has since re-added it). `create` does NOT optimistically insert. Rollback reads the
|
|
244
|
+
5. **Optimistic updates roll back per-row on failure.** `update` mutates the single target row; on error that row reverts to its pre-call value. `delete` removes the target row; on error it is re-inserted at its original position (unless another op has since re-added it). `create` does NOT optimistically insert. Rollback reads the _live_ store so an interleaved `refresh()` that brought new rows is preserved.
|
|
215
245
|
|
|
216
246
|
6. **`OwnsuiteContext.subjectId` is a hint, not authorization.** The server is authoritative. Setting it client-side has no security effect. When subject changes, call `suite.setContext(ctx, { replace: true, refresh: true })` to clear stale per-subject caches.
|
|
217
247
|
|
|
@@ -259,7 +289,7 @@ suite.domain("orders").subscribe((s) => {
|
|
|
259
289
|
|
|
260
290
|
```typescript
|
|
261
291
|
suite.on("own:row:created", (e) => {/* e.rowId, e.domain, e.timestamp */});
|
|
262
|
-
suite.on("domain:error",
|
|
292
|
+
suite.on("domain:error", (e) => {/* e.error */});
|
|
263
293
|
suite.onAny(({ event, data }) => {/* wildcard envelope */});
|
|
264
294
|
```
|
|
265
295
|
|
|
@@ -295,7 +325,11 @@ import { HTTP_ERROR } from "@marianmeres/http-utils";
|
|
|
295
325
|
const adapter: OwnedCollectionAdapter = {
|
|
296
326
|
async list(ctx, query) {
|
|
297
327
|
const url = new URL(`/api/shop/me/col/order/mod`, location.origin);
|
|
298
|
-
if (query)
|
|
328
|
+
if (query) {
|
|
329
|
+
for (const [k, v] of Object.entries(query)) {
|
|
330
|
+
url.searchParams.set(k, String(v));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
299
333
|
const res = await fetch(url, { signal: ctx.signal }); // forward abort
|
|
300
334
|
if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
|
|
301
335
|
return await res.json(); // { data, meta }
|
|
@@ -309,10 +343,7 @@ Joy ships a reusable factory at `src/admin/packages/joy/src/routes/me/owned-coll
|
|
|
309
343
|
### Testing with the mock adapter
|
|
310
344
|
|
|
311
345
|
```typescript
|
|
312
|
-
import {
|
|
313
|
-
createMockOwnedCollectionAdapter,
|
|
314
|
-
createOwnsuite,
|
|
315
|
-
} from "@marianmeres/ownsuite";
|
|
346
|
+
import { createMockOwnedCollectionAdapter, createOwnsuite } from "@marianmeres/ownsuite";
|
|
316
347
|
|
|
317
348
|
const adapter = createMockOwnedCollectionAdapter({
|
|
318
349
|
seed: [{ model_id: "1", data: { label: "a" } }],
|
|
@@ -373,6 +404,7 @@ deno task test:watch # watch mode
|
|
|
373
404
|
```
|
|
374
405
|
|
|
375
406
|
Coverage by file:
|
|
407
|
+
|
|
376
408
|
- `tests/ownsuite.test.ts` — core suite + `OwnedCollectionManager` CRUD, events, rollback.
|
|
377
409
|
- `tests/concurrency.test.ts` — critical invariants: concurrent mutations, abort-supersede, `getOne` not setting error, phantom-row prevention, destroy semantics, `errors()`/`hasErrors()` helpers.
|
|
378
410
|
- `tests/auth.test.ts` — `AuthManager` / `ProfileManager` / `SessionManager`: register / login / logout / unverified gate / OAuth login (popup + redirect) / OAuth unlink / profile update patching session / deleteAccount / identity-change hook propagation.
|
|
@@ -400,6 +432,7 @@ The client-side scope assumes the server enforces owner-based filtering. This re
|
|
|
400
432
|
3. **Auth middleware**: must populate `ctx.locals.subject` (typically via `@marianmeres/stack-common`'s `createJwtMiddleware`) before the collection routes handle the request.
|
|
401
433
|
|
|
402
434
|
URL shape the default adapter helper expects:
|
|
435
|
+
|
|
403
436
|
```
|
|
404
437
|
List GET {apiRoot}/{stack}/me/col/{entity}/mod
|
|
405
438
|
Get GET {apiRoot}/{stack}/me/col/{entity}/mod/{id}
|
|
@@ -411,6 +444,7 @@ Delete DELETE {apiRoot}/{stack}/me/col/{entity}/mod/{id}
|
|
|
411
444
|
### Joy admin SPA pairing
|
|
412
445
|
|
|
413
446
|
Joy ships:
|
|
447
|
+
|
|
414
448
|
- `src/admin/packages/joy/src/components/layout/LayoutCustomer.svelte` — simplified chrome for `/me/*`.
|
|
415
449
|
- `src/admin/packages/joy/src/routes/me/MeRouter.svelte` — route entry point.
|
|
416
450
|
- `src/admin/packages/joy/src/routes/me/owned-collection-adapter.ts` — reusable adapter factory.
|
|
@@ -471,14 +505,14 @@ described in the "Account lifecycle (optional)" section above.
|
|
|
471
505
|
|
|
472
506
|
## Differences from `@marianmeres/ecsuite`
|
|
473
507
|
|
|
474
|
-
| Aspect
|
|
475
|
-
|
|
476
|
-
| Domains
|
|
477
|
-
| State shape
|
|
478
|
-
| Persistence
|
|
479
|
-
| Scoping
|
|
480
|
-
| Optimistic create
|
|
481
|
-
| Optimistic update/delete | Yes
|
|
482
|
-
| Event namespaces
|
|
508
|
+
| Aspect | ecsuite | ownsuite |
|
|
509
|
+
| ------------------------ | ----------------------------------------------------------- | ----------------------------------- |
|
|
510
|
+
| Domains | Fixed 6 (cart, wishlist, order, customer, payment, product) | Arbitrary, registered by name |
|
|
511
|
+
| State shape | Domain-specific (cart items, orders list, etc.) | Generic `{ rows, meta }` per domain |
|
|
512
|
+
| Persistence | localStorage for cart/wishlist | None (server is source of truth) |
|
|
513
|
+
| Scoping | Customer-id hint in context | Server-enforced via `owner_id` |
|
|
514
|
+
| Optimistic create | Yes (cart items) | No (no client-assigned id) |
|
|
515
|
+
| Optimistic update/delete | Yes | Yes |
|
|
516
|
+
| Event namespaces | `cart:*`, `order:*`, etc. | `own:list:*`, `own:row:*` |
|
|
483
517
|
|
|
484
518
|
Consumers that already use ecsuite can compose both suites in the same app.
|
package/API.md
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
Convenience factory mirroring the ecsuite `createECSuite` convention. Equivalent to `new Ownsuite(config)`.
|
|
8
8
|
|
|
9
9
|
**Parameters:**
|
|
10
|
+
|
|
10
11
|
- `config` (`OwnsuiteConfig`, optional) — see [`OwnsuiteConfig`](#ownsuiteconfig).
|
|
11
12
|
|
|
12
13
|
**Returns:** `Ownsuite`
|
|
13
14
|
|
|
14
15
|
**Example:**
|
|
16
|
+
|
|
15
17
|
```typescript
|
|
16
18
|
import { createOwnsuite } from "@marianmeres/ownsuite";
|
|
17
19
|
|
|
@@ -32,6 +34,7 @@ await suite.initialize();
|
|
|
32
34
|
In-memory mock adapter for tests. Stores rows in a local `Map` keyed by `model_id`. Applies optional latency and can inject failures per operation — useful for exercising the optimistic-update rollback path deterministically.
|
|
33
35
|
|
|
34
36
|
**Parameters:**
|
|
37
|
+
|
|
35
38
|
- `options` (`MockAdapterOptions<TRow>`, optional)
|
|
36
39
|
- `options.seed` (`TRow[]`, optional) — initial rows
|
|
37
40
|
- `options.delayMs` (`number`, optional) — artificial latency per call. Default: `0`
|
|
@@ -44,6 +47,7 @@ In-memory mock adapter for tests. Stores rows in a local `Map` keyed by `model_i
|
|
|
44
47
|
The returned adapter exposes an extra `_rows()` method for test assertions that doesn't exist on production adapters.
|
|
45
48
|
|
|
46
49
|
**Example:**
|
|
50
|
+
|
|
47
51
|
```typescript
|
|
48
52
|
const adapter = createMockOwnedCollectionAdapter({
|
|
49
53
|
seed: [{ model_id: "1", data: { label: "a" } }],
|
|
@@ -72,6 +76,7 @@ Readonly properties pointing at the account-lifecycle managers. Populated only w
|
|
|
72
76
|
Register a new domain after construction. Throws if `name` is already registered.
|
|
73
77
|
|
|
74
78
|
**Parameters:**
|
|
79
|
+
|
|
75
80
|
- `name` (`string`) — unique domain label
|
|
76
81
|
- `cfg` (`OwnsuiteDomainConfig<TRow, TCreate, TUpdate>`)
|
|
77
82
|
- `cfg.adapter` (`OwnedCollectionAdapter`) — required
|
|
@@ -98,6 +103,7 @@ List registered domain names.
|
|
|
98
103
|
Initialize all registered domains (or a subset). Runs in parallel. Individual domain errors land in that domain's error state and **do not** reject the returned promise.
|
|
99
104
|
|
|
100
105
|
**Parameters:**
|
|
106
|
+
|
|
101
107
|
- `names` (`string[]`, optional) — domain names to initialize. Default: all registered domains.
|
|
102
108
|
|
|
103
109
|
**Returns:** `Promise<void>`
|
|
@@ -107,12 +113,14 @@ Initialize all registered domains (or a subset). Runs in parallel. Individual do
|
|
|
107
113
|
Update the shared context and propagate to every registered domain manager.
|
|
108
114
|
|
|
109
115
|
**Parameters:**
|
|
116
|
+
|
|
110
117
|
- `ctx` (`OwnsuiteContext`)
|
|
111
118
|
- `options` (`SetContextOptions`, optional)
|
|
112
119
|
- `options.replace` (`boolean`, default `false`) — replace the context wholesale instead of merging. Use this when the subject changes and previous per-subject keys must not leak into adapter calls.
|
|
113
120
|
- `options.refresh` (`boolean`, default `false`) — fire-and-forget `refresh()` on every domain after the context change. Recommended when `subjectId` changes so stale per-subject caches are cleared.
|
|
114
121
|
|
|
115
122
|
**Example:**
|
|
123
|
+
|
|
116
124
|
```typescript
|
|
117
125
|
// Subject change: drop old context + re-fetch every domain
|
|
118
126
|
suite.setContext({ subjectId: newId }, { replace: true, refresh: true });
|
|
@@ -145,12 +153,14 @@ True after `destroy()` has been called.
|
|
|
145
153
|
Subscribe to a specific event type.
|
|
146
154
|
|
|
147
155
|
**Parameters:**
|
|
156
|
+
|
|
148
157
|
- `type` (`OwnsuiteEventType`)
|
|
149
158
|
- `subscriber` (`Subscriber`) — from `@marianmeres/pubsub`
|
|
150
159
|
|
|
151
160
|
**Returns:** `Unsubscriber`
|
|
152
161
|
|
|
153
162
|
**Example:**
|
|
163
|
+
|
|
154
164
|
```typescript
|
|
155
165
|
const unsub = suite.on("own:row:created", (e) => {
|
|
156
166
|
console.log("created row", e.rowId, "in domain", e.domain);
|
|
@@ -178,6 +188,7 @@ Generic manager for a single owner-scoped collection domain. One instance per co
|
|
|
178
188
|
Typically created via `Ownsuite.registerDomain()` — manual construction is possible but bypasses the shared pubsub.
|
|
179
189
|
|
|
180
190
|
**Parameters:**
|
|
191
|
+
|
|
181
192
|
- `domainName` (`string`) — label (informational, used in event payloads and logs)
|
|
182
193
|
- `options` (`OwnedCollectionManagerOptions<TRow, TCreate, TUpdate>`, optional)
|
|
183
194
|
- `options.adapter` (`OwnedCollectionAdapter`, optional)
|
|
@@ -204,6 +215,7 @@ Fetch the list from the server. Populates `data.rows` + `data.meta` and transiti
|
|
|
204
215
|
Re-fetch the list. Same as `initialize` but re-entrant; accepts an adapter-specific query object.
|
|
205
216
|
|
|
206
217
|
**Parameters:**
|
|
218
|
+
|
|
207
219
|
- `query` (`Record<string, unknown>`, optional) — forwarded to `adapter.list(ctx, query)`
|
|
208
220
|
|
|
209
221
|
#### `manager.getOne(id): Promise<TRow | null>`
|
|
@@ -217,6 +229,7 @@ Callers that need error detail should wrap this method and inspect the adapter e
|
|
|
217
229
|
Create a new row. On success, prepends the server-returned row to the list. On failure, the list is unchanged and the manager transitions to `error`.
|
|
218
230
|
|
|
219
231
|
**Parameters:**
|
|
232
|
+
|
|
220
233
|
- `data` (`TCreate`) — creation payload. The server stamps `owner_id` — do not set it client-side.
|
|
221
234
|
|
|
222
235
|
**Returns:** the server-returned row, or `null` on failure.
|
|
@@ -228,6 +241,7 @@ Update a row. Optimistically merges `data` into the existing row; on server fail
|
|
|
228
241
|
If `id` is **not** in the current cached list (filtered out by an active query, or not loaded), the optimistic step is a no-op AND the successful server response is **not** inserted — call `refresh()` if you want the row to appear. The `own:row:updated` event is emitted regardless.
|
|
229
242
|
|
|
230
243
|
**Parameters:**
|
|
244
|
+
|
|
231
245
|
- `id` (`string`)
|
|
232
246
|
- `data` (`TUpdate`)
|
|
233
247
|
|
|
@@ -289,8 +303,8 @@ interface OwnsuiteConfig {
|
|
|
289
303
|
profile?: ProfileAdapter;
|
|
290
304
|
};
|
|
291
305
|
session?: {
|
|
292
|
-
storage?: SessionStorageType;
|
|
293
|
-
storageKey?: string;
|
|
306
|
+
storage?: SessionStorageType; // "local" | "session" | "memory" | SessionStorage
|
|
307
|
+
storageKey?: string; // default: "ownsuite:session"
|
|
294
308
|
};
|
|
295
309
|
}
|
|
296
310
|
```
|
|
@@ -315,8 +329,8 @@ interface OwnsuiteDomainConfig<TRow, TCreate, TUpdate> {
|
|
|
315
329
|
|
|
316
330
|
```typescript
|
|
317
331
|
interface SetContextOptions {
|
|
318
|
-
replace?: boolean;
|
|
319
|
-
refresh?: boolean;
|
|
332
|
+
replace?: boolean; // default: false — merge into existing context
|
|
333
|
+
refresh?: boolean; // default: false — fire refresh() on every domain
|
|
320
334
|
}
|
|
321
335
|
```
|
|
322
336
|
|
|
@@ -325,7 +339,7 @@ interface SetContextOptions {
|
|
|
325
339
|
```typescript
|
|
326
340
|
interface OwnsuiteContext {
|
|
327
341
|
subjectId?: string;
|
|
328
|
-
signal?: AbortSignal;
|
|
342
|
+
signal?: AbortSignal; // manager-injected, per-call
|
|
329
343
|
[key: string]: unknown;
|
|
330
344
|
}
|
|
331
345
|
```
|
|
@@ -336,10 +350,17 @@ Context passed to adapters. **`subjectId` is a hint only** — the server author
|
|
|
336
350
|
|
|
337
351
|
```typescript
|
|
338
352
|
interface OwnedCollectionAdapter<TRow, TCreate = unknown, TUpdate = unknown> {
|
|
339
|
-
list(
|
|
353
|
+
list(
|
|
354
|
+
ctx: OwnsuiteContext,
|
|
355
|
+
query?: Record<string, unknown>,
|
|
356
|
+
): Promise<OwnedListResult<TRow>>;
|
|
340
357
|
getOne(id: string, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
|
|
341
358
|
create(data: TCreate, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
|
|
342
|
-
update(
|
|
359
|
+
update(
|
|
360
|
+
id: string,
|
|
361
|
+
data: TUpdate,
|
|
362
|
+
ctx: OwnsuiteContext,
|
|
363
|
+
): Promise<OwnedRowResult<TRow>>;
|
|
343
364
|
delete(id: string, ctx: OwnsuiteContext): Promise<boolean>;
|
|
344
365
|
}
|
|
345
366
|
```
|
|
@@ -360,8 +381,6 @@ interface OwnedRowResult<TRow> {
|
|
|
360
381
|
}
|
|
361
382
|
```
|
|
362
383
|
|
|
363
|
-
Matches `@marianmeres/collection`'s REST envelope.
|
|
364
|
-
|
|
365
384
|
### `OwnedCollectionState<TRow>`
|
|
366
385
|
|
|
367
386
|
```typescript
|
|
@@ -375,7 +394,7 @@ interface OwnedCollectionState<TRow> {
|
|
|
375
394
|
|
|
376
395
|
```typescript
|
|
377
396
|
interface DomainStateWrapper<T> {
|
|
378
|
-
state: DomainState;
|
|
397
|
+
state: DomainState; // "initializing" | "ready" | "syncing" | "error"
|
|
379
398
|
data: T | null;
|
|
380
399
|
error: DomainError | null;
|
|
381
400
|
lastSyncedAt: number | null;
|
|
@@ -401,9 +420,9 @@ error → syncing (retry)
|
|
|
401
420
|
|
|
402
421
|
```typescript
|
|
403
422
|
interface DomainError {
|
|
404
|
-
code: string;
|
|
423
|
+
code: string; // e.g. "SYNC_FAILED", "FETCH_FAILED"
|
|
405
424
|
message: string;
|
|
406
|
-
operation: string;
|
|
425
|
+
operation: string; // e.g. "create", "update", "delete"
|
|
407
426
|
originalError?: unknown;
|
|
408
427
|
}
|
|
409
428
|
```
|
|
@@ -440,11 +459,11 @@ type OwnsuiteEvent =
|
|
|
440
459
|
| StateChangedEvent
|
|
441
460
|
| ErrorEvent
|
|
442
461
|
| SyncedEvent
|
|
443
|
-
| ListFetchedEvent
|
|
444
|
-
| RowFetchedEvent
|
|
445
|
-
| RowCreatedEvent
|
|
446
|
-
| RowUpdatedEvent
|
|
447
|
-
| RowDeletedEvent;
|
|
462
|
+
| ListFetchedEvent // + count
|
|
463
|
+
| RowFetchedEvent // + rowId
|
|
464
|
+
| RowCreatedEvent // + rowId
|
|
465
|
+
| RowUpdatedEvent // + rowId
|
|
466
|
+
| RowDeletedEvent; // + rowId
|
|
448
467
|
```
|
|
449
468
|
|
|
450
469
|
See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
|
|
@@ -455,7 +474,13 @@ See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
|
|
|
455
474
|
interface MockAdapterOptions<TRow> {
|
|
456
475
|
seed?: TRow[];
|
|
457
476
|
delayMs?: number;
|
|
458
|
-
failOn?: {
|
|
477
|
+
failOn?: {
|
|
478
|
+
list?: boolean;
|
|
479
|
+
getOne?: boolean;
|
|
480
|
+
create?: boolean;
|
|
481
|
+
update?: boolean;
|
|
482
|
+
delete?: boolean;
|
|
483
|
+
};
|
|
459
484
|
getRowId?: (row: TRow) => string;
|
|
460
485
|
newId?: () => string;
|
|
461
486
|
/** Reject create payloads containing `model_id` (default: true). */
|
|
@@ -515,9 +540,10 @@ Attached automatically when `adapters.auth` is passed to `createOwnsuite`. See a
|
|
|
515
540
|
|
|
516
541
|
### `createStackAccountAuthAdapter(options?)`
|
|
517
542
|
|
|
518
|
-
Default `AuthAdapter` pointing at
|
|
543
|
+
Default `AuthAdapter` pointing at a conventional account REST surface (register / login / logout / OAuth / verify).
|
|
519
544
|
|
|
520
545
|
**Parameters:**
|
|
546
|
+
|
|
521
547
|
- `options` (`StackAccountAdapterOptions`, optional)
|
|
522
548
|
- `options.baseUrl` (`string`, optional) — mount path. Default: `"/api/account"`.
|
|
523
549
|
- `options.fetch` (`typeof fetch`, optional) — custom fetch (tests / SSR).
|
|
@@ -556,18 +582,19 @@ In-memory mock for tests and demos. `createMockAuthStore` builds a shared state
|
|
|
556
582
|
**`verifyMockAccount(store, email)`** marks an account as verified — stands in for the user clicking the link in a real verification email.
|
|
557
583
|
|
|
558
584
|
**Example:**
|
|
585
|
+
|
|
559
586
|
```typescript
|
|
560
587
|
const store = createMockAuthStore({ requireVerifiedEmail: true });
|
|
561
588
|
const suite = createOwnsuite({
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
589
|
+
adapters: {
|
|
590
|
+
auth: createMockAuthAdapter(store),
|
|
591
|
+
profile: createMockProfileAdapter(store),
|
|
592
|
+
},
|
|
566
593
|
});
|
|
567
594
|
await suite.auth!.register({
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
595
|
+
email: "alice@example.com",
|
|
596
|
+
password: "mysecretpassword",
|
|
597
|
+
password_confirm: "mysecretpassword",
|
|
571
598
|
});
|
|
572
599
|
// suite.session!.get().status === "unverified"
|
|
573
600
|
verifyMockAccount(store, "alice@example.com");
|
|
@@ -613,17 +640,17 @@ Clear storage and in-memory state. Called by `suite.destroy()`.
|
|
|
613
640
|
|
|
614
641
|
Verbs only. Attached as `suite.auth`. No state of its own — results flow into `SessionManager`.
|
|
615
642
|
|
|
616
|
-
| Method
|
|
617
|
-
|
|
618
|
-
| `register({ email, password, password_confirm, roles?, extras? }, options?)`
|
|
619
|
-
| `login({ email, password }, options?)`
|
|
620
|
-
| `logout()`
|
|
621
|
-
| `resendVerification({ email, lang? })`
|
|
622
|
-
| `requestPasswordReset({ email, lang? })`
|
|
623
|
-
| `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset.
|
|
624
|
-
| `deleteAccount({ password?, confirm? })`
|
|
625
|
-
| `initiateOAuth(provider, opts)`
|
|
626
|
-
| `handleOAuthCallback(options?)`
|
|
643
|
+
| Method | Purpose |
|
|
644
|
+
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
645
|
+
| `register({ email, password, password_confirm, roles?, extras? }, options?)` | Create account. Returns an `AuthTokenResult`. When the server's verification gate is on, the result carries `requiresVerification: true` and the session flips to `"unverified"` — no JWT yet. `options.remember` pins the resulting session to `localStorage` (`true`) or `sessionStorage` (`false`); omit to use the `SessionManager` default. |
|
|
646
|
+
| `login({ email, password }, options?)` | Exchange credentials for a JWT. Session flips to `"authenticated"` on success, `"unverified"` if the server reports the gate. `options.remember` selects per-login storage (see `register`). |
|
|
647
|
+
| `logout()` | Best-effort server revoke + local clear. Idempotent. |
|
|
648
|
+
| `resendVerification({ email, lang? })` | Trigger a fresh verification email. Anti-enumeration: always resolves. |
|
|
649
|
+
| `requestPasswordReset({ email, lang? })` | Trigger a password-reset email. Anti-enumeration. |
|
|
650
|
+
| `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset. |
|
|
651
|
+
| `deleteAccount({ password?, confirm? })` | Irreversible server delete + local session clear + identity-changed hook. |
|
|
652
|
+
| `initiateOAuth(provider, opts)` | Start an OAuth flow. `mode: "popup"` (default) resolves with the auth result from the popup's `postMessage`; `mode: "redirect"` navigates the top window. `opts.remember` pins the resulting session's storage backend (only meaningful for `action: "login"`). |
|
|
653
|
+
| `handleOAuthCallback(options?)` | For `mode: "redirect"` apps, call from your callback route to extract the result from the URL (delegated to `adapter.handleOAuthCallback`). `options.remember` pins the resulting session's storage — pass the same value the user picked before the redirect. |
|
|
627
654
|
|
|
628
655
|
Successful identity changes (register-with-autologin, login, OAuth login, logout, deleteAccount) fire the orchestrator's `onIdentityChanged` hook, which resets every owner-scoped domain and re-initializes them with the new context.
|
|
629
656
|
|
|
@@ -633,14 +660,14 @@ Successful identity changes (register-with-autologin, login, OAuth login, logout
|
|
|
633
660
|
|
|
634
661
|
Singleton `/me` manager. Attached as `suite.profile`.
|
|
635
662
|
|
|
636
|
-
| Method
|
|
637
|
-
|
|
638
|
-
| `fetch()`
|
|
639
|
-
| `update({ email?, current_password? })` | PUT `/me`. Updates the session subject in place; emits `profile:updated`.
|
|
640
|
-
| `listOAuth()`
|
|
641
|
-
| `unlinkOAuth(provider)`
|
|
642
|
-
| `get()` / `subscribe(fn)`
|
|
643
|
-
| `reset()` / `destroy()`
|
|
663
|
+
| Method | Purpose |
|
|
664
|
+
| --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
665
|
+
| `fetch()` | GET `/me`. Aborts any in-flight fetch (supersede). Patches the session subject in place on success. |
|
|
666
|
+
| `update({ email?, current_password? })` | PUT `/me`. Updates the session subject in place; emits `profile:updated`. |
|
|
667
|
+
| `listOAuth()` | List the account's linked OAuth providers. |
|
|
668
|
+
| `unlinkOAuth(provider)` | DELETE a provider connection; emits `oauth:unlinked`; re-fetches the profile. |
|
|
669
|
+
| `get()` / `subscribe(fn)` | Read / subscribe to `ProfileState` (`{ profile, loading, error }`). |
|
|
670
|
+
| `reset()` / `destroy()` | Abort in-flight fetch; drop state. |
|
|
644
671
|
|
|
645
672
|
---
|
|
646
673
|
|
|
@@ -649,6 +676,7 @@ Singleton `/me` manager. Attached as `suite.profile`.
|
|
|
649
676
|
Open an OAuth popup and await the server's `postMessage`.
|
|
650
677
|
|
|
651
678
|
**Parameters:**
|
|
679
|
+
|
|
652
680
|
- `url` (`string`) — server OAuth init URL.
|
|
653
681
|
- `options` (`OpenOAuthPopupOptions`, optional)
|
|
654
682
|
- `options.host` (`PopupWindowHost`, optional) — shim for tests. Default: `globalThis`.
|
|
@@ -667,10 +695,10 @@ Open an OAuth popup and await the server's `postMessage`.
|
|
|
667
695
|
|
|
668
696
|
```typescript
|
|
669
697
|
interface SessionState {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
698
|
+
status: "anonymous" | "authenticated" | "unverified";
|
|
699
|
+
subject: SessionSubject | null;
|
|
700
|
+
jwt: string | null;
|
|
701
|
+
expiresAt: number | null; // unix seconds
|
|
674
702
|
}
|
|
675
703
|
```
|
|
676
704
|
|
|
@@ -678,11 +706,11 @@ interface SessionState {
|
|
|
678
706
|
|
|
679
707
|
```typescript
|
|
680
708
|
interface SessionSubject {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
709
|
+
id: string;
|
|
710
|
+
email: string;
|
|
711
|
+
roles: string[];
|
|
712
|
+
isVerified: boolean;
|
|
713
|
+
hasPassword: boolean; // OAuth-only accounts: false
|
|
686
714
|
}
|
|
687
715
|
```
|
|
688
716
|
|
|
@@ -692,9 +720,9 @@ interface SessionSubject {
|
|
|
692
720
|
type SessionStatus = "anonymous" | "authenticated" | "unverified";
|
|
693
721
|
|
|
694
722
|
interface SessionStorage {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
723
|
+
get(key: string): string | null;
|
|
724
|
+
set(key: string, value: string): void;
|
|
725
|
+
del(key: string): void;
|
|
698
726
|
}
|
|
699
727
|
|
|
700
728
|
type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
|
|
@@ -704,13 +732,13 @@ type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
|
|
|
704
732
|
|
|
705
733
|
```typescript
|
|
706
734
|
interface AuthTokenResult {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
735
|
+
jwt?: string; // absent when requiresVerification is true
|
|
736
|
+
email: string;
|
|
737
|
+
roles: string[];
|
|
738
|
+
isVerified?: boolean;
|
|
739
|
+
validFrom?: number;
|
|
740
|
+
validUntil?: number;
|
|
741
|
+
requiresVerification?: boolean; // server declined auto-login pending verify
|
|
714
742
|
}
|
|
715
743
|
```
|
|
716
744
|
|
|
@@ -718,11 +746,11 @@ interface AuthTokenResult {
|
|
|
718
746
|
|
|
719
747
|
```typescript
|
|
720
748
|
interface ProfileResult {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
749
|
+
email: string;
|
|
750
|
+
roles: string[];
|
|
751
|
+
isVerified: boolean;
|
|
752
|
+
hasPassword: boolean;
|
|
753
|
+
oauthConnections: OAuthConnection[];
|
|
726
754
|
}
|
|
727
755
|
```
|
|
728
756
|
|
|
@@ -733,25 +761,25 @@ type OAuthProvider = "google" | "facebook" | "apple" | "twitter";
|
|
|
733
761
|
type OAuthAction = "login" | "link";
|
|
734
762
|
|
|
735
763
|
interface OAuthInitOptions {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
764
|
+
action: OAuthAction;
|
|
765
|
+
redirect?: string;
|
|
766
|
+
lang?: string;
|
|
767
|
+
mode?: "popup" | "redirect"; // default "popup"
|
|
768
|
+
remember?: boolean; // same semantics as AuthActionOptions.remember
|
|
741
769
|
}
|
|
742
770
|
|
|
743
771
|
interface AuthActionOptions {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
772
|
+
/** true → localStorage; false → sessionStorage; undefined → default.
|
|
773
|
+
* Ignored when SessionManager was constructed with a custom
|
|
774
|
+
* SessionStorage object. */
|
|
775
|
+
remember?: boolean;
|
|
748
776
|
}
|
|
749
777
|
|
|
750
778
|
interface OAuthConnection {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
779
|
+
provider: OAuthProvider;
|
|
780
|
+
display_name?: string;
|
|
781
|
+
avatar_url?: string;
|
|
782
|
+
email?: string;
|
|
755
783
|
}
|
|
756
784
|
```
|
|
757
785
|
|
|
@@ -759,15 +787,15 @@ interface OAuthConnection {
|
|
|
759
787
|
|
|
760
788
|
```typescript
|
|
761
789
|
interface AuthAdapter {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
790
|
+
register(input, ctx): Promise<AuthTokenResult>;
|
|
791
|
+
login(input, ctx): Promise<AuthTokenResult>;
|
|
792
|
+
logout(ctx): Promise<void>;
|
|
793
|
+
oauthInitUrl(provider, opts, ctx): string;
|
|
794
|
+
handleOAuthCallback?(ctx): Promise<AuthTokenResult>;
|
|
795
|
+
resendVerification(input, ctx): Promise<void>;
|
|
796
|
+
requestPasswordReset(input, ctx): Promise<void>;
|
|
797
|
+
changePassword(input, ctx): Promise<void>;
|
|
798
|
+
deleteAccount(input, ctx): Promise<{ deleted: true }>;
|
|
771
799
|
}
|
|
772
800
|
```
|
|
773
801
|
|
|
@@ -777,10 +805,10 @@ Parameter shapes match [`src/types/auth.ts`](src/types/auth.ts). Implementations
|
|
|
777
805
|
|
|
778
806
|
```typescript
|
|
779
807
|
interface ProfileAdapter {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
808
|
+
get(ctx): Promise<ProfileResult>;
|
|
809
|
+
update(input: { email?; current_password? }, ctx): Promise<ProfileResult>;
|
|
810
|
+
listOAuth(ctx): Promise<OAuthConnection[]>;
|
|
811
|
+
unlinkOAuth(provider, ctx): Promise<void>;
|
|
784
812
|
}
|
|
785
813
|
```
|
|
786
814
|
|
|
@@ -788,8 +816,8 @@ interface ProfileAdapter {
|
|
|
788
816
|
|
|
789
817
|
```typescript
|
|
790
818
|
interface StackAccountAdapterOptions {
|
|
791
|
-
|
|
792
|
-
|
|
819
|
+
baseUrl?: string; // default "/api/account"
|
|
820
|
+
fetch?: typeof fetch;
|
|
793
821
|
}
|
|
794
822
|
```
|
|
795
823
|
|
|
@@ -801,18 +829,18 @@ See [src/oauth/popup.ts](src/oauth/popup.ts) for full definitions. `OAuthPopupMe
|
|
|
801
829
|
|
|
802
830
|
```typescript
|
|
803
831
|
interface MockAccount {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
832
|
+
email: string;
|
|
833
|
+
password: string; // plaintext — mock does no hashing
|
|
834
|
+
roles: string[];
|
|
835
|
+
isVerified: boolean;
|
|
836
|
+
hasPassword: boolean;
|
|
837
|
+
oauthConnections: OAuthConnection[];
|
|
810
838
|
}
|
|
811
839
|
|
|
812
840
|
interface MockAuthStore {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
841
|
+
accounts: Map<string, MockAccount>;
|
|
842
|
+
requireVerifiedEmail: boolean;
|
|
843
|
+
jwtsByEmail: Map<string, string>;
|
|
816
844
|
}
|
|
817
845
|
```
|
|
818
846
|
|
|
@@ -824,13 +852,13 @@ Fields are public by design so test code can peek at / mutate them directly.
|
|
|
824
852
|
|
|
825
853
|
Emitted on the shared pubsub. Each payload has a `timestamp` (ms).
|
|
826
854
|
|
|
827
|
-
| Event
|
|
828
|
-
|
|
829
|
-
| `auth:register`
|
|
830
|
-
| `auth:login`
|
|
831
|
-
| `auth:logout`
|
|
832
|
-
| `auth:session:changed`
|
|
855
|
+
| Event | Payload |
|
|
856
|
+
| ---------------------------- | ------------------------------------------------------- |
|
|
857
|
+
| `auth:register` | `{ email, requiresVerification }` |
|
|
858
|
+
| `auth:login` | `{ email }` |
|
|
859
|
+
| `auth:logout` | `{ subjectId? }` |
|
|
860
|
+
| `auth:session:changed` | `{ session: SessionState }` |
|
|
833
861
|
| `auth:verification:required` | `{ email }` (fired when status flips to `"unverified"`) |
|
|
834
|
-
| `profile:updated`
|
|
835
|
-
| `oauth:linked`
|
|
836
|
-
| `oauth:unlinked`
|
|
862
|
+
| `profile:updated` | `{ email }` |
|
|
863
|
+
| `oauth:linked` | `{ connection: OAuthConnection }` |
|
|
864
|
+
| `oauth:unlinked` | `{ provider: OAuthProvider }` |
|
package/README.md
CHANGED
|
@@ -8,10 +8,7 @@ Client-side helper library for owner-scoped UIs. Generic domain managers with op
|
|
|
8
8
|
|
|
9
9
|
## What it does
|
|
10
10
|
|
|
11
|
-
Ownsuite gives front-end applications a uniform way to read, create, update and delete records from owner-scoped REST endpoints (typically `/me/*`). Each row is implicitly scoped to the authenticated subject by the server — the client never sets `owner_id`.
|
|
12
|
-
|
|
13
|
-
- **@marianmeres/collection** — the `ownerIdScope` route hook enforces owner-based filtering on the server.
|
|
14
|
-
- **@marianmeres/stack-common** — the `ownsuiteOptions()` helper wires the server mount.
|
|
11
|
+
Ownsuite gives front-end applications a uniform way to read, create, update and delete records from owner-scoped REST endpoints (typically `/me/*`). Each row is implicitly scoped to the authenticated subject by the server — the client never sets `owner_id`.
|
|
15
12
|
|
|
16
13
|
## Features
|
|
17
14
|
|
|
@@ -24,52 +21,52 @@ Ownsuite gives front-end applications a uniform way to read, create, update and
|
|
|
24
21
|
- **Event system** — subscribe to list fetches, row CRUD, and lifecycle transitions
|
|
25
22
|
- **Mock adapter** — in-memory fixture for tests, with configurable failure injection and latency
|
|
26
23
|
- **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,
|
|
24
|
+
- **Account lifecycle (opt-in)** — `suite.auth` / `suite.session` / `suite.profile` for register / login / OAuth / verify / logout / profile edit / delete account, with bundled default adapters for a standard account REST surface
|
|
28
25
|
|
|
29
26
|
## Authentication (optional)
|
|
30
27
|
|
|
31
|
-
Pass an `AuthAdapter` to `createOwnsuite` to attach the account-lifecycle managers. The default adapters target
|
|
28
|
+
Pass an `AuthAdapter` to `createOwnsuite` to attach the account-lifecycle managers. The bundled default adapters target a conventional account REST surface (register / login / logout / OAuth / verify / profile CRUD); apps with custom routes can write their own against the `AuthAdapter` / `ProfileAdapter` interfaces exported from this package.
|
|
32
29
|
|
|
33
30
|
```typescript
|
|
34
31
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
createOwnsuite,
|
|
33
|
+
createStackAccountAuthAdapter,
|
|
34
|
+
createStackAccountProfileAdapter,
|
|
38
35
|
} from "@marianmeres/ownsuite";
|
|
39
36
|
|
|
40
37
|
const suite = createOwnsuite({
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
38
|
+
adapters: {
|
|
39
|
+
auth: createStackAccountAuthAdapter({ baseUrl: "/api/account" }),
|
|
40
|
+
profile: createStackAccountProfileAdapter({ baseUrl: "/api/account" }),
|
|
41
|
+
},
|
|
42
|
+
session: { storage: "local", storageKey: "myapp:session" },
|
|
43
|
+
// Existing owner-scoped domains continue to work — their ctx.jwt is
|
|
44
|
+
// populated automatically from the session and they re-initialize on
|
|
45
|
+
// every login / logout.
|
|
46
|
+
domains: {
|
|
47
|
+
orders: { adapter: ordersAdapter },
|
|
48
|
+
},
|
|
52
49
|
});
|
|
53
50
|
|
|
54
51
|
// Observable session — UI subscribes to this for logged-in state.
|
|
55
52
|
suite.session!.subscribe(({ status, subject }) => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
if (status === "authenticated") console.log("hi", subject!.email);
|
|
54
|
+
if (status === "unverified") console.log("check your inbox");
|
|
55
|
+
if (status === "anonymous") console.log("signed out");
|
|
59
56
|
});
|
|
60
57
|
|
|
61
|
-
// Register → server requires email verification
|
|
58
|
+
// Register → server requires email verification by default
|
|
62
59
|
await suite.auth!.register({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
email: "alice@example.com",
|
|
61
|
+
password: "mysecretpassword",
|
|
62
|
+
password_confirm: "mysecretpassword",
|
|
66
63
|
});
|
|
67
64
|
// suite.session!.get().status === "unverified"
|
|
68
65
|
|
|
69
66
|
// After the user clicks the email link and the server flips isVerified:
|
|
70
67
|
await suite.auth!.login(
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
{ email: "alice@example.com", password: "mysecretpassword" },
|
|
69
|
+
{ remember: true }, // true → localStorage; false → sessionStorage (per-login override)
|
|
73
70
|
);
|
|
74
71
|
// suite.session!.get().status === "authenticated"
|
|
75
72
|
// Every registered owner-scoped domain is re-initialized with the new JWT.
|
|
@@ -80,12 +77,12 @@ await suite.auth!.initiateOAuth("google", { action: "login" });
|
|
|
80
77
|
// Profile edit — changing email resets isVerified server-side and dispatches
|
|
81
78
|
// a new verification email. Session subject is patched in place.
|
|
82
79
|
await suite.profile!.update({
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
email: "renamed@example.com",
|
|
81
|
+
current_password: "mysecretpassword",
|
|
85
82
|
});
|
|
86
83
|
|
|
87
|
-
// Logout — revokes JWT server-side
|
|
88
|
-
//
|
|
84
|
+
// Logout — revokes JWT server-side and clears local session storage.
|
|
85
|
+
// Owner-scoped domains reset to initializing.
|
|
89
86
|
await suite.auth!.logout();
|
|
90
87
|
```
|
|
91
88
|
|
|
@@ -160,14 +157,11 @@ Each domain holds a single list of rows owned by the authenticated subject. List
|
|
|
160
157
|
## Testing with the mock adapter
|
|
161
158
|
|
|
162
159
|
```typescript
|
|
163
|
-
import {
|
|
164
|
-
createMockOwnedCollectionAdapter,
|
|
165
|
-
createOwnsuite,
|
|
166
|
-
} from "@marianmeres/ownsuite";
|
|
160
|
+
import { createMockOwnedCollectionAdapter, createOwnsuite } from "@marianmeres/ownsuite";
|
|
167
161
|
|
|
168
162
|
const adapter = createMockOwnedCollectionAdapter({
|
|
169
163
|
seed: [{ model_id: "1", data: { label: "hello" } }],
|
|
170
|
-
failOn: { update: true },
|
|
164
|
+
failOn: { update: true }, // force update failures for rollback tests
|
|
171
165
|
});
|
|
172
166
|
|
|
173
167
|
const suite = createOwnsuite({ domains: { notes: { adapter } } });
|
|
@@ -65,9 +65,7 @@ async function requestJson(doFetch, url, init, ctx) {
|
|
|
65
65
|
headers: {
|
|
66
66
|
...(init.headers ?? {}),
|
|
67
67
|
...authHeaders(ctx),
|
|
68
|
-
...(init.body
|
|
69
|
-
? { "Content-Type": "application/json" }
|
|
70
|
-
: {}),
|
|
68
|
+
...(init.body ? { "Content-Type": "application/json" } : {}),
|
|
71
69
|
},
|
|
72
70
|
signal: ctx.signal,
|
|
73
71
|
});
|
|
@@ -82,6 +80,72 @@ async function requestJson(doFetch, url, init, ctx) {
|
|
|
82
80
|
return undefined;
|
|
83
81
|
return (await res.json());
|
|
84
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Coerce a server-provided validity marker to **epoch seconds**.
|
|
85
|
+
*
|
|
86
|
+
* `AuthTokenResult.validFrom` / `validUntil` are typed (and consumed
|
|
87
|
+
* downstream — notably the session-expiry check) as numeric epoch seconds, but
|
|
88
|
+
* the stack-account server sends ISO-8601 strings. Blind-casting the wire shape
|
|
89
|
+
* lands a string in a numeric field, which silently breaks `expiresAt * 1000`
|
|
90
|
+
* arithmetic (`NaN`, so an expired session never expires). This bridges the two
|
|
91
|
+
* tolerantly:
|
|
92
|
+
* - ISO-8601 string → parsed to epoch seconds
|
|
93
|
+
* - epoch-seconds num → returned as-is
|
|
94
|
+
* - epoch-ms num → divided down (heuristic: `> 1e12` ⇒ milliseconds)
|
|
95
|
+
* - null / undefined / unparseable → `undefined`
|
|
96
|
+
*/
|
|
97
|
+
function toEpochSeconds(v) {
|
|
98
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
99
|
+
return v > 1e12 ? Math.floor(v / 1000) : v;
|
|
100
|
+
}
|
|
101
|
+
if (typeof v === "string") {
|
|
102
|
+
const ms = Date.parse(v);
|
|
103
|
+
return Number.isNaN(ms) ? undefined : Math.floor(ms / 1000);
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
/** Best-effort read of a JWT payload's numeric `exp` claim (epoch seconds).
|
|
108
|
+
* The signature is NOT verified — the server is authoritative; this only
|
|
109
|
+
* reads the expiry of an already-trusted token as a fallback when the
|
|
110
|
+
* server's `validUntil` is missing or unparseable. Returns `undefined` for a
|
|
111
|
+
* malformed / non-JWT string so callers degrade gracefully. */
|
|
112
|
+
function jwtExpSeconds(jwt) {
|
|
113
|
+
if (!jwt)
|
|
114
|
+
return undefined;
|
|
115
|
+
const parts = jwt.split(".");
|
|
116
|
+
if (parts.length < 2)
|
|
117
|
+
return undefined;
|
|
118
|
+
try {
|
|
119
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
120
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
|
|
121
|
+
const bytes = Uint8Array.from(atob(b64 + pad), (c) => c.charCodeAt(0));
|
|
122
|
+
const payload = JSON.parse(new TextDecoder().decode(bytes));
|
|
123
|
+
return toEpochSeconds(payload.exp);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Translate the stack-account wire payload into ownsuite's
|
|
130
|
+
* {@link AuthTokenResult}, converting `validFrom` / `validUntil` (ISO string
|
|
131
|
+
* or numeric) to epoch seconds. Prefers the server's `validUntil`; falls back
|
|
132
|
+
* to the JWT's own `exp` claim when `validUntil` is absent or unparseable, so
|
|
133
|
+
* a malformed validity field can never produce an immortal client session.
|
|
134
|
+
* Replaces the previous blind `r.data as AuthTokenResult` cast. */
|
|
135
|
+
function normalizeAuthResult(data) {
|
|
136
|
+
const validFrom = toEpochSeconds(data.validFrom);
|
|
137
|
+
const validUntil = toEpochSeconds(data.validUntil) ??
|
|
138
|
+
jwtExpSeconds(data.jwt);
|
|
139
|
+
return {
|
|
140
|
+
jwt: data.jwt,
|
|
141
|
+
email: data.email,
|
|
142
|
+
roles: data.roles ?? [],
|
|
143
|
+
isVerified: data.isVerified,
|
|
144
|
+
requiresVerification: data.requiresVerification,
|
|
145
|
+
...(validFrom !== undefined ? { validFrom } : {}),
|
|
146
|
+
...(validUntil !== undefined ? { validUntil } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
85
149
|
/** Build the default {@link AuthAdapter} for `@marianmeres/stack-account`.
|
|
86
150
|
* Points at `{baseUrl}/auth/*` (register/login/logout/verify/password/
|
|
87
151
|
* delete) and `{baseUrl}/oauth/*` (init + callback). */
|
|
@@ -91,11 +155,11 @@ export function createStackAccountAuthAdapter(opts = {}) {
|
|
|
91
155
|
return {
|
|
92
156
|
async register(input, ctx) {
|
|
93
157
|
const r = await postJson(doFetch, join(base, "/register"), input, ctx);
|
|
94
|
-
return r.data;
|
|
158
|
+
return normalizeAuthResult(r.data);
|
|
95
159
|
},
|
|
96
160
|
async login(input, ctx) {
|
|
97
161
|
const r = await postJson(doFetch, join(base, "/login"), input, ctx);
|
|
98
|
-
return r.data;
|
|
162
|
+
return normalizeAuthResult(r.data);
|
|
99
163
|
},
|
|
100
164
|
async logout(ctx) {
|
|
101
165
|
await requestJson(doFetch, join(base, "/logout"), { method: "POST" }, ctx);
|
package/dist/domains/session.js
CHANGED
|
@@ -151,11 +151,16 @@ export class SessionManager {
|
|
|
151
151
|
storage.del(this.#storageKey);
|
|
152
152
|
return null;
|
|
153
153
|
}
|
|
154
|
-
if (parsed.expiresAt !== null &&
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
if (parsed.expiresAt !== null && parsed.expiresAt !== undefined) {
|
|
155
|
+
// Fail-closed: `expiresAt` MUST be finite epoch seconds. A
|
|
156
|
+
// present-but-non-finite value (e.g. an ISO string laundered
|
|
157
|
+
// through a buggy adapter) is treated as expired and wiped,
|
|
158
|
+
// rather than yielding `NaN <= now === false` → immortal session.
|
|
159
|
+
const exp = typeof parsed.expiresAt === "number" ? parsed.expiresAt : NaN;
|
|
160
|
+
if (!Number.isFinite(exp) || exp * 1000 <= Date.now()) {
|
|
161
|
+
storage.del(this.#storageKey);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
159
164
|
}
|
|
160
165
|
return parsed;
|
|
161
166
|
}
|
package/dist/oauth/popup.js
CHANGED
|
@@ -18,7 +18,7 @@ const DEFAULT_FEATURES = "width=500,height=700,noopener=no,noreferrer=no";
|
|
|
18
18
|
* message is an error, or if the optional timeout fires.
|
|
19
19
|
*/
|
|
20
20
|
export function openOAuthPopup(url, options = {}) {
|
|
21
|
-
const host =
|
|
21
|
+
const host = options.host ?? globalThis;
|
|
22
22
|
if (typeof host.open !== "function" || typeof host.addEventListener !== "function") {
|
|
23
23
|
return Promise.reject(new Error("openOAuthPopup: host window does not support popups"));
|
|
24
24
|
}
|
package/dist/ownsuite.js
CHANGED
|
@@ -211,9 +211,7 @@ export class Ownsuite {
|
|
|
211
211
|
setContext(ctx, options = {}) {
|
|
212
212
|
if (this.#destroyed)
|
|
213
213
|
return;
|
|
214
|
-
this.#context = options.replace
|
|
215
|
-
? { ...ctx }
|
|
216
|
-
: { ...this.#context, ...ctx };
|
|
214
|
+
this.#context = options.replace ? { ...ctx } : { ...this.#context, ...ctx };
|
|
217
215
|
for (const m of this.#domains.values()) {
|
|
218
216
|
if (options.replace)
|
|
219
217
|
m.replaceContext(this.#context);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/ownsuite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/mod.js",
|
|
6
6
|
"types": "dist/mod.d.ts",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"author": "Marian Meres",
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@marianmeres/clog": "^3.
|
|
26
|
-
"@marianmeres/collection-types": "^1.
|
|
27
|
-
"@marianmeres/http-utils": "^2.
|
|
25
|
+
"@marianmeres/clog": "^3.21.0",
|
|
26
|
+
"@marianmeres/collection-types": "^1.41.0",
|
|
27
|
+
"@marianmeres/http-utils": "^2.11.0",
|
|
28
28
|
"@marianmeres/pubsub": "^3.0.0",
|
|
29
29
|
"@marianmeres/store": "^3.0.1"
|
|
30
30
|
},
|