@marianmeres/ownsuite 2.1.0 → 2.2.2
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 +11 -4
- package/API.md +27 -6
- package/README.md +5 -2
- package/dist/adapters/mock-auth.d.ts +24 -2
- package/dist/adapters/mock-auth.js +8 -0
- package/dist/adapters/mock.d.ts +2 -0
- package/dist/adapters/stack-account.d.ts +7 -0
- package/dist/adapters/stack-account.js +6 -0
- package/dist/domains/auth.d.ts +49 -5
- package/dist/domains/auth.js +59 -10
- package/dist/domains/base.d.ts +20 -0
- package/dist/domains/base.js +18 -0
- package/dist/domains/owned-collection.d.ts +5 -0
- package/dist/domains/owned-collection.js +2 -0
- package/dist/domains/profile.d.ts +18 -0
- package/dist/domains/profile.js +11 -0
- package/dist/domains/session.d.ts +35 -7
- package/dist/domains/session.js +131 -26
- package/dist/mod.d.ts +3 -0
- package/dist/oauth/popup.d.ts +25 -0
- package/dist/ownsuite.d.ts +13 -2
- package/dist/ownsuite.js +10 -2
- package/dist/types/adapter.d.ts +4 -0
- package/dist/types/auth.d.ts +88 -0
- package/dist/types/events.d.ts +59 -8
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,7 @@ Machine-readable documentation for AI coding assistants.
|
|
|
6
6
|
|
|
7
7
|
```yaml
|
|
8
8
|
name: "@marianmeres/ownsuite"
|
|
9
|
-
version: "2.
|
|
9
|
+
version: "2.1.0"
|
|
10
10
|
type: "library"
|
|
11
11
|
language: "typescript"
|
|
12
12
|
runtime: "deno"
|
|
@@ -153,16 +153,23 @@ export {
|
|
|
153
153
|
createMockAuthAdapter, createMockProfileAdapter,
|
|
154
154
|
createMockAuthStore, verifyMockAccount,
|
|
155
155
|
} from "./adapters/mod.ts";
|
|
156
|
-
export type { MockAuthStore } from "./adapters/mod.ts";
|
|
156
|
+
export type { MockAccount, MockAuthStore } from "./adapters/mod.ts";
|
|
157
|
+
|
|
158
|
+
// Upstream types re-exported so they appear in the public doc graph
|
|
159
|
+
// (they already surface as return types of subscribe / on). Not intended
|
|
160
|
+
// as primary consumer API — use them only when typing wrappers.
|
|
161
|
+
export type { PubSub, Subscriber, Unsubscriber } from "@marianmeres/pubsub";
|
|
162
|
+
export type { StoreLike } from "@marianmeres/store";
|
|
163
|
+
export type { Clog } from "@marianmeres/clog";
|
|
157
164
|
```
|
|
158
165
|
|
|
159
166
|
## Account lifecycle (optional)
|
|
160
167
|
|
|
161
168
|
When `adapters.auth` is supplied, `createOwnsuite` instantiates three extra managers and attaches them as readonly suite properties:
|
|
162
169
|
|
|
163
|
-
- **`suite.session: SessionManager`** — reactive `{ status, subject, jwt, expiresAt }` persisted via pluggable `SessionStorage` (`"local"` / `"session"` / `"memory"` / custom object).
|
|
170
|
+
- **`suite.session: SessionManager`** — reactive `{ status, subject, jwt, expiresAt }` persisted via pluggable `SessionStorage` (`"local"` / `"session"` / `"memory"` / custom object). With a built-in string backend, hydrates by probing in order `local → session → memory` and adopts the first non-expired payload as the active backend for the instance's lifetime (losing backends are wiped). `setAuthenticated({ storage })` pins the session to a specific built-in backend per login ("Remember me"); subsequent `patchSubject` / `setUnverified` writes land on the same backend. `clear()` wipes every built-in backend so stale blobs from a previous toggle don't leak back in. Exposes `subscribe` (Svelte-compatible), `get()`, and state-mutation methods (`setAuthenticated`, `setUnverified`, `clear`, `patchSubject`). Writes are driven by `AuthManager`, not consumers directly.
|
|
164
171
|
|
|
165
|
-
- **`suite.auth: AuthManager`** — verbs only, no state. `register` / `login` / `logout` / `resendVerification` / `requestPasswordReset` / `changePassword` / `deleteAccount` / `initiateOAuth` (`mode: "popup" | "redirect"`) / `handleOAuthCallback` (redirect mode). Each call pipes the result into the session and fires an `onIdentityChanged` hook that resets + re-initializes every owner-scoped domain with the fresh context.
|
|
172
|
+
- **`suite.auth: AuthManager`** — verbs only, no state. `register(input, options?)` / `login(input, options?)` / `logout` / `resendVerification` / `requestPasswordReset` / `changePassword` / `deleteAccount` / `initiateOAuth(provider, opts)` (`mode: "popup" | "redirect"`) / `handleOAuthCallback(options?)` (redirect mode). The `options.remember` boolean (`true` → localStorage, `false` → sessionStorage, `undefined` → session manager default; same field on `OAuthInitOptions`) pins the resulting session's storage backend. Each call pipes the result into the session and fires an `onIdentityChanged` hook that resets + re-initializes every owner-scoped domain with the fresh context.
|
|
166
173
|
|
|
167
174
|
- **`suite.profile: ProfileManager`** — singleton (one-row) `/me` CRUD. `fetch` / `update` / `listOAuth` / `unlinkOAuth`. Every successful fetch/update patches the session subject in place so consumers reading `suite.session` see email / roles / verification / connections without a second fetch. Update emits `profile:updated`.
|
|
168
175
|
|
package/API.md
CHANGED
|
@@ -593,10 +593,12 @@ Current JWT, or `null` when anonymous.
|
|
|
593
593
|
|
|
594
594
|
Shorthand reads.
|
|
595
595
|
|
|
596
|
-
#### `session.setAuthenticated({ jwt, subject, expiresAt? })`
|
|
596
|
+
#### `session.setAuthenticated({ jwt, subject, expiresAt?, storage? })`
|
|
597
597
|
|
|
598
598
|
Enter the `authenticated` state. Normally called by `AuthManager`, not consumers.
|
|
599
599
|
|
|
600
|
+
- `storage` (`SessionStorageType`, optional) — per-login storage pin. When `"local"` / `"session"` / `"memory"`, switches the active built-in backend for this session (and for subsequent `patchSubject` / `setUnverified` writes). The previously-active built-in backend's blob is wiped as part of the switch so "Remember me" toggles don't leave stale data. Silently ignored when the manager was constructed with a custom `SessionStorage` object, or when the override is itself an object.
|
|
601
|
+
|
|
600
602
|
#### `session.setUnverified(email)` / `session.clear()` / `session.patchSubject(patch)`
|
|
601
603
|
|
|
602
604
|
Mutation helpers — typically driven by `AuthManager` / `ProfileManager`.
|
|
@@ -613,15 +615,15 @@ Verbs only. Attached as `suite.auth`. No state of its own — results flow into
|
|
|
613
615
|
|
|
614
616
|
| Method | Purpose |
|
|
615
617
|
|---|---|
|
|
616
|
-
| `register({ email, password, password_confirm, roles?, extras? })` | Create account. Returns an `AuthTokenResult`. When the server's verification gate is on, the result carries `requiresVerification: true` and the session flips to `"unverified"` — no JWT yet. |
|
|
617
|
-
| `login({ email, password })` | Exchange credentials for a JWT. Session flips to `"authenticated"` on success, `"unverified"` if the server reports the gate. |
|
|
618
|
+
| `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. |
|
|
619
|
+
| `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`). |
|
|
618
620
|
| `logout()` | Best-effort server revoke + local clear. Idempotent. |
|
|
619
621
|
| `resendVerification({ email, lang? })` | Trigger a fresh verification email. Anti-enumeration: always resolves. |
|
|
620
622
|
| `requestPasswordReset({ email, lang? })` | Trigger a password-reset email. Anti-enumeration. |
|
|
621
623
|
| `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset. |
|
|
622
624
|
| `deleteAccount({ password?, confirm? })` | Irreversible server delete + local session clear + identity-changed hook. |
|
|
623
|
-
| `initiateOAuth(provider, opts)` | Start an OAuth flow. `mode: "popup"` (default) resolves with the auth result from the popup's `postMessage`; `mode: "redirect"` navigates the top window. |
|
|
624
|
-
| `handleOAuthCallback()` | For `mode: "redirect"` apps, call from your callback route to extract the result from the URL (delegated to `adapter.handleOAuthCallback`). |
|
|
625
|
+
| `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"`). |
|
|
626
|
+
| `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
627
|
|
|
626
628
|
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
629
|
|
|
@@ -735,6 +737,14 @@ interface OAuthInitOptions {
|
|
|
735
737
|
redirect?: string;
|
|
736
738
|
lang?: string;
|
|
737
739
|
mode?: "popup" | "redirect"; // default "popup"
|
|
740
|
+
remember?: boolean; // same semantics as AuthActionOptions.remember
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
interface AuthActionOptions {
|
|
744
|
+
/** true → localStorage; false → sessionStorage; undefined → default.
|
|
745
|
+
* Ignored when SessionManager was constructed with a custom
|
|
746
|
+
* SessionStorage object. */
|
|
747
|
+
remember?: boolean;
|
|
738
748
|
}
|
|
739
749
|
|
|
740
750
|
interface OAuthConnection {
|
|
@@ -787,9 +797,18 @@ interface StackAccountAdapterOptions {
|
|
|
787
797
|
|
|
788
798
|
See [src/oauth/popup.ts](src/oauth/popup.ts) for full definitions. `OAuthPopupMessage` is a union of `OAuthPopupLoginMessage` (`{ type: "oauth_login_success", jwt, email, roles?, ... }`) and `OAuthPopupLinkMessage` (`{ type: "oauth_link_success", provider }`). Errors posted by the server (`{ type: "oauth_error", error }`) cause the promise to reject.
|
|
789
799
|
|
|
790
|
-
### `MockAuthStore`
|
|
800
|
+
### `MockAccount` / `MockAuthStore`
|
|
791
801
|
|
|
792
802
|
```typescript
|
|
803
|
+
interface MockAccount {
|
|
804
|
+
email: string;
|
|
805
|
+
password: string; // plaintext — mock does no hashing
|
|
806
|
+
roles: string[];
|
|
807
|
+
isVerified: boolean;
|
|
808
|
+
hasPassword: boolean;
|
|
809
|
+
oauthConnections: OAuthConnection[];
|
|
810
|
+
}
|
|
811
|
+
|
|
793
812
|
interface MockAuthStore {
|
|
794
813
|
accounts: Map<string, MockAccount>;
|
|
795
814
|
requireVerifiedEmail: boolean;
|
|
@@ -797,6 +816,8 @@ interface MockAuthStore {
|
|
|
797
816
|
}
|
|
798
817
|
```
|
|
799
818
|
|
|
819
|
+
Fields are public by design so test code can peek at / mutate them directly.
|
|
820
|
+
|
|
800
821
|
---
|
|
801
822
|
|
|
802
823
|
## Account-lifecycle events
|
package/README.md
CHANGED
|
@@ -67,7 +67,10 @@ await suite.auth!.register({
|
|
|
67
67
|
// suite.session!.get().status === "unverified"
|
|
68
68
|
|
|
69
69
|
// After the user clicks the email link and the server flips isVerified:
|
|
70
|
-
await suite.auth!.login(
|
|
70
|
+
await suite.auth!.login(
|
|
71
|
+
{ email: "alice@example.com", password: "mysecretpassword" },
|
|
72
|
+
{ remember: true }, // true → localStorage; false → sessionStorage (per-login override)
|
|
73
|
+
);
|
|
71
74
|
// suite.session!.get().status === "authenticated"
|
|
72
75
|
// Every registered owner-scoped domain is re-initialized with the new JWT.
|
|
73
76
|
|
|
@@ -86,7 +89,7 @@ await suite.profile!.update({
|
|
|
86
89
|
await suite.auth!.logout();
|
|
87
90
|
```
|
|
88
91
|
|
|
89
|
-
Session state is persisted through a pluggable `SessionStorage` backend (`"local"` / `"session"` / `"memory"` / custom object with `get`/`set`/`del`). Expired stored sessions are discarded on construction so a reload after the JWT lapses starts anonymous.
|
|
92
|
+
Session state is persisted through a pluggable `SessionStorage` backend (`"local"` / `"session"` / `"memory"` / custom object with `get`/`set`/`del`). Expired stored sessions are discarded on construction so a reload after the JWT lapses starts anonymous. With a built-in string backend, hydration probes `local → session → memory` and adopts whichever holds a non-expired payload — the per-login `remember` flag on `auth.login` / `auth.register` / `auth.initiateOAuth` (`true` → `localStorage`, `false` → `sessionStorage`) switches the active backend for that session, and `session.clear()` wipes all built-in backends so toggles can't leak stale data.
|
|
90
93
|
|
|
91
94
|
Tests can use the in-memory mock adapters (`createMockAuthAdapter`, `createMockProfileAdapter`, `createMockAuthStore`, `verifyMockAccount`) and the injectable popup host for deterministic OAuth dances.
|
|
92
95
|
|
|
@@ -10,15 +10,27 @@
|
|
|
10
10
|
* adapters close over, so tests can peek at or mutate state directly.
|
|
11
11
|
*/
|
|
12
12
|
import type { AuthAdapter, OAuthConnection, ProfileAdapter } from "../types/mod.js";
|
|
13
|
-
|
|
13
|
+
/** In-memory account record held by {@link MockAuthStore}. Test code may
|
|
14
|
+
* construct these via the `seed` option of {@link createMockAuthStore}. */
|
|
15
|
+
export interface MockAccount {
|
|
16
|
+
/** Email (and implicit primary key in the mock store). */
|
|
14
17
|
email: string;
|
|
18
|
+
/** Plaintext password — the mock runs no hashing. */
|
|
15
19
|
password: string;
|
|
20
|
+
/** Authorization roles returned on the next login / `/me` read. */
|
|
16
21
|
roles: string[];
|
|
22
|
+
/** Verified flag — flipped by {@link verifyMockAccount}. */
|
|
17
23
|
isVerified: boolean;
|
|
24
|
+
/** Whether the account has a local password (OAuth-only accounts: `false`). */
|
|
18
25
|
hasPassword: boolean;
|
|
26
|
+
/** Linked OAuth provider connections. */
|
|
19
27
|
oauthConnections: OAuthConnection[];
|
|
20
28
|
}
|
|
29
|
+
/** In-memory store backing the mock auth + profile adapters. Pass the same
|
|
30
|
+
* store to `createMockAuthAdapter` and `createMockProfileAdapter` so they
|
|
31
|
+
* share state. Fields are public so test code can peek or mutate them. */
|
|
21
32
|
export interface MockAuthStore {
|
|
33
|
+
/** Account records, keyed by email. */
|
|
22
34
|
accounts: Map<string, MockAccount>;
|
|
23
35
|
/** If true, register/login return requiresVerification=true until the
|
|
24
36
|
* email is explicitly verified via `verifyMockAccount()`. */
|
|
@@ -26,13 +38,23 @@ export interface MockAuthStore {
|
|
|
26
38
|
/** Last-issued JWT per email (just a synthetic string). */
|
|
27
39
|
jwtsByEmail: Map<string, string>;
|
|
28
40
|
}
|
|
41
|
+
/** Create a fresh in-memory {@link MockAuthStore}. Pass `seed` to preload
|
|
42
|
+
* accounts, `requireVerifiedEmail: true` to make login gate on verification. */
|
|
29
43
|
export declare function createMockAuthStore(init?: {
|
|
44
|
+
/** When true, register/login return `requiresVerification: true` until
|
|
45
|
+
* the account is verified via {@link verifyMockAccount}. Default: `false`. */
|
|
30
46
|
requireVerifiedEmail?: boolean;
|
|
47
|
+
/** Accounts to preload into the store. */
|
|
31
48
|
seed?: MockAccount[];
|
|
32
49
|
}): MockAuthStore;
|
|
50
|
+
/** Build an {@link AuthAdapter} backed by the given in-memory
|
|
51
|
+
* {@link MockAuthStore}. Simulates register / login / password change /
|
|
52
|
+
* delete without a server. JWTs are synthetic strings — do not ship. */
|
|
33
53
|
export declare function createMockAuthAdapter(store: MockAuthStore): AuthAdapter;
|
|
54
|
+
/** Build a {@link ProfileAdapter} backed by the given {@link MockAuthStore}.
|
|
55
|
+
* Shares state with an auth adapter created from the same store so login-
|
|
56
|
+
* then-`/me` round-trips reflect each other. */
|
|
34
57
|
export declare function createMockProfileAdapter(store: MockAuthStore): ProfileAdapter;
|
|
35
58
|
/** Test helper — mark a mock account as verified as if the user had clicked
|
|
36
59
|
* the link in the verification email. */
|
|
37
60
|
export declare function verifyMockAccount(store: MockAuthStore, email: string): void;
|
|
38
|
-
export {};
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Deliberately small: everything is held in a shared `Store` object the
|
|
10
10
|
* adapters close over, so tests can peek at or mutate state directly.
|
|
11
11
|
*/
|
|
12
|
+
/** Create a fresh in-memory {@link MockAuthStore}. Pass `seed` to preload
|
|
13
|
+
* accounts, `requireVerifiedEmail: true` to make login gate on verification. */
|
|
12
14
|
export function createMockAuthStore(init = {}) {
|
|
13
15
|
const store = {
|
|
14
16
|
accounts: new Map(),
|
|
@@ -23,6 +25,9 @@ export function createMockAuthStore(init = {}) {
|
|
|
23
25
|
function mintJwt(email) {
|
|
24
26
|
return `mock.${btoa(email)}.${Date.now().toString(36)}`;
|
|
25
27
|
}
|
|
28
|
+
/** Build an {@link AuthAdapter} backed by the given in-memory
|
|
29
|
+
* {@link MockAuthStore}. Simulates register / login / password change /
|
|
30
|
+
* delete without a server. JWTs are synthetic strings — do not ship. */
|
|
26
31
|
export function createMockAuthAdapter(store) {
|
|
27
32
|
return {
|
|
28
33
|
register(input, _ctx) {
|
|
@@ -148,6 +153,9 @@ export function createMockAuthAdapter(store) {
|
|
|
148
153
|
},
|
|
149
154
|
};
|
|
150
155
|
}
|
|
156
|
+
/** Build a {@link ProfileAdapter} backed by the given {@link MockAuthStore}.
|
|
157
|
+
* Shares state with an auth adapter created from the same store so login-
|
|
158
|
+
* then-`/me` round-trips reflect each other. */
|
|
151
159
|
export function createMockProfileAdapter(store) {
|
|
152
160
|
function emailFromCtx(ctx) {
|
|
153
161
|
const jwt = ctx.jwt;
|
package/dist/adapters/mock.d.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* path deterministically.
|
|
9
9
|
*/
|
|
10
10
|
import type { OwnedCollectionAdapter } from "../types/adapter.js";
|
|
11
|
+
/** Options for {@link createMockOwnedCollectionAdapter}. Only present in
|
|
12
|
+
* tests / storybooks — production code never calls this. */
|
|
11
13
|
export interface MockAdapterOptions<TRow> {
|
|
12
14
|
/** Initial seed rows. */
|
|
13
15
|
seed?: TRow[];
|
|
@@ -28,11 +28,18 @@
|
|
|
28
28
|
* AuthAdapter / ProfileAdapter interfaces.
|
|
29
29
|
*/
|
|
30
30
|
import type { AuthAdapter, ProfileAdapter } from "../types/mod.js";
|
|
31
|
+
/** Options shared by the two stack-account adapter factories. */
|
|
31
32
|
export interface StackAccountAdapterOptions {
|
|
32
33
|
/** Base URL of the mounted stack-account app. Default: "/api/account". */
|
|
33
34
|
baseUrl?: string;
|
|
34
35
|
/** Override the `fetch` implementation (useful for tests / SSR). */
|
|
35
36
|
fetch?: typeof fetch;
|
|
36
37
|
}
|
|
38
|
+
/** Build the default {@link AuthAdapter} for `@marianmeres/stack-account`.
|
|
39
|
+
* Points at `{baseUrl}/auth/*` (register/login/logout/verify/password/
|
|
40
|
+
* delete) and `{baseUrl}/oauth/*` (init + callback). */
|
|
37
41
|
export declare function createStackAccountAuthAdapter(opts?: StackAccountAdapterOptions): AuthAdapter;
|
|
42
|
+
/** Build the default {@link ProfileAdapter} for `@marianmeres/stack-account`.
|
|
43
|
+
* Points at `{baseUrl}/me` (GET + PUT) and `{baseUrl}/me/oauth/*` for
|
|
44
|
+
* connection listing / unlinking. */
|
|
38
45
|
export declare function createStackAccountProfileAdapter(opts?: StackAccountAdapterOptions): ProfileAdapter;
|
|
@@ -82,6 +82,9 @@ async function requestJson(doFetch, url, init, ctx) {
|
|
|
82
82
|
return undefined;
|
|
83
83
|
return (await res.json());
|
|
84
84
|
}
|
|
85
|
+
/** Build the default {@link AuthAdapter} for `@marianmeres/stack-account`.
|
|
86
|
+
* Points at `{baseUrl}/auth/*` (register/login/logout/verify/password/
|
|
87
|
+
* delete) and `{baseUrl}/oauth/*` (init + callback). */
|
|
85
88
|
export function createStackAccountAuthAdapter(opts = {}) {
|
|
86
89
|
const base = opts.baseUrl ?? "/api/account";
|
|
87
90
|
const doFetch = resolveFetch(opts);
|
|
@@ -123,6 +126,9 @@ export function createStackAccountAuthAdapter(opts = {}) {
|
|
|
123
126
|
},
|
|
124
127
|
};
|
|
125
128
|
}
|
|
129
|
+
/** Build the default {@link ProfileAdapter} for `@marianmeres/stack-account`.
|
|
130
|
+
* Points at `{baseUrl}/me` (GET + PUT) and `{baseUrl}/me/oauth/*` for
|
|
131
|
+
* connection listing / unlinking. */
|
|
126
132
|
export function createStackAccountProfileAdapter(opts = {}) {
|
|
127
133
|
const base = opts.baseUrl ?? "/api/account";
|
|
128
134
|
const doFetch = resolveFetch(opts);
|
package/dist/domains/auth.d.ts
CHANGED
|
@@ -12,16 +12,22 @@
|
|
|
12
12
|
* management.
|
|
13
13
|
*/
|
|
14
14
|
import { type PubSub } from "@marianmeres/pubsub";
|
|
15
|
-
import type { AuthAdapter, AuthTokenResult, OAuthInitOptions, OAuthProvider, OwnsuiteContext, ProfileAdapter } from "../types/mod.js";
|
|
15
|
+
import type { AuthActionOptions, AuthAdapter, AuthTokenResult, OAuthInitOptions, OAuthProvider, OwnsuiteContext, ProfileAdapter } from "../types/mod.js";
|
|
16
16
|
import type { SessionManager } from "./session.js";
|
|
17
17
|
import type { ProfileManager } from "./profile.js";
|
|
18
|
+
/** Construction-time options for {@link AuthManager}. Normally assembled
|
|
19
|
+
* by `createOwnsuite` — call sites rarely instantiate this directly. */
|
|
18
20
|
export interface AuthManagerOptions {
|
|
21
|
+
/** Server adapter. The manager is stateless beyond what it pushes into
|
|
22
|
+
* {@link SessionManager}. */
|
|
19
23
|
adapter: AuthAdapter;
|
|
24
|
+
/** Session manager that receives the JWT / subject / status writes. */
|
|
20
25
|
session: SessionManager;
|
|
21
26
|
/** Profile manager — auth hydrates the session subject from `/me`
|
|
22
27
|
* immediately after login so subscribers see roles/isVerified/etc.
|
|
23
28
|
* without a second await. */
|
|
24
29
|
profile: ProfileManager;
|
|
30
|
+
/** Shared pubsub for event emission. Created privately when omitted. */
|
|
25
31
|
pubsub?: PubSub;
|
|
26
32
|
/** Called after a successful identity change (register/login/OAuth
|
|
27
33
|
* login/logout). The orchestrator wires this to reset + re-init every
|
|
@@ -35,37 +41,73 @@ export interface AuthManagerOptions {
|
|
|
35
41
|
* tests that don't need a separate profile fetch. */
|
|
36
42
|
profileAdapter?: ProfileAdapter;
|
|
37
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Verbs for the account lifecycle: register / login / logout / OAuth /
|
|
46
|
+
* password-reset / delete account. Holds no state of its own — every
|
|
47
|
+
* outcome is piped into the linked {@link SessionManager}, which is the
|
|
48
|
+
* single source of truth for JWT + subject + status. Usually accessed via
|
|
49
|
+
* `suite.auth`, not constructed directly.
|
|
50
|
+
*/
|
|
38
51
|
export declare class AuthManager {
|
|
39
52
|
#private;
|
|
53
|
+
/** Construct a new `AuthManager`. Normally called by `createOwnsuite`,
|
|
54
|
+
* not by consumers. */
|
|
40
55
|
constructor(options: AuthManagerOptions);
|
|
56
|
+
/** Merge `ctx` into the current adapter context. Keys not in `ctx`
|
|
57
|
+
* are preserved. */
|
|
41
58
|
setContext(ctx: OwnsuiteContext): void;
|
|
59
|
+
/** Replace the adapter context wholesale. Callers use this when
|
|
60
|
+
* switching subjects or clearing host-app context. */
|
|
42
61
|
replaceContext(ctx: OwnsuiteContext): void;
|
|
62
|
+
/** Create a new account. Returns the server's {@link AuthTokenResult}.
|
|
63
|
+
* When the verification gate is on, the result carries
|
|
64
|
+
* `requiresVerification: true` and the session flips to `"unverified"`
|
|
65
|
+
* (no JWT). Otherwise the session flips to `"authenticated"` and the
|
|
66
|
+
* profile is hydrated from `/me`. `options.remember` pins the session's
|
|
67
|
+
* storage backend — see {@link AuthActionOptions.remember}. */
|
|
43
68
|
register(input: {
|
|
44
69
|
email: string;
|
|
45
70
|
password: string;
|
|
46
71
|
password_confirm: string;
|
|
47
72
|
roles?: string[];
|
|
48
73
|
extras?: Record<string, unknown>;
|
|
49
|
-
}): Promise<AuthTokenResult>;
|
|
74
|
+
}, options?: AuthActionOptions): Promise<AuthTokenResult>;
|
|
75
|
+
/** Exchange credentials for a JWT. On success the session flips to
|
|
76
|
+
* `"authenticated"` and the profile is hydrated from `/me`. Rejects
|
|
77
|
+
* on bad credentials (session untouched) or — when the server's
|
|
78
|
+
* verification gate is on for an unverified account — throws through
|
|
79
|
+
* the adapter. `options.remember` controls storage persistence. */
|
|
50
80
|
login(input: {
|
|
51
81
|
email: string;
|
|
52
82
|
password: string;
|
|
53
|
-
}): Promise<AuthTokenResult>;
|
|
83
|
+
}, options?: AuthActionOptions): Promise<AuthTokenResult>;
|
|
84
|
+
/** Log out. Best-effort server revoke + unconditional local clear;
|
|
85
|
+
* the session is always wiped even if the server call fails. Fires
|
|
86
|
+
* `auth:logout` and the `onIdentityChanged` hook. Safe to call when
|
|
87
|
+
* already anonymous. */
|
|
54
88
|
logout(): Promise<void>;
|
|
89
|
+
/** Trigger a fresh verification email. Anti-enumeration: always resolves
|
|
90
|
+
* regardless of whether the address corresponds to a real account. */
|
|
55
91
|
resendVerification(input: {
|
|
56
92
|
email: string;
|
|
57
93
|
lang?: string;
|
|
58
94
|
}): Promise<void>;
|
|
95
|
+
/** Trigger a password-reset email. Anti-enumeration: always resolves. */
|
|
59
96
|
requestPasswordReset(input: {
|
|
60
97
|
email: string;
|
|
61
98
|
lang?: string;
|
|
62
99
|
}): Promise<void>;
|
|
100
|
+
/** Change the password. Pass `current_password` for an authenticated
|
|
101
|
+
* self-change, or `token` for a reset-link flow. `new_password` and
|
|
102
|
+
* `confirm_password` are always required. */
|
|
63
103
|
changePassword(input: {
|
|
64
104
|
current_password?: string;
|
|
65
105
|
new_password: string;
|
|
66
106
|
confirm_password: string;
|
|
67
107
|
token?: string;
|
|
68
108
|
}): Promise<void>;
|
|
109
|
+
/** Irreversibly delete the authenticated account. On success clears the
|
|
110
|
+
* local session and fires `auth:logout` + `onIdentityChanged`. */
|
|
69
111
|
deleteAccount(input: {
|
|
70
112
|
password?: string;
|
|
71
113
|
confirm?: boolean;
|
|
@@ -78,6 +120,8 @@ export declare class AuthManager {
|
|
|
78
120
|
*/
|
|
79
121
|
initiateOAuth(provider: OAuthProvider, opts: OAuthInitOptions): Promise<AuthTokenResult | void>;
|
|
80
122
|
/** For the redirect-mode callback page: delegate to the adapter if
|
|
81
|
-
* available to extract the result from the current URL/page state.
|
|
82
|
-
|
|
123
|
+
* available to extract the result from the current URL/page state.
|
|
124
|
+
* `options.remember` pins the resulting session to local/session
|
|
125
|
+
* storage — pass the same value the user picked before the redirect. */
|
|
126
|
+
handleOAuthCallback(options?: AuthActionOptions): Promise<AuthTokenResult | void>;
|
|
83
127
|
}
|
package/dist/domains/auth.js
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createPubSub } from "@marianmeres/pubsub";
|
|
15
15
|
import { openOAuthPopup } from "../oauth/popup.js";
|
|
16
|
+
/**
|
|
17
|
+
* Verbs for the account lifecycle: register / login / logout / OAuth /
|
|
18
|
+
* password-reset / delete account. Holds no state of its own — every
|
|
19
|
+
* outcome is piped into the linked {@link SessionManager}, which is the
|
|
20
|
+
* single source of truth for JWT + subject + status. Usually accessed via
|
|
21
|
+
* `suite.auth`, not constructed directly.
|
|
22
|
+
*/
|
|
16
23
|
export class AuthManager {
|
|
17
24
|
#pubsub;
|
|
18
25
|
#adapter;
|
|
@@ -20,6 +27,8 @@ export class AuthManager {
|
|
|
20
27
|
#profile;
|
|
21
28
|
#onIdentityChanged;
|
|
22
29
|
#context;
|
|
30
|
+
/** Construct a new `AuthManager`. Normally called by `createOwnsuite`,
|
|
31
|
+
* not by consumers. */
|
|
23
32
|
constructor(options) {
|
|
24
33
|
this.#adapter = options.adapter;
|
|
25
34
|
this.#session = options.session;
|
|
@@ -28,9 +37,13 @@ export class AuthManager {
|
|
|
28
37
|
this.#onIdentityChanged = options.onIdentityChanged;
|
|
29
38
|
this.#context = options.context ?? {};
|
|
30
39
|
}
|
|
40
|
+
/** Merge `ctx` into the current adapter context. Keys not in `ctx`
|
|
41
|
+
* are preserved. */
|
|
31
42
|
setContext(ctx) {
|
|
32
43
|
this.#context = { ...this.#context, ...ctx };
|
|
33
44
|
}
|
|
45
|
+
/** Replace the adapter context wholesale. Callers use this when
|
|
46
|
+
* switching subjects or clearing host-app context. */
|
|
34
47
|
replaceContext(ctx) {
|
|
35
48
|
this.#context = { ...ctx };
|
|
36
49
|
}
|
|
@@ -46,8 +59,13 @@ export class AuthManager {
|
|
|
46
59
|
* present, we pull the profile to build a complete subject. When the
|
|
47
60
|
* server returned requiresVerification, we surface the "unverified"
|
|
48
61
|
* status without any profile fetch. Triggers identity-change hook on
|
|
49
|
-
* actual login.
|
|
50
|
-
|
|
62
|
+
* actual login.
|
|
63
|
+
*
|
|
64
|
+
* `remember` translates to a per-login storage pin on the session:
|
|
65
|
+
* `true` → `"local"`, `false` → `"session"`, `undefined` → session
|
|
66
|
+
* manager's configured default. Silently no-op in the verification-gate
|
|
67
|
+
* branch (no JWT yet → nothing to persist). */
|
|
68
|
+
async #applyAuthResult(result, remember) {
|
|
51
69
|
if (result.requiresVerification || !result.jwt) {
|
|
52
70
|
this.#session.setUnverified(result.email);
|
|
53
71
|
this.#pubsub.publish("auth:verification:required", {
|
|
@@ -66,10 +84,16 @@ export class AuthManager {
|
|
|
66
84
|
isVerified: result.isVerified ?? false,
|
|
67
85
|
hasPassword: true, // corrected by profile fetch below
|
|
68
86
|
};
|
|
87
|
+
const storage = remember === true
|
|
88
|
+
? "local"
|
|
89
|
+
: remember === false
|
|
90
|
+
? "session"
|
|
91
|
+
: undefined;
|
|
69
92
|
this.#session.setAuthenticated({
|
|
70
93
|
jwt: result.jwt,
|
|
71
94
|
subject: provisional,
|
|
72
95
|
expiresAt: result.validUntil ?? null,
|
|
96
|
+
...(storage ? { storage } : {}),
|
|
73
97
|
});
|
|
74
98
|
// Best-effort hydrate the subject from /me. If this fails we keep the
|
|
75
99
|
// provisional subject; consumers can retry via profile.fetch().
|
|
@@ -90,7 +114,13 @@ export class AuthManager {
|
|
|
90
114
|
return result;
|
|
91
115
|
}
|
|
92
116
|
// ─────────────────────── verbs ──────────────────────────────────────────
|
|
93
|
-
|
|
117
|
+
/** Create a new account. Returns the server's {@link AuthTokenResult}.
|
|
118
|
+
* When the verification gate is on, the result carries
|
|
119
|
+
* `requiresVerification: true` and the session flips to `"unverified"`
|
|
120
|
+
* (no JWT). Otherwise the session flips to `"authenticated"` and the
|
|
121
|
+
* profile is hydrated from `/me`. `options.remember` pins the session's
|
|
122
|
+
* storage backend — see {@link AuthActionOptions.remember}. */
|
|
123
|
+
async register(input, options) {
|
|
94
124
|
const result = await this.#adapter.register(input, this.#ctx());
|
|
95
125
|
this.#pubsub.publish("auth:register", {
|
|
96
126
|
type: "auth:register",
|
|
@@ -98,17 +128,26 @@ export class AuthManager {
|
|
|
98
128
|
email: input.email,
|
|
99
129
|
requiresVerification: Boolean(result.requiresVerification),
|
|
100
130
|
});
|
|
101
|
-
return await this.#applyAuthResult(result);
|
|
131
|
+
return await this.#applyAuthResult(result, options?.remember);
|
|
102
132
|
}
|
|
103
|
-
|
|
133
|
+
/** Exchange credentials for a JWT. On success the session flips to
|
|
134
|
+
* `"authenticated"` and the profile is hydrated from `/me`. Rejects
|
|
135
|
+
* on bad credentials (session untouched) or — when the server's
|
|
136
|
+
* verification gate is on for an unverified account — throws through
|
|
137
|
+
* the adapter. `options.remember` controls storage persistence. */
|
|
138
|
+
async login(input, options) {
|
|
104
139
|
const result = await this.#adapter.login(input, this.#ctx());
|
|
105
140
|
this.#pubsub.publish("auth:login", {
|
|
106
141
|
type: "auth:login",
|
|
107
142
|
timestamp: Date.now(),
|
|
108
143
|
email: input.email,
|
|
109
144
|
});
|
|
110
|
-
return await this.#applyAuthResult(result);
|
|
145
|
+
return await this.#applyAuthResult(result, options?.remember);
|
|
111
146
|
}
|
|
147
|
+
/** Log out. Best-effort server revoke + unconditional local clear;
|
|
148
|
+
* the session is always wiped even if the server call fails. Fires
|
|
149
|
+
* `auth:logout` and the `onIdentityChanged` hook. Safe to call when
|
|
150
|
+
* already anonymous. */
|
|
112
151
|
async logout() {
|
|
113
152
|
const subjectId = this.#session.get().subject?.id;
|
|
114
153
|
try {
|
|
@@ -132,15 +171,23 @@ export class AuthManager {
|
|
|
132
171
|
}
|
|
133
172
|
}
|
|
134
173
|
}
|
|
174
|
+
/** Trigger a fresh verification email. Anti-enumeration: always resolves
|
|
175
|
+
* regardless of whether the address corresponds to a real account. */
|
|
135
176
|
async resendVerification(input) {
|
|
136
177
|
await this.#adapter.resendVerification(input, this.#ctx());
|
|
137
178
|
}
|
|
179
|
+
/** Trigger a password-reset email. Anti-enumeration: always resolves. */
|
|
138
180
|
async requestPasswordReset(input) {
|
|
139
181
|
await this.#adapter.requestPasswordReset(input, this.#ctx());
|
|
140
182
|
}
|
|
183
|
+
/** Change the password. Pass `current_password` for an authenticated
|
|
184
|
+
* self-change, or `token` for a reset-link flow. `new_password` and
|
|
185
|
+
* `confirm_password` are always required. */
|
|
141
186
|
async changePassword(input) {
|
|
142
187
|
await this.#adapter.changePassword(input, this.#ctx());
|
|
143
188
|
}
|
|
189
|
+
/** Irreversibly delete the authenticated account. On success clears the
|
|
190
|
+
* local session and fires `auth:logout` + `onIdentityChanged`. */
|
|
144
191
|
async deleteAccount(input) {
|
|
145
192
|
await this.#adapter.deleteAccount(input, this.#ctx());
|
|
146
193
|
// Clear session locally and fire logout/identity-change.
|
|
@@ -197,15 +244,17 @@ export class AuthManager {
|
|
|
197
244
|
roles: message.roles ?? [],
|
|
198
245
|
isVerified: true,
|
|
199
246
|
};
|
|
200
|
-
return await this.#applyAuthResult(result);
|
|
247
|
+
return await this.#applyAuthResult(result, opts.remember);
|
|
201
248
|
}
|
|
202
249
|
/** For the redirect-mode callback page: delegate to the adapter if
|
|
203
|
-
* available to extract the result from the current URL/page state.
|
|
204
|
-
|
|
250
|
+
* available to extract the result from the current URL/page state.
|
|
251
|
+
* `options.remember` pins the resulting session to local/session
|
|
252
|
+
* storage — pass the same value the user picked before the redirect. */
|
|
253
|
+
async handleOAuthCallback(options) {
|
|
205
254
|
if (!this.#adapter.handleOAuthCallback) {
|
|
206
255
|
throw new Error("AuthManager: handleOAuthCallback is not implemented by the adapter");
|
|
207
256
|
}
|
|
208
257
|
const result = await this.#adapter.handleOAuthCallback(this.#ctx());
|
|
209
|
-
return await this.#applyAuthResult(result);
|
|
258
|
+
return await this.#applyAuthResult(result, options?.remember);
|
|
210
259
|
}
|
|
211
260
|
}
|
package/dist/domains/base.d.ts
CHANGED
|
@@ -27,12 +27,24 @@ export interface BaseDomainOptions {
|
|
|
27
27
|
*/
|
|
28
28
|
export declare abstract class BaseDomainManager<TData, TAdapter> {
|
|
29
29
|
#private;
|
|
30
|
+
/** Reactive store holding the full {@link DomainStateWrapper} for this
|
|
31
|
+
* domain. Subclasses read + publish through it. */
|
|
30
32
|
protected readonly store: StoreLike<DomainStateWrapper<TData>>;
|
|
33
|
+
/** Shared pubsub used to emit domain lifecycle / CRUD events. */
|
|
31
34
|
protected readonly pubsub: PubSub;
|
|
35
|
+
/** Stable name of this domain (used as event `domain` and log prefix). */
|
|
32
36
|
protected readonly domainName: DomainName;
|
|
37
|
+
/** Scoped logger, prefixed with `ownsuite:<domainName>`. */
|
|
33
38
|
protected readonly clog: Clog;
|
|
39
|
+
/** Server adapter instance; `null` until `setAdapter()` / constructor
|
|
40
|
+
* installs one. */
|
|
34
41
|
protected adapter: TAdapter | null;
|
|
42
|
+
/** Current context forwarded to every adapter call (jwt, subjectId,
|
|
43
|
+
* signal, plus any consumer-supplied extras). */
|
|
35
44
|
protected context: OwnsuiteContext;
|
|
45
|
+
/** Construct a new domain manager. Not called directly — subclasses like
|
|
46
|
+
* {@link OwnedCollectionManager} extend this and are instantiated by
|
|
47
|
+
* the suite. */
|
|
36
48
|
constructor(domainName: DomainName, options?: BaseDomainOptions);
|
|
37
49
|
/** Svelte-compatible subscribe method. */
|
|
38
50
|
get subscribe(): StoreLike<DomainStateWrapper<TData>>["subscribe"];
|
|
@@ -40,7 +52,11 @@ export declare abstract class BaseDomainManager<TData, TAdapter> {
|
|
|
40
52
|
get(): DomainStateWrapper<TData>;
|
|
41
53
|
/** True after `destroy()` has been called. */
|
|
42
54
|
get isDestroyed(): boolean;
|
|
55
|
+
/** Install or replace the server adapter for this domain. Call
|
|
56
|
+
* {@link refresh} afterwards to pick up data from the new source. */
|
|
43
57
|
setAdapter(adapter: TAdapter): void;
|
|
58
|
+
/** Current adapter, or `null` when none installed (typically only in
|
|
59
|
+
* tests or between `destroy()` and re-attachment). */
|
|
44
60
|
getAdapter(): TAdapter | null;
|
|
45
61
|
/**
|
|
46
62
|
* Merge `ctx` into the current context. Keys not present in `ctx` are
|
|
@@ -49,6 +65,8 @@ export declare abstract class BaseDomainManager<TData, TAdapter> {
|
|
|
49
65
|
setContext(context: OwnsuiteContext): void;
|
|
50
66
|
/** Replace the context object entirely (no merge with existing). */
|
|
51
67
|
replaceContext(context: OwnsuiteContext): void;
|
|
68
|
+
/** Snapshot of the current context. Mutating the returned object does
|
|
69
|
+
* not affect the manager. */
|
|
52
70
|
getContext(): OwnsuiteContext;
|
|
53
71
|
/** Transition to a new state. */
|
|
54
72
|
protected setState(state: DomainState): void;
|
|
@@ -101,6 +119,8 @@ export declare abstract class BaseDomainManager<TData, TAdapter> {
|
|
|
101
119
|
* callers that prefer a whole-data restore over per-change inversion.
|
|
102
120
|
*/
|
|
103
121
|
protected withOptimisticUpdate<T>(operation: string, optimisticUpdate: () => void, serverSync: () => Promise<T>, onSuccess?: (result: T) => void, onError?: (error: DomainError, snapshot: TData | null) => void): Promise<void>;
|
|
122
|
+
/** Boot the domain — typically fetch its initial list. Subclasses
|
|
123
|
+
* implement this; see {@link OwnedCollectionManager.initialize}. */
|
|
104
124
|
abstract initialize(): Promise<void>;
|
|
105
125
|
/**
|
|
106
126
|
* Reset to `initializing` state. Aborts any in-flight reads/mutations
|
package/dist/domains/base.js
CHANGED
|
@@ -38,11 +38,20 @@ function safeClone(value) {
|
|
|
38
38
|
* @typeParam TAdapter - The adapter interface type for server communication.
|
|
39
39
|
*/
|
|
40
40
|
export class BaseDomainManager {
|
|
41
|
+
/** Reactive store holding the full {@link DomainStateWrapper} for this
|
|
42
|
+
* domain. Subclasses read + publish through it. */
|
|
41
43
|
store;
|
|
44
|
+
/** Shared pubsub used to emit domain lifecycle / CRUD events. */
|
|
42
45
|
pubsub;
|
|
46
|
+
/** Stable name of this domain (used as event `domain` and log prefix). */
|
|
43
47
|
domainName;
|
|
48
|
+
/** Scoped logger, prefixed with `ownsuite:<domainName>`. */
|
|
44
49
|
clog;
|
|
50
|
+
/** Server adapter instance; `null` until `setAdapter()` / constructor
|
|
51
|
+
* installs one. */
|
|
45
52
|
adapter = null;
|
|
53
|
+
/** Current context forwarded to every adapter call (jwt, subjectId,
|
|
54
|
+
* signal, plus any consumer-supplied extras). */
|
|
46
55
|
context = {};
|
|
47
56
|
/** Mutation chain head. Each create/update/delete appends itself here. */
|
|
48
57
|
#mutationChain = Promise.resolve();
|
|
@@ -51,6 +60,9 @@ export class BaseDomainManager {
|
|
|
51
60
|
/** All active controllers created via `newController()`, for bulk abort. */
|
|
52
61
|
#activeControllers = new Set();
|
|
53
62
|
#destroyed = false;
|
|
63
|
+
/** Construct a new domain manager. Not called directly — subclasses like
|
|
64
|
+
* {@link OwnedCollectionManager} extend this and are instantiated by
|
|
65
|
+
* the suite. */
|
|
54
66
|
constructor(domainName, options = {}) {
|
|
55
67
|
this.domainName = domainName;
|
|
56
68
|
this.clog = createClog(`ownsuite:${domainName}`, { color: "auto" });
|
|
@@ -76,9 +88,13 @@ export class BaseDomainManager {
|
|
|
76
88
|
get isDestroyed() {
|
|
77
89
|
return this.#destroyed;
|
|
78
90
|
}
|
|
91
|
+
/** Install or replace the server adapter for this domain. Call
|
|
92
|
+
* {@link refresh} afterwards to pick up data from the new source. */
|
|
79
93
|
setAdapter(adapter) {
|
|
80
94
|
this.adapter = adapter;
|
|
81
95
|
}
|
|
96
|
+
/** Current adapter, or `null` when none installed (typically only in
|
|
97
|
+
* tests or between `destroy()` and re-attachment). */
|
|
82
98
|
getAdapter() {
|
|
83
99
|
return this.adapter;
|
|
84
100
|
}
|
|
@@ -93,6 +109,8 @@ export class BaseDomainManager {
|
|
|
93
109
|
replaceContext(context) {
|
|
94
110
|
this.context = { ...context };
|
|
95
111
|
}
|
|
112
|
+
/** Snapshot of the current context. Mutating the returned object does
|
|
113
|
+
* not affect the manager. */
|
|
96
114
|
getContext() {
|
|
97
115
|
return { ...this.context };
|
|
98
116
|
}
|