@marianmeres/ownsuite 2.1.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.
package/AGENTS.md CHANGED
@@ -160,9 +160,9 @@ export type { MockAuthStore } from "./adapters/mod.ts";
160
160
 
161
161
  When `adapters.auth` is supplied, `createOwnsuite` instantiates three extra managers and attaches them as readonly suite properties:
162
162
 
163
- - **`suite.session: SessionManager`** — reactive `{ status, subject, jwt, expiresAt }` persisted via pluggable `SessionStorage` (`"local"` / `"session"` / `"memory"` / custom object). Hydrates on construction; discards expired sessions. Exposes `subscribe` (Svelte-compatible), `get()`, and state-mutation methods (`setAuthenticated`, `setUnverified`, `clear`, `patchSubject`). Writes are driven by `AuthManager`, not consumers directly.
163
+ - **`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
164
 
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.
165
+ - **`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
166
 
167
167
  - **`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
168
 
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 {
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({ email: "alice@example.com", password: "mysecretpassword" });
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
 
@@ -12,7 +12,7 @@
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
18
  export interface AuthManagerOptions {
@@ -46,11 +46,11 @@ export declare class AuthManager {
46
46
  password_confirm: string;
47
47
  roles?: string[];
48
48
  extras?: Record<string, unknown>;
49
- }): Promise<AuthTokenResult>;
49
+ }, options?: AuthActionOptions): Promise<AuthTokenResult>;
50
50
  login(input: {
51
51
  email: string;
52
52
  password: string;
53
- }): Promise<AuthTokenResult>;
53
+ }, options?: AuthActionOptions): Promise<AuthTokenResult>;
54
54
  logout(): Promise<void>;
55
55
  resendVerification(input: {
56
56
  email: string;
@@ -78,6 +78,8 @@ export declare class AuthManager {
78
78
  */
79
79
  initiateOAuth(provider: OAuthProvider, opts: OAuthInitOptions): Promise<AuthTokenResult | void>;
80
80
  /** For the redirect-mode callback page: delegate to the adapter if
81
- * available to extract the result from the current URL/page state. */
82
- handleOAuthCallback(): Promise<AuthTokenResult | void>;
81
+ * available to extract the result from the current URL/page state.
82
+ * `options.remember` pins the resulting session to local/session
83
+ * storage — pass the same value the user picked before the redirect. */
84
+ handleOAuthCallback(options?: AuthActionOptions): Promise<AuthTokenResult | void>;
83
85
  }
@@ -46,8 +46,13 @@ export class AuthManager {
46
46
  * present, we pull the profile to build a complete subject. When the
47
47
  * server returned requiresVerification, we surface the "unverified"
48
48
  * status without any profile fetch. Triggers identity-change hook on
49
- * actual login. */
50
- async #applyAuthResult(result) {
49
+ * actual login.
50
+ *
51
+ * `remember` translates to a per-login storage pin on the session:
52
+ * `true` → `"local"`, `false` → `"session"`, `undefined` → session
53
+ * manager's configured default. Silently no-op in the verification-gate
54
+ * branch (no JWT yet → nothing to persist). */
55
+ async #applyAuthResult(result, remember) {
51
56
  if (result.requiresVerification || !result.jwt) {
52
57
  this.#session.setUnverified(result.email);
53
58
  this.#pubsub.publish("auth:verification:required", {
@@ -66,10 +71,16 @@ export class AuthManager {
66
71
  isVerified: result.isVerified ?? false,
67
72
  hasPassword: true, // corrected by profile fetch below
68
73
  };
74
+ const storage = remember === true
75
+ ? "local"
76
+ : remember === false
77
+ ? "session"
78
+ : undefined;
69
79
  this.#session.setAuthenticated({
70
80
  jwt: result.jwt,
71
81
  subject: provisional,
72
82
  expiresAt: result.validUntil ?? null,
83
+ ...(storage ? { storage } : {}),
73
84
  });
74
85
  // Best-effort hydrate the subject from /me. If this fails we keep the
75
86
  // provisional subject; consumers can retry via profile.fetch().
@@ -90,7 +101,7 @@ export class AuthManager {
90
101
  return result;
91
102
  }
92
103
  // ─────────────────────── verbs ──────────────────────────────────────────
93
- async register(input) {
104
+ async register(input, options) {
94
105
  const result = await this.#adapter.register(input, this.#ctx());
95
106
  this.#pubsub.publish("auth:register", {
96
107
  type: "auth:register",
@@ -98,16 +109,16 @@ export class AuthManager {
98
109
  email: input.email,
99
110
  requiresVerification: Boolean(result.requiresVerification),
100
111
  });
101
- return await this.#applyAuthResult(result);
112
+ return await this.#applyAuthResult(result, options?.remember);
102
113
  }
103
- async login(input) {
114
+ async login(input, options) {
104
115
  const result = await this.#adapter.login(input, this.#ctx());
105
116
  this.#pubsub.publish("auth:login", {
106
117
  type: "auth:login",
107
118
  timestamp: Date.now(),
108
119
  email: input.email,
109
120
  });
110
- return await this.#applyAuthResult(result);
121
+ return await this.#applyAuthResult(result, options?.remember);
111
122
  }
112
123
  async logout() {
113
124
  const subjectId = this.#session.get().subject?.id;
@@ -197,15 +208,17 @@ export class AuthManager {
197
208
  roles: message.roles ?? [],
198
209
  isVerified: true,
199
210
  };
200
- return await this.#applyAuthResult(result);
211
+ return await this.#applyAuthResult(result, opts.remember);
201
212
  }
202
213
  /** For the redirect-mode callback page: delegate to the adapter if
203
- * available to extract the result from the current URL/page state. */
204
- async handleOAuthCallback() {
214
+ * available to extract the result from the current URL/page state.
215
+ * `options.remember` pins the resulting session to local/session
216
+ * storage — pass the same value the user picked before the redirect. */
217
+ async handleOAuthCallback(options) {
205
218
  if (!this.#adapter.handleOAuthCallback) {
206
219
  throw new Error("AuthManager: handleOAuthCallback is not implemented by the adapter");
207
220
  }
208
221
  const result = await this.#adapter.handleOAuthCallback(this.#ctx());
209
- return await this.#applyAuthResult(result);
222
+ return await this.#applyAuthResult(result, options?.remember);
210
223
  }
211
224
  }
@@ -34,9 +34,14 @@ export interface SessionManagerOptions {
34
34
  /**
35
35
  * Session manager — pure reactive state + persistence, no HTTP.
36
36
  *
37
- * Writes are driven by the AuthManager. On construction it hydrates from
38
- * storage; if the stored JWT has expired, it transitions to anonymous and
39
- * clears the storage.
37
+ * Writes are driven by the AuthManager. On construction it hydrates by
38
+ * probing the built-in backends in order `local session memory` and
39
+ * adopting whichever holds a non-expired payload as the active backend for
40
+ * the rest of the instance's lifetime (until `clear()`). Stale blobs on the
41
+ * other built-in backends are wiped on adoption.
42
+ *
43
+ * When constructed with a custom `SessionStorage` object, there is a single
44
+ * backend and per-login `remember` choices are silently ignored.
40
45
  */
41
46
  export declare class SessionManager {
42
47
  #private;
@@ -51,23 +56,37 @@ export declare class SessionManager {
51
56
  /** JWT for adapter `Authorization` headers, or null when anonymous. */
52
57
  getJwt(): string | null;
53
58
  /** Transition to authenticated. Called after login/register/OAuth succeed
54
- * and the subject has been loaded. */
59
+ * and the subject has been loaded.
60
+ *
61
+ * When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
62
+ * pins this session to that built-in backend; subsequent
63
+ * `patchSubject` / `setUnverified` writes land on the same backend.
64
+ * The previously-active backend's blob is wiped as part of the switch
65
+ * so "Remember me" toggles don't leave stale data behind.
66
+ *
67
+ * Ignored when the manager was constructed with a custom `SessionStorage`
68
+ * object (single backend) or when `opts.storage` is itself an object
69
+ * (no multi-backend custom storage by design). */
55
70
  setAuthenticated(opts: {
56
71
  jwt: string;
57
72
  subject: SessionSubject;
58
73
  expiresAt?: number | null;
74
+ storage?: SessionStorageType;
59
75
  }): void;
60
76
  /** Server confirmed credentials but refuses login until email is
61
77
  * verified. Expose the email so the UI can prompt "check your inbox"
62
78
  * without a second server call. No JWT in this state. */
63
79
  setUnverified(email: string): void;
64
- /** Drop the session. Storage is cleared; downstream domains should be
65
- * reset by the suite orchestrator. */
80
+ /** Drop the session. Every built-in backend (local + session + memory)
81
+ * is wiped — not just the active one — so stale blobs from a previous
82
+ * "Remember me" toggle can't leak back in on the next construction.
83
+ * Resets the active backend to the manager's configured default.
84
+ * Downstream domains should be reset by the suite orchestrator. */
66
85
  clear(): void;
67
86
  /** Patch the subject in place without touching the JWT. Used when the
68
87
  * profile manager changes email / linked providers / etc. */
69
88
  patchSubject(patch: Partial<SessionSubject>): void;
70
- /** Test / reset hook — clears storage AND in-memory state without
89
+ /** Test / reset hook — clears every backend AND in-memory state without
71
90
  * emitting. Used by teardown. */
72
91
  destroy(): void;
73
92
  }
@@ -16,6 +16,11 @@
16
16
  import { createStore } from "@marianmeres/store";
17
17
  import { createPubSub } from "@marianmeres/pubsub";
18
18
  const DEFAULT_STORAGE_KEY = "ownsuite:session";
19
+ const BUILT_IN_ORDER = [
20
+ "local",
21
+ "session",
22
+ "memory",
23
+ ];
19
24
  const EMPTY = {
20
25
  status: "anonymous",
21
26
  subject: null,
@@ -82,56 +87,114 @@ export function resolveSessionStorage(type = "local") {
82
87
  /**
83
88
  * Session manager — pure reactive state + persistence, no HTTP.
84
89
  *
85
- * Writes are driven by the AuthManager. On construction it hydrates from
86
- * storage; if the stored JWT has expired, it transitions to anonymous and
87
- * clears the storage.
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.
88
98
  */
89
99
  export class SessionManager {
90
100
  #store;
91
101
  #pubsub;
92
- #storage;
93
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;
94
112
  constructor(options = {}) {
95
113
  this.#pubsub = options.pubsub ?? createPubSub();
96
- this.#storage = resolveSessionStorage(options.storage ?? "local");
97
114
  this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
98
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
+ }
99
135
  this.#hydrate();
100
136
  }
101
- /** Read from storage and populate the store. Expired sessions are wiped. */
102
- #hydrate() {
103
- const raw = this.#storage.get(this.#storageKey);
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);
104
141
  if (!raw)
105
- return;
142
+ return null;
106
143
  try {
107
144
  const parsed = JSON.parse(raw);
108
- // Basic shape check.
109
145
  if (typeof parsed !== "object" ||
110
146
  parsed === null ||
111
147
  typeof parsed.status !== "string") {
112
- this.#storage.del(this.#storageKey);
113
- return;
148
+ storage.del(this.#storageKey);
149
+ return null;
114
150
  }
115
- // Expiry check (only meaningful when expiresAt is set).
116
151
  if (parsed.expiresAt !== null &&
117
152
  parsed.expiresAt !== undefined &&
118
153
  parsed.expiresAt * 1000 <= Date.now()) {
119
- this.#storage.del(this.#storageKey);
120
- return;
154
+ storage.del(this.#storageKey);
155
+ return null;
121
156
  }
122
- this.#store.set(parsed);
157
+ return parsed;
123
158
  }
124
159
  catch {
125
- this.#storage.del(this.#storageKey);
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;
126
189
  }
127
190
  }
128
191
  #persist() {
129
192
  const s = this.#store.get();
130
193
  if (s.status === "anonymous") {
131
- this.#storage.del(this.#storageKey);
194
+ this.#activeStorage.del(this.#storageKey);
132
195
  }
133
196
  else {
134
- this.#storage.set(this.#storageKey, JSON.stringify(s));
197
+ this.#activeStorage.set(this.#storageKey, JSON.stringify(s));
135
198
  }
136
199
  }
137
200
  #emitChange() {
@@ -163,9 +226,26 @@ export class SessionManager {
163
226
  return this.#store.get().jwt;
164
227
  }
165
228
  /** Transition to authenticated. Called after login/register/OAuth succeed
166
- * and the subject has been loaded. */
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). */
167
240
  setAuthenticated(opts) {
168
- const { jwt, subject, expiresAt = null } = 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
+ }
169
249
  this.#store.set({
170
250
  status: "authenticated",
171
251
  subject,
@@ -194,15 +274,32 @@ export class SessionManager {
194
274
  this.#persist();
195
275
  this.#emitChange();
196
276
  }
197
- /** Drop the session. Storage is cleared; downstream domains should be
198
- * reset by the suite orchestrator. */
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. */
199
282
  clear() {
283
+ this.#wipeAllBackends();
284
+ if (this.#builtIn) {
285
+ this.#activeStorage = this.#builtIn[this.#defaultStorageType];
286
+ this.#activeStorageType = this.#defaultStorageType;
287
+ }
200
288
  if (this.#store.get().status === "anonymous")
201
289
  return;
202
290
  this.#store.set({ ...EMPTY });
203
- this.#persist();
204
291
  this.#emitChange();
205
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
+ }
206
303
  /** Patch the subject in place without touching the JWT. Used when the
207
304
  * profile manager changes email / linked providers / etc. */
208
305
  patchSubject(patch) {
@@ -217,10 +314,10 @@ export class SessionManager {
217
314
  this.#persist();
218
315
  this.#emitChange();
219
316
  }
220
- /** Test / reset hook — clears storage AND in-memory state without
317
+ /** Test / reset hook — clears every backend AND in-memory state without
221
318
  * emitting. Used by teardown. */
222
319
  destroy() {
223
- this.#storage.del(this.#storageKey);
320
+ this.#wipeAllBackends();
224
321
  this.#store.set({ ...EMPTY });
225
322
  }
226
323
  }
@@ -79,6 +79,26 @@ export interface OAuthInitOptions {
79
79
  * whether to open a popup and wait for a postMessage, or redirect the
80
80
  * top window. */
81
81
  mode?: "popup" | "redirect";
82
+ /** Same semantics as {@link AuthActionOptions.remember} — pins the
83
+ * resulting session to `localStorage` (`true`) or `sessionStorage`
84
+ * (`false`). Only meaningful for `action: "login"`. */
85
+ remember?: boolean;
86
+ }
87
+ /**
88
+ * Options accepted by `AuthManager.login` / `register` /
89
+ * `handleOAuthCallback` to express per-login storage preference
90
+ * ("Remember me").
91
+ */
92
+ export interface AuthActionOptions {
93
+ /** `true` → persist the resulting session to `localStorage` (survives
94
+ * browser restart).
95
+ * `false` → persist to `sessionStorage` (dies with the tab).
96
+ * `undefined` → use the `SessionManager`'s configured default backend.
97
+ *
98
+ * Silently ignored when the `SessionManager` was constructed with a
99
+ * custom `SessionStorage` object — the single custom backend is used
100
+ * regardless. */
101
+ remember?: boolean;
82
102
  }
83
103
  /**
84
104
  * Uniform result shape for `register` / `login` / OAuth success. When the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/ownsuite",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",