@marianmeres/ownsuite 2.2.3 → 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 +142 -112
- package/README.md +26 -29
- 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
|
```
|
|
@@ -373,7 +394,7 @@ interface OwnedCollectionState<TRow> {
|
|
|
373
394
|
|
|
374
395
|
```typescript
|
|
375
396
|
interface DomainStateWrapper<T> {
|
|
376
|
-
state: DomainState;
|
|
397
|
+
state: DomainState; // "initializing" | "ready" | "syncing" | "error"
|
|
377
398
|
data: T | null;
|
|
378
399
|
error: DomainError | null;
|
|
379
400
|
lastSyncedAt: number | null;
|
|
@@ -399,9 +420,9 @@ error → syncing (retry)
|
|
|
399
420
|
|
|
400
421
|
```typescript
|
|
401
422
|
interface DomainError {
|
|
402
|
-
code: string;
|
|
423
|
+
code: string; // e.g. "SYNC_FAILED", "FETCH_FAILED"
|
|
403
424
|
message: string;
|
|
404
|
-
operation: string;
|
|
425
|
+
operation: string; // e.g. "create", "update", "delete"
|
|
405
426
|
originalError?: unknown;
|
|
406
427
|
}
|
|
407
428
|
```
|
|
@@ -438,11 +459,11 @@ type OwnsuiteEvent =
|
|
|
438
459
|
| StateChangedEvent
|
|
439
460
|
| ErrorEvent
|
|
440
461
|
| SyncedEvent
|
|
441
|
-
| ListFetchedEvent
|
|
442
|
-
| RowFetchedEvent
|
|
443
|
-
| RowCreatedEvent
|
|
444
|
-
| RowUpdatedEvent
|
|
445
|
-
| RowDeletedEvent;
|
|
462
|
+
| ListFetchedEvent // + count
|
|
463
|
+
| RowFetchedEvent // + rowId
|
|
464
|
+
| RowCreatedEvent // + rowId
|
|
465
|
+
| RowUpdatedEvent // + rowId
|
|
466
|
+
| RowDeletedEvent; // + rowId
|
|
446
467
|
```
|
|
447
468
|
|
|
448
469
|
See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
|
|
@@ -453,7 +474,13 @@ See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
|
|
|
453
474
|
interface MockAdapterOptions<TRow> {
|
|
454
475
|
seed?: TRow[];
|
|
455
476
|
delayMs?: number;
|
|
456
|
-
failOn?: {
|
|
477
|
+
failOn?: {
|
|
478
|
+
list?: boolean;
|
|
479
|
+
getOne?: boolean;
|
|
480
|
+
create?: boolean;
|
|
481
|
+
update?: boolean;
|
|
482
|
+
delete?: boolean;
|
|
483
|
+
};
|
|
457
484
|
getRowId?: (row: TRow) => string;
|
|
458
485
|
newId?: () => string;
|
|
459
486
|
/** Reject create payloads containing `model_id` (default: true). */
|
|
@@ -516,6 +543,7 @@ Attached automatically when `adapters.auth` is passed to `createOwnsuite`. See a
|
|
|
516
543
|
Default `AuthAdapter` pointing at a conventional account REST surface (register / login / logout / OAuth / verify).
|
|
517
544
|
|
|
518
545
|
**Parameters:**
|
|
546
|
+
|
|
519
547
|
- `options` (`StackAccountAdapterOptions`, optional)
|
|
520
548
|
- `options.baseUrl` (`string`, optional) — mount path. Default: `"/api/account"`.
|
|
521
549
|
- `options.fetch` (`typeof fetch`, optional) — custom fetch (tests / SSR).
|
|
@@ -554,18 +582,19 @@ In-memory mock for tests and demos. `createMockAuthStore` builds a shared state
|
|
|
554
582
|
**`verifyMockAccount(store, email)`** marks an account as verified — stands in for the user clicking the link in a real verification email.
|
|
555
583
|
|
|
556
584
|
**Example:**
|
|
585
|
+
|
|
557
586
|
```typescript
|
|
558
587
|
const store = createMockAuthStore({ requireVerifiedEmail: true });
|
|
559
588
|
const suite = createOwnsuite({
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
589
|
+
adapters: {
|
|
590
|
+
auth: createMockAuthAdapter(store),
|
|
591
|
+
profile: createMockProfileAdapter(store),
|
|
592
|
+
},
|
|
564
593
|
});
|
|
565
594
|
await suite.auth!.register({
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
595
|
+
email: "alice@example.com",
|
|
596
|
+
password: "mysecretpassword",
|
|
597
|
+
password_confirm: "mysecretpassword",
|
|
569
598
|
});
|
|
570
599
|
// suite.session!.get().status === "unverified"
|
|
571
600
|
verifyMockAccount(store, "alice@example.com");
|
|
@@ -611,17 +640,17 @@ Clear storage and in-memory state. Called by `suite.destroy()`.
|
|
|
611
640
|
|
|
612
641
|
Verbs only. Attached as `suite.auth`. No state of its own — results flow into `SessionManager`.
|
|
613
642
|
|
|
614
|
-
| Method
|
|
615
|
-
|
|
616
|
-
| `register({ email, password, password_confirm, roles?, extras? }, options?)`
|
|
617
|
-
| `login({ email, password }, options?)`
|
|
618
|
-
| `logout()`
|
|
619
|
-
| `resendVerification({ email, lang? })`
|
|
620
|
-
| `requestPasswordReset({ email, lang? })`
|
|
621
|
-
| `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset.
|
|
622
|
-
| `deleteAccount({ password?, confirm? })`
|
|
623
|
-
| `initiateOAuth(provider, opts)`
|
|
624
|
-
| `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. |
|
|
625
654
|
|
|
626
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.
|
|
627
656
|
|
|
@@ -631,14 +660,14 @@ Successful identity changes (register-with-autologin, login, OAuth login, logout
|
|
|
631
660
|
|
|
632
661
|
Singleton `/me` manager. Attached as `suite.profile`.
|
|
633
662
|
|
|
634
|
-
| Method
|
|
635
|
-
|
|
636
|
-
| `fetch()`
|
|
637
|
-
| `update({ email?, current_password? })` | PUT `/me`. Updates the session subject in place; emits `profile:updated`.
|
|
638
|
-
| `listOAuth()`
|
|
639
|
-
| `unlinkOAuth(provider)`
|
|
640
|
-
| `get()` / `subscribe(fn)`
|
|
641
|
-
| `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. |
|
|
642
671
|
|
|
643
672
|
---
|
|
644
673
|
|
|
@@ -647,6 +676,7 @@ Singleton `/me` manager. Attached as `suite.profile`.
|
|
|
647
676
|
Open an OAuth popup and await the server's `postMessage`.
|
|
648
677
|
|
|
649
678
|
**Parameters:**
|
|
679
|
+
|
|
650
680
|
- `url` (`string`) — server OAuth init URL.
|
|
651
681
|
- `options` (`OpenOAuthPopupOptions`, optional)
|
|
652
682
|
- `options.host` (`PopupWindowHost`, optional) — shim for tests. Default: `globalThis`.
|
|
@@ -665,10 +695,10 @@ Open an OAuth popup and await the server's `postMessage`.
|
|
|
665
695
|
|
|
666
696
|
```typescript
|
|
667
697
|
interface SessionState {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
698
|
+
status: "anonymous" | "authenticated" | "unverified";
|
|
699
|
+
subject: SessionSubject | null;
|
|
700
|
+
jwt: string | null;
|
|
701
|
+
expiresAt: number | null; // unix seconds
|
|
672
702
|
}
|
|
673
703
|
```
|
|
674
704
|
|
|
@@ -676,11 +706,11 @@ interface SessionState {
|
|
|
676
706
|
|
|
677
707
|
```typescript
|
|
678
708
|
interface SessionSubject {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
709
|
+
id: string;
|
|
710
|
+
email: string;
|
|
711
|
+
roles: string[];
|
|
712
|
+
isVerified: boolean;
|
|
713
|
+
hasPassword: boolean; // OAuth-only accounts: false
|
|
684
714
|
}
|
|
685
715
|
```
|
|
686
716
|
|
|
@@ -690,9 +720,9 @@ interface SessionSubject {
|
|
|
690
720
|
type SessionStatus = "anonymous" | "authenticated" | "unverified";
|
|
691
721
|
|
|
692
722
|
interface SessionStorage {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
723
|
+
get(key: string): string | null;
|
|
724
|
+
set(key: string, value: string): void;
|
|
725
|
+
del(key: string): void;
|
|
696
726
|
}
|
|
697
727
|
|
|
698
728
|
type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
|
|
@@ -702,13 +732,13 @@ type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
|
|
|
702
732
|
|
|
703
733
|
```typescript
|
|
704
734
|
interface AuthTokenResult {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
712
742
|
}
|
|
713
743
|
```
|
|
714
744
|
|
|
@@ -716,11 +746,11 @@ interface AuthTokenResult {
|
|
|
716
746
|
|
|
717
747
|
```typescript
|
|
718
748
|
interface ProfileResult {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
749
|
+
email: string;
|
|
750
|
+
roles: string[];
|
|
751
|
+
isVerified: boolean;
|
|
752
|
+
hasPassword: boolean;
|
|
753
|
+
oauthConnections: OAuthConnection[];
|
|
724
754
|
}
|
|
725
755
|
```
|
|
726
756
|
|
|
@@ -731,25 +761,25 @@ type OAuthProvider = "google" | "facebook" | "apple" | "twitter";
|
|
|
731
761
|
type OAuthAction = "login" | "link";
|
|
732
762
|
|
|
733
763
|
interface OAuthInitOptions {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
764
|
+
action: OAuthAction;
|
|
765
|
+
redirect?: string;
|
|
766
|
+
lang?: string;
|
|
767
|
+
mode?: "popup" | "redirect"; // default "popup"
|
|
768
|
+
remember?: boolean; // same semantics as AuthActionOptions.remember
|
|
739
769
|
}
|
|
740
770
|
|
|
741
771
|
interface AuthActionOptions {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
772
|
+
/** true → localStorage; false → sessionStorage; undefined → default.
|
|
773
|
+
* Ignored when SessionManager was constructed with a custom
|
|
774
|
+
* SessionStorage object. */
|
|
775
|
+
remember?: boolean;
|
|
746
776
|
}
|
|
747
777
|
|
|
748
778
|
interface OAuthConnection {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
779
|
+
provider: OAuthProvider;
|
|
780
|
+
display_name?: string;
|
|
781
|
+
avatar_url?: string;
|
|
782
|
+
email?: string;
|
|
753
783
|
}
|
|
754
784
|
```
|
|
755
785
|
|
|
@@ -757,15 +787,15 @@ interface OAuthConnection {
|
|
|
757
787
|
|
|
758
788
|
```typescript
|
|
759
789
|
interface AuthAdapter {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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 }>;
|
|
769
799
|
}
|
|
770
800
|
```
|
|
771
801
|
|
|
@@ -775,10 +805,10 @@ Parameter shapes match [`src/types/auth.ts`](src/types/auth.ts). Implementations
|
|
|
775
805
|
|
|
776
806
|
```typescript
|
|
777
807
|
interface ProfileAdapter {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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>;
|
|
782
812
|
}
|
|
783
813
|
```
|
|
784
814
|
|
|
@@ -786,8 +816,8 @@ interface ProfileAdapter {
|
|
|
786
816
|
|
|
787
817
|
```typescript
|
|
788
818
|
interface StackAccountAdapterOptions {
|
|
789
|
-
|
|
790
|
-
|
|
819
|
+
baseUrl?: string; // default "/api/account"
|
|
820
|
+
fetch?: typeof fetch;
|
|
791
821
|
}
|
|
792
822
|
```
|
|
793
823
|
|
|
@@ -799,18 +829,18 @@ See [src/oauth/popup.ts](src/oauth/popup.ts) for full definitions. `OAuthPopupMe
|
|
|
799
829
|
|
|
800
830
|
```typescript
|
|
801
831
|
interface MockAccount {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
832
|
+
email: string;
|
|
833
|
+
password: string; // plaintext — mock does no hashing
|
|
834
|
+
roles: string[];
|
|
835
|
+
isVerified: boolean;
|
|
836
|
+
hasPassword: boolean;
|
|
837
|
+
oauthConnections: OAuthConnection[];
|
|
808
838
|
}
|
|
809
839
|
|
|
810
840
|
interface MockAuthStore {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
841
|
+
accounts: Map<string, MockAccount>;
|
|
842
|
+
requireVerifiedEmail: boolean;
|
|
843
|
+
jwtsByEmail: Map<string, string>;
|
|
814
844
|
}
|
|
815
845
|
```
|
|
816
846
|
|
|
@@ -822,13 +852,13 @@ Fields are public by design so test code can peek at / mutate them directly.
|
|
|
822
852
|
|
|
823
853
|
Emitted on the shared pubsub. Each payload has a `timestamp` (ms).
|
|
824
854
|
|
|
825
|
-
| Event
|
|
826
|
-
|
|
827
|
-
| `auth:register`
|
|
828
|
-
| `auth:login`
|
|
829
|
-
| `auth:logout`
|
|
830
|
-
| `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 }` |
|
|
831
861
|
| `auth:verification:required` | `{ email }` (fired when status flips to `"unverified"`) |
|
|
832
|
-
| `profile:updated`
|
|
833
|
-
| `oauth:linked`
|
|
834
|
-
| `oauth:unlinked`
|
|
862
|
+
| `profile:updated` | `{ email }` |
|
|
863
|
+
| `oauth:linked` | `{ connection: OAuthConnection }` |
|
|
864
|
+
| `oauth:unlinked` | `{ provider: OAuthProvider }` |
|
package/README.md
CHANGED
|
@@ -29,44 +29,44 @@ Pass an `AuthAdapter` to `createOwnsuite` to attach the account-lifecycle manage
|
|
|
29
29
|
|
|
30
30
|
```typescript
|
|
31
31
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
createOwnsuite,
|
|
33
|
+
createStackAccountAuthAdapter,
|
|
34
|
+
createStackAccountProfileAdapter,
|
|
35
35
|
} from "@marianmeres/ownsuite";
|
|
36
36
|
|
|
37
37
|
const suite = createOwnsuite({
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
},
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
// Observable session — UI subscribes to this for logged-in state.
|
|
52
52
|
suite.session!.subscribe(({ status, subject }) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// Register → server requires email verification by default
|
|
59
59
|
await suite.auth!.register({
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
email: "alice@example.com",
|
|
61
|
+
password: "mysecretpassword",
|
|
62
|
+
password_confirm: "mysecretpassword",
|
|
63
63
|
});
|
|
64
64
|
// suite.session!.get().status === "unverified"
|
|
65
65
|
|
|
66
66
|
// After the user clicks the email link and the server flips isVerified:
|
|
67
67
|
await suite.auth!.login(
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
{ email: "alice@example.com", password: "mysecretpassword" },
|
|
69
|
+
{ remember: true }, // true → localStorage; false → sessionStorage (per-login override)
|
|
70
70
|
);
|
|
71
71
|
// suite.session!.get().status === "authenticated"
|
|
72
72
|
// Every registered owner-scoped domain is re-initialized with the new JWT.
|
|
@@ -77,8 +77,8 @@ await suite.auth!.initiateOAuth("google", { action: "login" });
|
|
|
77
77
|
// Profile edit — changing email resets isVerified server-side and dispatches
|
|
78
78
|
// a new verification email. Session subject is patched in place.
|
|
79
79
|
await suite.profile!.update({
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
email: "renamed@example.com",
|
|
81
|
+
current_password: "mysecretpassword",
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
// Logout — revokes JWT server-side and clears local session storage.
|
|
@@ -157,14 +157,11 @@ Each domain holds a single list of rows owned by the authenticated subject. List
|
|
|
157
157
|
## Testing with the mock adapter
|
|
158
158
|
|
|
159
159
|
```typescript
|
|
160
|
-
import {
|
|
161
|
-
createMockOwnedCollectionAdapter,
|
|
162
|
-
createOwnsuite,
|
|
163
|
-
} from "@marianmeres/ownsuite";
|
|
160
|
+
import { createMockOwnedCollectionAdapter, createOwnsuite } from "@marianmeres/ownsuite";
|
|
164
161
|
|
|
165
162
|
const adapter = createMockOwnedCollectionAdapter({
|
|
166
163
|
seed: [{ model_id: "1", data: { label: "hello" } }],
|
|
167
|
-
failOn: { update: true },
|
|
164
|
+
failOn: { update: true }, // force update failures for rollback tests
|
|
168
165
|
});
|
|
169
166
|
|
|
170
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
|
},
|