@marianmeres/ownsuite 2.0.0 → 2.2.1

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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @module domains/session
3
+ *
4
+ * SessionManager — source of truth for the JWT and the authenticated subject.
5
+ *
6
+ * This is NOT an `OwnedCollectionManager` — there's no list-of-rows and no
7
+ * remote adapter. The session is a single reactive record, persisted via a
8
+ * pluggable `SessionStorage`, that drives every other manager (its JWT and
9
+ * subjectId end up on `OwnsuiteContext`).
10
+ *
11
+ * Consumers subscribe directly (`suite.session.subscribe(fn)`) or read the
12
+ * current snapshot (`suite.session.get()`). Writes go through the high-
13
+ * level `AuthManager` methods (`login`, `logout`, etc.) which call into
14
+ * this manager.
15
+ */
16
+ import { createStore } from "@marianmeres/store";
17
+ import { createPubSub } from "@marianmeres/pubsub";
18
+ const DEFAULT_STORAGE_KEY = "ownsuite:session";
19
+ const BUILT_IN_ORDER = [
20
+ "local",
21
+ "session",
22
+ "memory",
23
+ ];
24
+ const EMPTY = {
25
+ status: "anonymous",
26
+ subject: null,
27
+ jwt: null,
28
+ expiresAt: null,
29
+ };
30
+ /** In-memory session storage — survives the current JS realm only. */
31
+ export function createMemorySessionStorage() {
32
+ const map = new Map();
33
+ return {
34
+ get(k) {
35
+ return map.has(k) ? map.get(k) : null;
36
+ },
37
+ set(k, v) {
38
+ map.set(k, v);
39
+ },
40
+ del(k) {
41
+ map.delete(k);
42
+ },
43
+ };
44
+ }
45
+ /** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
46
+ * Gracefully falls back to memory storage when running in an environment
47
+ * without Web Storage (SSR, worker without storage, etc). */
48
+ export function resolveSessionStorage(type = "local") {
49
+ if (typeof type === "object" && type !== null)
50
+ return type;
51
+ if (type === "memory")
52
+ return createMemorySessionStorage();
53
+ const backend = type === "session"
54
+ ? "sessionStorage"
55
+ : "localStorage";
56
+ const g = globalThis;
57
+ const store = g[backend];
58
+ if (!store)
59
+ return createMemorySessionStorage();
60
+ return {
61
+ get(k) {
62
+ try {
63
+ return store.getItem(k);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ },
69
+ set(k, v) {
70
+ try {
71
+ store.setItem(k, v);
72
+ }
73
+ catch {
74
+ // quota exceeded / disabled — degrade silently
75
+ }
76
+ },
77
+ del(k) {
78
+ try {
79
+ store.removeItem(k);
80
+ }
81
+ catch {
82
+ // ignore
83
+ }
84
+ },
85
+ };
86
+ }
87
+ /**
88
+ * Session manager — pure reactive state + persistence, no HTTP.
89
+ *
90
+ * Writes are driven by the AuthManager. On construction it hydrates by
91
+ * probing the built-in backends in order `local → session → memory` and
92
+ * adopting whichever holds a non-expired payload as the active backend for
93
+ * the rest of the instance's lifetime (until `clear()`). Stale blobs on the
94
+ * other built-in backends are wiped on adoption.
95
+ *
96
+ * When constructed with a custom `SessionStorage` object, there is a single
97
+ * backend and per-login `remember` choices are silently ignored.
98
+ */
99
+ export class SessionManager {
100
+ #store;
101
+ #pubsub;
102
+ #storageKey;
103
+ /** Set only when the consumer passed a `SessionStorage` object at
104
+ * construction — then there is one backend and no toggling. */
105
+ #customStorage;
106
+ /** Resolved eagerly in the string-storage case so `clear()` can wipe
107
+ * every backend and per-login overrides can switch between them. */
108
+ #builtIn;
109
+ #defaultStorageType;
110
+ #activeStorage;
111
+ #activeStorageType;
112
+ constructor(options = {}) {
113
+ this.#pubsub = options.pubsub ?? createPubSub();
114
+ this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
115
+ this.#store = createStore({ ...EMPTY });
116
+ const configured = options.storage ?? "local";
117
+ if (typeof configured === "string") {
118
+ this.#customStorage = null;
119
+ this.#builtIn = {
120
+ local: resolveSessionStorage("local"),
121
+ session: resolveSessionStorage("session"),
122
+ memory: createMemorySessionStorage(),
123
+ };
124
+ this.#defaultStorageType = configured;
125
+ this.#activeStorage = this.#builtIn[configured];
126
+ this.#activeStorageType = configured;
127
+ }
128
+ else {
129
+ this.#customStorage = configured;
130
+ this.#builtIn = null;
131
+ this.#defaultStorageType = "local";
132
+ this.#activeStorage = configured;
133
+ this.#activeStorageType = "custom";
134
+ }
135
+ this.#hydrate();
136
+ }
137
+ /** Try to parse a payload and validate shape + expiry. Returns the state
138
+ * on success, or `null` (and deletes the stored blob) on any failure. */
139
+ #readCandidate(storage) {
140
+ const raw = storage.get(this.#storageKey);
141
+ if (!raw)
142
+ return null;
143
+ try {
144
+ const parsed = JSON.parse(raw);
145
+ if (typeof parsed !== "object" ||
146
+ parsed === null ||
147
+ typeof parsed.status !== "string") {
148
+ storage.del(this.#storageKey);
149
+ return null;
150
+ }
151
+ if (parsed.expiresAt !== null &&
152
+ parsed.expiresAt !== undefined &&
153
+ parsed.expiresAt * 1000 <= Date.now()) {
154
+ storage.del(this.#storageKey);
155
+ return null;
156
+ }
157
+ return parsed;
158
+ }
159
+ catch {
160
+ storage.del(this.#storageKey);
161
+ return null;
162
+ }
163
+ }
164
+ /** Read from storage and populate the store. Expired sessions are wiped.
165
+ * In the built-in case, probes `local → session → memory` and wipes
166
+ * the losing backends so stale blobs can't leak back in. */
167
+ #hydrate() {
168
+ if (this.#customStorage) {
169
+ const parsed = this.#readCandidate(this.#customStorage);
170
+ if (parsed)
171
+ this.#store.set(parsed);
172
+ return;
173
+ }
174
+ const builtIn = this.#builtIn;
175
+ for (const type of BUILT_IN_ORDER) {
176
+ const parsed = this.#readCandidate(builtIn[type]);
177
+ if (!parsed)
178
+ continue;
179
+ // Adopt this backend; wipe the others so a later login-with-toggle
180
+ // can't accidentally re-hydrate a stale blob.
181
+ for (const other of BUILT_IN_ORDER) {
182
+ if (other !== type)
183
+ builtIn[other].del(this.#storageKey);
184
+ }
185
+ this.#activeStorage = builtIn[type];
186
+ this.#activeStorageType = type;
187
+ this.#store.set(parsed);
188
+ return;
189
+ }
190
+ }
191
+ #persist() {
192
+ const s = this.#store.get();
193
+ if (s.status === "anonymous") {
194
+ this.#activeStorage.del(this.#storageKey);
195
+ }
196
+ else {
197
+ this.#activeStorage.set(this.#storageKey, JSON.stringify(s));
198
+ }
199
+ }
200
+ #emitChange() {
201
+ this.#pubsub.publish("auth:session:changed", {
202
+ type: "auth:session:changed",
203
+ timestamp: Date.now(),
204
+ session: this.#store.get(),
205
+ });
206
+ }
207
+ /** Svelte-compatible subscribe. */
208
+ get subscribe() {
209
+ return this.#store.subscribe;
210
+ }
211
+ /** Current session state snapshot. */
212
+ get() {
213
+ return this.#store.get();
214
+ }
215
+ get isAuthenticated() {
216
+ return this.#store.get().status === "authenticated";
217
+ }
218
+ get isUnverified() {
219
+ return this.#store.get().status === "unverified";
220
+ }
221
+ get isAnonymous() {
222
+ return this.#store.get().status === "anonymous";
223
+ }
224
+ /** JWT for adapter `Authorization` headers, or null when anonymous. */
225
+ getJwt() {
226
+ return this.#store.get().jwt;
227
+ }
228
+ /** Transition to authenticated. Called after login/register/OAuth succeed
229
+ * and the subject has been loaded.
230
+ *
231
+ * When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
232
+ * pins this session to that built-in backend; subsequent
233
+ * `patchSubject` / `setUnverified` writes land on the same backend.
234
+ * The previously-active backend's blob is wiped as part of the switch
235
+ * so "Remember me" toggles don't leave stale data behind.
236
+ *
237
+ * Ignored when the manager was constructed with a custom `SessionStorage`
238
+ * object (single backend) or when `opts.storage` is itself an object
239
+ * (no multi-backend custom storage by design). */
240
+ setAuthenticated(opts) {
241
+ const { jwt, subject, expiresAt = null, storage } = opts;
242
+ if (typeof storage === "string" && this.#builtIn) {
243
+ if (this.#activeStorage !== this.#builtIn[storage]) {
244
+ this.#activeStorage.del(this.#storageKey);
245
+ this.#activeStorage = this.#builtIn[storage];
246
+ this.#activeStorageType = storage;
247
+ }
248
+ }
249
+ this.#store.set({
250
+ status: "authenticated",
251
+ subject,
252
+ jwt,
253
+ expiresAt,
254
+ });
255
+ this.#persist();
256
+ this.#emitChange();
257
+ }
258
+ /** Server confirmed credentials but refuses login until email is
259
+ * verified. Expose the email so the UI can prompt "check your inbox"
260
+ * without a second server call. No JWT in this state. */
261
+ setUnverified(email) {
262
+ this.#store.set({
263
+ status: "unverified",
264
+ subject: {
265
+ id: "",
266
+ email,
267
+ roles: [],
268
+ isVerified: false,
269
+ hasPassword: false,
270
+ },
271
+ jwt: null,
272
+ expiresAt: null,
273
+ });
274
+ this.#persist();
275
+ this.#emitChange();
276
+ }
277
+ /** Drop the session. Every built-in backend (local + session + memory)
278
+ * is wiped — not just the active one — so stale blobs from a previous
279
+ * "Remember me" toggle can't leak back in on the next construction.
280
+ * Resets the active backend to the manager's configured default.
281
+ * Downstream domains should be reset by the suite orchestrator. */
282
+ clear() {
283
+ this.#wipeAllBackends();
284
+ if (this.#builtIn) {
285
+ this.#activeStorage = this.#builtIn[this.#defaultStorageType];
286
+ this.#activeStorageType = this.#defaultStorageType;
287
+ }
288
+ if (this.#store.get().status === "anonymous")
289
+ return;
290
+ this.#store.set({ ...EMPTY });
291
+ this.#emitChange();
292
+ }
293
+ #wipeAllBackends() {
294
+ if (this.#customStorage) {
295
+ this.#customStorage.del(this.#storageKey);
296
+ return;
297
+ }
298
+ const builtIn = this.#builtIn;
299
+ for (const type of BUILT_IN_ORDER) {
300
+ builtIn[type].del(this.#storageKey);
301
+ }
302
+ }
303
+ /** Patch the subject in place without touching the JWT. Used when the
304
+ * profile manager changes email / linked providers / etc. */
305
+ patchSubject(patch) {
306
+ const s = this.#store.get();
307
+ if (!s.subject)
308
+ return;
309
+ const next = {
310
+ ...s,
311
+ subject: { ...s.subject, ...patch },
312
+ };
313
+ this.#store.set(next);
314
+ this.#persist();
315
+ this.#emitChange();
316
+ }
317
+ /** Test / reset hook — clears every backend AND in-memory state without
318
+ * emitting. Used by teardown. */
319
+ destroy() {
320
+ this.#wipeAllBackends();
321
+ this.#store.set({ ...EMPTY });
322
+ }
323
+ }
package/dist/mod.d.ts CHANGED
@@ -29,3 +29,4 @@ export * from "./ownsuite.js";
29
29
  export * from "./domains/mod.js";
30
30
  export * from "./types/mod.js";
31
31
  export * from "./adapters/mod.js";
32
+ export * from "./oauth/popup.js";
package/dist/mod.js CHANGED
@@ -29,3 +29,4 @@ export * from "./ownsuite.js";
29
29
  export * from "./domains/mod.js";
30
30
  export * from "./types/mod.js";
31
31
  export * from "./adapters/mod.js";
32
+ export * from "./oauth/popup.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @module oauth/popup
3
+ *
4
+ * `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
5
+ * resolves with the postMessage the server's callback page posts back.
6
+ *
7
+ * Server shape (stack-account): the OAuth callback page renders a `<script>`
8
+ * that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
9
+ * We listen for those messages, validate the `type` field, and resolve.
10
+ *
11
+ * For tests, an injectable window-like interface lets us shim `postMessage`
12
+ * via a MessageChannel without a real browser.
13
+ */
14
+ export interface OAuthPopupLoginMessage {
15
+ type: "oauth_login_success";
16
+ jwt: string;
17
+ email: string;
18
+ roles?: string[];
19
+ isNewAccount?: boolean;
20
+ redirectUrl?: string;
21
+ }
22
+ export interface OAuthPopupLinkMessage {
23
+ type: "oauth_link_success";
24
+ provider: string;
25
+ }
26
+ export interface OAuthPopupErrorMessage {
27
+ type: "oauth_error";
28
+ error: string;
29
+ }
30
+ export type OAuthPopupMessage = OAuthPopupLoginMessage | OAuthPopupLinkMessage;
31
+ /**
32
+ * Minimal window-like surface we need. Real `Window` satisfies this; tests
33
+ * supply a shim.
34
+ */
35
+ export interface PopupWindowHost {
36
+ open(url: string, target: string, features?: string): PopupWindowHandle | null;
37
+ addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
38
+ removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
39
+ }
40
+ export interface PopupWindowHandle {
41
+ closed: boolean;
42
+ focus?(): void;
43
+ close?(): void;
44
+ }
45
+ export interface OpenOAuthPopupOptions {
46
+ /** Host window — defaults to `globalThis` when running in the browser. */
47
+ host?: PopupWindowHost;
48
+ /** How often to poll for popup-closed-without-message. Default 500ms. */
49
+ closedPollMs?: number;
50
+ /** Hard timeout in ms; 0 disables. Default 0. */
51
+ timeoutMs?: number;
52
+ /** Restrict accepted message origins. Default: no origin check (the
53
+ * server's postMessage uses "*" intentionally; origin validation is the
54
+ * caller's responsibility if needed). */
55
+ expectedOrigin?: string;
56
+ /** Popup window features string. */
57
+ features?: string;
58
+ }
59
+ /**
60
+ * Open the OAuth popup at `url` and await a success message from it.
61
+ * Rejects if the popup is closed before a message arrives, if the received
62
+ * message is an error, or if the optional timeout fires.
63
+ */
64
+ export declare function openOAuthPopup(url: string, options?: OpenOAuthPopupOptions): Promise<OAuthPopupMessage>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @module oauth/popup
3
+ *
4
+ * `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
5
+ * resolves with the postMessage the server's callback page posts back.
6
+ *
7
+ * Server shape (stack-account): the OAuth callback page renders a `<script>`
8
+ * that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
9
+ * We listen for those messages, validate the `type` field, and resolve.
10
+ *
11
+ * For tests, an injectable window-like interface lets us shim `postMessage`
12
+ * via a MessageChannel without a real browser.
13
+ */
14
+ const DEFAULT_FEATURES = "width=500,height=700,noopener=no,noreferrer=no";
15
+ /**
16
+ * Open the OAuth popup at `url` and await a success message from it.
17
+ * Rejects if the popup is closed before a message arrives, if the received
18
+ * message is an error, or if the optional timeout fires.
19
+ */
20
+ export function openOAuthPopup(url, options = {}) {
21
+ const host = (options.host ?? globalThis);
22
+ if (typeof host.open !== "function" || typeof host.addEventListener !== "function") {
23
+ return Promise.reject(new Error("openOAuthPopup: host window does not support popups"));
24
+ }
25
+ const features = options.features ?? DEFAULT_FEATURES;
26
+ const closedPollMs = options.closedPollMs ?? 500;
27
+ const popup = host.open(url, "oauth_popup", features);
28
+ if (!popup) {
29
+ return Promise.reject(new Error("OAUTH_POPUP_BLOCKED"));
30
+ }
31
+ return new Promise((resolve, reject) => {
32
+ let settled = false;
33
+ let pollTimer;
34
+ let timeoutTimer;
35
+ const cleanup = () => {
36
+ settled = true;
37
+ host.removeEventListener("message", onMessage);
38
+ if (pollTimer !== undefined)
39
+ clearInterval(pollTimer);
40
+ if (timeoutTimer !== undefined)
41
+ clearTimeout(timeoutTimer);
42
+ };
43
+ const onMessage = (event) => {
44
+ if (settled)
45
+ return;
46
+ if (options.expectedOrigin !== undefined &&
47
+ event.origin !== options.expectedOrigin) {
48
+ return; // silently drop mismatched origin messages
49
+ }
50
+ const data = event.data;
51
+ if (!data || typeof data !== "object" || typeof data.type !== "string") {
52
+ return;
53
+ }
54
+ if (data.type === "oauth_login_success" ||
55
+ data.type === "oauth_link_success") {
56
+ cleanup();
57
+ try {
58
+ popup.close?.();
59
+ }
60
+ catch {
61
+ // ignore
62
+ }
63
+ resolve(data);
64
+ return;
65
+ }
66
+ if (data.type === "oauth_error") {
67
+ cleanup();
68
+ try {
69
+ popup.close?.();
70
+ }
71
+ catch {
72
+ // ignore
73
+ }
74
+ reject(new Error(data.error));
75
+ return;
76
+ }
77
+ };
78
+ host.addEventListener("message", onMessage);
79
+ if (closedPollMs > 0) {
80
+ pollTimer = setInterval(() => {
81
+ if (settled)
82
+ return;
83
+ if (popup.closed) {
84
+ cleanup();
85
+ reject(new Error("OAUTH_POPUP_CLOSED"));
86
+ }
87
+ }, closedPollMs);
88
+ }
89
+ if (options.timeoutMs && options.timeoutMs > 0) {
90
+ timeoutTimer = setTimeout(() => {
91
+ if (settled)
92
+ return;
93
+ cleanup();
94
+ try {
95
+ popup.close?.();
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ reject(new Error("OAUTH_POPUP_TIMEOUT"));
101
+ }, options.timeoutMs);
102
+ }
103
+ });
104
+ }
@@ -14,7 +14,11 @@ import { type Subscriber, type Unsubscriber } from "@marianmeres/pubsub";
14
14
  import type { DomainError, OwnsuiteContext } from "./types/state.js";
15
15
  import type { OwnedCollectionAdapter } from "./types/adapter.js";
16
16
  import type { OwnsuiteEventType } from "./types/events.js";
17
+ import type { AuthAdapter, ProfileAdapter, SessionStorageType } from "./types/auth.js";
17
18
  import { OwnedCollectionManager } from "./domains/owned-collection.js";
19
+ import { AuthManager } from "./domains/auth.js";
20
+ import { ProfileManager } from "./domains/profile.js";
21
+ import { SessionManager } from "./domains/session.js";
18
22
  /**
19
23
  * Configuration for a single domain at construction time. The caller
20
24
  * provides a unique name and an adapter. A custom `getRowId` is optional
@@ -32,6 +36,18 @@ export interface OwnsuiteConfig {
32
36
  domains?: Record<string, OwnsuiteDomainConfig>;
33
37
  /** Auto-initialize all registered domains on creation (default: false). */
34
38
  autoInitialize?: boolean;
39
+ /** Auth / profile adapters. When present, ownsuite builds the
40
+ * SessionManager / AuthManager / ProfileManager trio and exposes them
41
+ * as `suite.auth`, `suite.profile`, `suite.session`. */
42
+ adapters?: {
43
+ auth?: AuthAdapter;
44
+ profile?: ProfileAdapter;
45
+ };
46
+ /** Session persistence config. Ignored when no auth adapter is provided. */
47
+ session?: {
48
+ storage?: SessionStorageType;
49
+ storageKey?: string;
50
+ };
35
51
  }
36
52
  /** Options for {@link Ownsuite.setContext}. */
37
53
  export interface SetContextOptions {
@@ -60,6 +76,11 @@ export interface SetContextOptions {
60
76
  */
61
77
  export declare class Ownsuite {
62
78
  #private;
79
+ /** Optional auth/session/profile managers. Present iff `adapters.auth`
80
+ * was supplied at construction. */
81
+ readonly session: SessionManager | null;
82
+ readonly auth: AuthManager | null;
83
+ readonly profile: ProfileManager | null;
63
84
  constructor(config?: OwnsuiteConfig);
64
85
  /** True after `destroy()` has been called. */
65
86
  get isDestroyed(): boolean;
package/dist/ownsuite.js CHANGED
@@ -13,6 +13,9 @@
13
13
  import { createClog } from "@marianmeres/clog";
14
14
  import { createPubSub, } from "@marianmeres/pubsub";
15
15
  import { OwnedCollectionManager } from "./domains/owned-collection.js";
16
+ import { AuthManager } from "./domains/auth.js";
17
+ import { ProfileManager } from "./domains/profile.js";
18
+ import { SessionManager } from "./domains/session.js";
16
19
  /**
17
20
  * Main Ownsuite class — coordinates owner-scoped domain managers.
18
21
  *
@@ -38,9 +41,70 @@ export class Ownsuite {
38
41
  // deno-lint-ignore no-explicit-any
39
42
  #domains = new Map();
40
43
  #destroyed = false;
44
+ /** Optional auth/session/profile managers. Present iff `adapters.auth`
45
+ * was supplied at construction. */
46
+ session = null;
47
+ auth = null;
48
+ profile = null;
41
49
  constructor(config = {}) {
42
50
  this.#pubsub = createPubSub();
43
51
  this.#context = { ...(config.context ?? {}) };
52
+ // Build session / auth / profile managers if the auth adapter is wired.
53
+ if (config.adapters?.auth) {
54
+ const session = new SessionManager({
55
+ storage: config.session?.storage,
56
+ storageKey: config.session?.storageKey,
57
+ pubsub: this.#pubsub,
58
+ });
59
+ this.session = session;
60
+ // Profile adapter is optional but strongly encouraged. Without it
61
+ // login still works — we just don't hydrate roles/isVerified from
62
+ // /me after auth and consumers must call auth-result-based state
63
+ // themselves.
64
+ const profileAdapter = config.adapters.profile;
65
+ if (profileAdapter) {
66
+ this.profile = new ProfileManager({
67
+ adapter: profileAdapter,
68
+ session,
69
+ pubsub: this.#pubsub,
70
+ context: this.#context,
71
+ });
72
+ }
73
+ // AuthManager requires a profile manager to hydrate the subject
74
+ // after login. If none was provided, build a stub that throws —
75
+ // AuthManager will still call it inside a try/catch and recover.
76
+ const profileForAuth = this.profile ?? new ProfileManager({
77
+ adapter: {
78
+ get: () => Promise.reject(new Error("no profile adapter configured")),
79
+ update: () => Promise.reject(new Error("no profile adapter configured")),
80
+ listOAuth: () => Promise.resolve([]),
81
+ unlinkOAuth: () => Promise.reject(new Error("no profile adapter configured")),
82
+ },
83
+ session,
84
+ pubsub: this.#pubsub,
85
+ context: this.#context,
86
+ });
87
+ this.auth = new AuthManager({
88
+ adapter: config.adapters.auth,
89
+ session,
90
+ profile: profileForAuth,
91
+ pubsub: this.#pubsub,
92
+ context: this.#context,
93
+ onIdentityChanged: (ctx) => this.#onIdentityChanged(ctx),
94
+ });
95
+ // Listen for session changes to keep the suite-wide context
96
+ // `jwt` / `subjectId` in sync with the authenticated session.
97
+ session.subscribe((s) => {
98
+ const patch = {
99
+ jwt: s.jwt ?? undefined,
100
+ subjectId: s.subject?.id || undefined,
101
+ };
102
+ this.#context = { ...this.#context, ...patch };
103
+ for (const m of this.#domains.values()) {
104
+ m.setContext(patch);
105
+ }
106
+ });
107
+ }
44
108
  for (const [name, cfg] of Object.entries(config.domains ?? {})) {
45
109
  this.registerDomain(name, cfg);
46
110
  }
@@ -51,6 +115,26 @@ export class Ownsuite {
51
115
  void this.initialize();
52
116
  }
53
117
  }
118
+ /** Identity-change hook fired by the AuthManager after a successful
119
+ * login / register / logout / OAuth login. Reset every owner-scoped
120
+ * domain so their subscribed state reflects the new subject (or the
121
+ * anonymous state). Subsequent fetches pick up the new ctx.jwt. */
122
+ async #onIdentityChanged(ctx) {
123
+ for (const m of this.#domains.values())
124
+ m.setContext(ctx);
125
+ for (const m of this.#domains.values())
126
+ m.reset();
127
+ // Re-init for authenticated state; stay at "initializing" for logout
128
+ // (so stale data doesn't flash while consumers unmount).
129
+ if (ctx.jwt) {
130
+ try {
131
+ await this.initialize();
132
+ }
133
+ catch {
134
+ // initialize() doesn't reject on domain errors, but guard anyway.
135
+ }
136
+ }
137
+ }
54
138
  /** True after `destroy()` has been called. */
55
139
  get isDestroyed() {
56
140
  return this.#destroyed;
@@ -190,6 +274,8 @@ export class Ownsuite {
190
274
  for (const m of this.#domains.values())
191
275
  m.destroy();
192
276
  this.#domains.clear();
277
+ this.profile?.destroy();
278
+ this.session?.destroy();
193
279
  // Our internal pubsub: clear all subscribers. Best-effort — if a custom
194
280
  // pubsub implementation doesn't expose `unsubscribeAll`, skip it.
195
281
  const ps = this.#pubsub;