@djangocfg/api 2.1.603 → 2.1.605

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,112 @@
1
+ // @vitest-environment jsdom
2
+ // @vitest-environment-options {"url": "https://my.example.com/"}
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { readCookie } from '../cookies';
6
+ import { CFG_KEYS, createStorage } from '../storage';
7
+
8
+ const ACCESS = CFG_KEYS.accessToken.key;
9
+ const REFRESH = CFG_KEYS.refreshToken.key;
10
+
11
+ function wipe() {
12
+ localStorage.clear();
13
+ document.cookie.split('; ').forEach((c) => {
14
+ const name = c.split('=')[0];
15
+ document.cookie = `${name}=; Max-Age=0; Path=/`;
16
+ document.cookie = `${name}=; Max-Age=0; Path=/; Domain=example.com`;
17
+ });
18
+ }
19
+
20
+ beforeEach(wipe);
21
+
22
+ describe('shared cookie domain', () => {
23
+ it('finds the registrable domain by asking the browser', () => {
24
+ const storage = createStorage('cfg');
25
+ expect(storage.cookieDomain()).toBe('example.com');
26
+ });
27
+
28
+ it('round-trips and removes a token cookie on the shared domain', () => {
29
+ const storage = createStorage('cfg');
30
+ expect(storage.write(ACCESS, 'jwt', 'cookie')).toBe(true);
31
+ expect(storage.read(ACCESS, 'cookie')).toBe('jwt');
32
+ storage.remove(ACCESS, 'cookie');
33
+ expect(storage.read(ACCESS, 'cookie')).toBeNull();
34
+ expect(document.cookie).not.toContain('cfg.auth.access_token');
35
+ });
36
+
37
+ it('a host-only cookie from an older build does not shadow the shared one, and sign-out clears both', () => {
38
+ const storage = createStorage('cfg');
39
+ document.cookie = `${encodeURIComponent(ACCESS)}=old; Path=/`;
40
+ storage.write(ACCESS, 'new', 'cookie');
41
+ expect(storage.read(ACCESS, 'cookie')).toBe('new');
42
+ document.cookie = `${encodeURIComponent(ACCESS)}=stale; Path=/`; // stray host-only twin again
43
+ storage.remove(ACCESS, 'cookie');
44
+ expect(document.cookie).not.toContain('cfg.auth.access_token');
45
+ });
46
+
47
+ it('stays host-only when asked', () => {
48
+ const storage = createStorage('cfg', { cookieDomain: false });
49
+ expect(storage.cookieDomain()).toBeNull();
50
+ storage.write(ACCESS, 'jwt', 'cookie');
51
+ expect(storage.read(ACCESS, 'cookie')).toBe('jwt');
52
+ });
53
+
54
+ it('honours a forced domain', () => {
55
+ const storage = createStorage('cfg', { cookieDomain: 'example.com' });
56
+ expect(storage.cookieDomain()).toBe('example.com');
57
+ });
58
+
59
+ it('clearAll removes the personal cookie keys', () => {
60
+ const storage = createStorage('cfg', { keys: CFG_KEYS });
61
+ storage.write(ACCESS, 'a', 'cookie');
62
+ storage.write(REFRESH, 'r', 'cookie');
63
+ storage.clearAll();
64
+ expect(storage.read(ACCESS, 'cookie')).toBeNull();
65
+ expect(storage.read(REFRESH, 'cookie')).toBeNull();
66
+ });
67
+ });
68
+
69
+ describe('readCookie (SSR header)', () => {
70
+ it('reads an encoded name and value from a Cookie header', () => {
71
+ const header = `a=1; ${encodeURIComponent(ACCESS)}=${encodeURIComponent('x.y.z')}; b=2`;
72
+ expect(readCookie(header, ACCESS)).toBe('x.y.z');
73
+ expect(readCookie(header, 'missing')).toBeNull();
74
+ expect(readCookie(null, ACCESS)).toBeNull();
75
+ expect(readCookie('', ACCESS)).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe('auth store session', () => {
80
+ async function loadAuth() {
81
+ vi.resetModules();
82
+ return (await import('../_api/generated/helpers/auth')).auth;
83
+ }
84
+
85
+ it('defaults to the cookie backend and writes the pair there', async () => {
86
+ const auth = await loadAuth();
87
+ expect(auth.getStorageMode()).toBe('cookie');
88
+ auth.setSession({ access: 'A', refresh: 'R' });
89
+ expect(readCookie(document.cookie, ACCESS)).toBe('A');
90
+ expect(readCookie(document.cookie, REFRESH)).toBe('R');
91
+ expect(localStorage.getItem(ACCESS)).toBeNull();
92
+ auth.clearTokens();
93
+ expect(readCookie(document.cookie, ACCESS)).toBeNull();
94
+ });
95
+
96
+ it('adopts a session an older build kept in localStorage, once', async () => {
97
+ localStorage.setItem(ACCESS, 'oldA');
98
+ localStorage.setItem(REFRESH, 'oldR');
99
+ const auth = await loadAuth();
100
+ expect(auth.getToken()).toBe('oldA');
101
+ expect(auth.getRefreshToken()).toBe('oldR');
102
+ expect(localStorage.getItem(ACCESS)).toBeNull();
103
+ expect(readCookie(document.cookie, REFRESH)).toBe('oldR');
104
+ });
105
+
106
+ it('keeps the API key in localStorage', async () => {
107
+ const auth = await loadAuth();
108
+ auth.setApiKeyPersist('key-1');
109
+ expect(localStorage.getItem('cfg.auth.api_key')).toBe('key-1');
110
+ expect(document.cookie).not.toContain('api_key');
111
+ });
112
+ });
@@ -25,6 +25,12 @@ interface KVStore {
25
25
  set(key: string, value: string | null): void;
26
26
  }
27
27
 
28
+ /** Seconds until a JWT's `exp`, for the cookie lifetime; undefined when it has none. */
29
+ function secondsUntilExp(token: string): number | undefined {
30
+ const exp = jwtExpMs(token);
31
+ return exp === null ? undefined : Math.max(1, Math.floor((exp - Date.now()) / 1000));
32
+ }
33
+
28
34
  // Every read and write goes through the registry's guarded storage: SSR-safe,
29
35
  // private-mode and quota safe, and a key written under an older spelling is
30
36
  // moved to its `cfg.auth.*` name on first read.
@@ -40,17 +46,22 @@ function backendFor(mode: StorageMode): KVStore {
40
46
  get: (key) => storage.read(key, store),
41
47
  set(key, value) {
42
48
  if (value === null) storage.remove(key, store);
43
- else storage.write(key, value, store);
49
+ // The refresh cookie lives as long as the refresh token; everything else takes the default.
50
+ else storage.write(key, value, store, store === 'cookie' && key === REFRESH_KEY ? { maxAgeSeconds: secondsUntilExp(value) } : undefined);
44
51
  },
45
52
  };
46
53
  }
47
54
 
48
55
  /**
49
- * The session backend. `localStorage` by default and for every login path.
56
+ * The session backend: a cookie shared by every subdomain of the registrable
57
+ * domain (`Domain=example.com`, found by asking the browser), so signing in on
58
+ * `my.example.com` signs in `example.com` and `studio.example.com`, and so
59
+ * Next.js `cookies()` can read the token during SSR. Django never reads this
60
+ * cookie (the API authenticates the `Authorization` header), so it is no CSRF
61
+ * surface.
50
62
  *
51
- * `setStorageMode` exists for a host that must relocate the whole session once
52
- * at bootstrap — e.g. 'cookie' so Next.js `cookies()` can read the token during
53
- * SSR. It is NOT a per-login switch: this value lives in module memory and
63
+ * `setStorageMode('localStorage')` opts a host out, once at bootstrap. It is NOT
64
+ * a per-login switch: this value lives in module memory and
54
65
  * resets to the default on every page load, so tokens written to a non-default
55
66
  * backend after startup would be stranded — present in the browser, invisible
56
67
  * to the client, and every request would go out unauthenticated.
@@ -59,8 +70,27 @@ function backendFor(mode: StorageMode): KVStore {
59
70
  * minting the refresh token; the client just stores whatever it
60
71
  * was given, in one place.
61
72
  */
62
- let _storageMode: StorageMode = 'localStorage';
73
+ let _storageMode: StorageMode = 'cookie';
63
74
  let _storage: KVStore = backendFor(_storageMode);
75
+ // The API key is per browser, not per domain family: always localStorage.
76
+ const _apiKeyStore: KVStore = backendFor('localStorage');
77
+
78
+ /** One-time move of a session an older build kept in localStorage into the shared cookie. */
79
+ function adoptLegacySession(): void {
80
+ if (!isBrowser || _storageMode !== 'cookie') return;
81
+ try {
82
+ if (_storage.get(ACCESS_KEY) !== null || _storage.get(REFRESH_KEY) !== null) return;
83
+ const legacy = backendFor('localStorage');
84
+ const access = legacy.get(ACCESS_KEY);
85
+ const refresh = legacy.get(REFRESH_KEY);
86
+ if (access === null && refresh === null) return;
87
+ if (access !== null) _storage.set(ACCESS_KEY, access);
88
+ if (refresh !== null) _storage.set(REFRESH_KEY, refresh);
89
+ legacy.set(ACCESS_KEY, null);
90
+ legacy.set(REFRESH_KEY, null);
91
+ } catch {}
92
+ }
93
+ adoptLegacySession();
64
94
 
65
95
  /** Detect locale from `NEXT_LOCALE` cookie or `navigator.language`. */
66
96
  function detectLocale(): string | null {
@@ -277,6 +307,15 @@ function ensureStorageSync(): void {
277
307
  }
278
308
  });
279
309
  window.addEventListener(SESSION_SYNC_EVENT, () => notifySessionChanged());
310
+ // Cookies fire no `storage` event: a sign-in or sign-out on another subdomain is
311
+ // picked up when this page is looked at again (or at once where `cookieStore` exists).
312
+ window.addEventListener('focus', () => notifySessionChanged());
313
+ document.addEventListener('visibilitychange', () => {
314
+ if (document.visibilityState === 'visible') notifySessionChanged();
315
+ });
316
+ try {
317
+ (window as unknown as { cookieStore?: EventTarget }).cookieStore?.addEventListener('change', () => notifySessionChanged());
318
+ } catch {}
280
319
  }
281
320
 
282
321
  /**
@@ -366,6 +405,16 @@ export const auth = {
366
405
  notifySessionChanged();
367
406
  },
368
407
 
408
+ // ── Shared cookie domain ──────────────────────────────────────────
409
+ /** `'auto'` (default): the registrable domain of the page. A domain forces it; `null` keeps
410
+ * cookies host-only (no sign-in sharing between subdomains). Call before sign-in. */
411
+ setCookieDomain(domain: 'auto' | string | null): void {
412
+ storage.setCookieDomain(domain === null ? false : domain);
413
+ notifySessionChanged();
414
+ },
415
+ /** The `Domain` the session cookie is written with; `null` = host-only. */
416
+ getCookieDomain(): string | null { return storage.cookieDomain(); },
417
+
369
418
  // ── Bearer token ──────────────────────────────────────────────────
370
419
  getToken(): string | null { return _storage.get(ACCESS_KEY); },
371
420
  setToken(token: string | null): void {
@@ -447,14 +496,14 @@ export const auth = {
447
496
 
448
497
  // ── API key ───────────────────────────────────────────────────────
449
498
  getApiKey(): string | null {
450
- return _apiKeyOverride ?? _storage.get(API_KEY_KEY) ?? defaultApiKey();
499
+ return _apiKeyOverride ?? _apiKeyStore.get(API_KEY_KEY) ?? defaultApiKey();
451
500
  },
452
501
  setApiKey(key: string | null): void { _apiKeyOverride = key; },
453
502
  setApiKeyPersist(key: string | null): void {
454
503
  _apiKeyOverride = key;
455
- _storage.set(API_KEY_KEY, key);
504
+ _apiKeyStore.set(API_KEY_KEY, key);
456
505
  },
457
- clearApiKey(): void { _apiKeyOverride = null; _storage.set(API_KEY_KEY, null); },
506
+ clearApiKey(): void { _apiKeyOverride = null; _apiKeyStore.set(API_KEY_KEY, null); },
458
507
 
459
508
  // ── Locale ────────────────────────────────────────────────────────
460
509
  getLocale(): string | null { return _localeOverride ?? detectLocale(); },
@@ -40,8 +40,8 @@ const f = (
40
40
 
41
41
  /** Every key the framework writes. No legacy spellings are read: old keys are simply ignored. */
42
42
  export const CFG_KEYS = {
43
- accessToken: k('cfg.auth.access_token', 'local', true),
44
- refreshToken: k('cfg.auth.refresh_token', 'local', true),
43
+ accessToken: k('cfg.auth.access_token', 'cookie', true),
44
+ refreshToken: k('cfg.auth.refresh_token', 'cookie', true),
45
45
  apiKey: k('cfg.auth.api_key', 'local', true),
46
46
  email: k('cfg.auth.email', 'local', false),
47
47
  emailTs: k('cfg.auth.email_ts', 'local', false),
@@ -90,6 +90,17 @@ export interface StorageOptions {
90
90
  readonly families?: Readonly<Record<string, FamilySpec>>;
91
91
  /** Cookie lifetime for the `cookie` store. Default 30 days. */
92
92
  readonly cookieMaxAgeSeconds?: number;
93
+ /**
94
+ * Cookie `Domain`. `'auto'` (default): the registrable domain of the page, so every subdomain
95
+ * shares the cookie (`example.com` for `my.example.com`). A string forces a domain; `false`
96
+ * keeps cookies host-only.
97
+ */
98
+ readonly cookieDomain?: 'auto' | string | false;
99
+ }
100
+
101
+ export interface WriteOptions {
102
+ /** Cookie store: lifetime of this write. Default: the owner's `cookieMaxAgeSeconds`. */
103
+ readonly maxAgeSeconds?: number;
93
104
  }
94
105
 
95
106
  export interface GuardedStorage {
@@ -102,7 +113,7 @@ export interface GuardedStorage {
102
113
  isValid(key: string): boolean;
103
114
  read(key: string, store?: KeyStore): string | null;
104
115
  /** True when the value reached the browser's storage; false means it is held in memory for this page only. */
105
- write(key: string, value: string, store?: KeyStore): boolean;
116
+ write(key: string, value: string, store?: KeyStore, options?: WriteOptions): boolean;
106
117
  /** Removes the key. */
107
118
  remove(key: string, store?: KeyStore): void;
108
119
  readJSON<T>(key: string, store?: KeyStore): T | null;
@@ -111,12 +122,48 @@ export interface GuardedStorage {
111
122
  keys(store?: Exclude<KeyStore, 'cookie'>): string[];
112
123
  /** Sign-out: removes the `personal` keys. `{ all: true }` removes everything under this owner. */
113
124
  clearAll(options?: { all?: boolean }): void;
125
+ /** Cookie store: change the `Domain` policy (`'auto'`, a domain, or `false` for host-only cookies). */
126
+ setCookieDomain(domain: 'auto' | string | false): void;
127
+ /** The `Domain` cookies are written with right now; `null` means host-only. */
128
+ cookieDomain(): string | null;
114
129
  /** Cross-tab: calls `listener` with the new value when ANOTHER tab changes `key`. Cookies fire no events. */
115
130
  onChange(key: string, listener: (value: string | null) => void, store?: Exclude<KeyStore, 'cookie'>): () => void;
116
131
  }
117
132
 
118
133
  const hasWindow = () => typeof window !== 'undefined';
119
134
 
135
+ const domainByHost = new Map<string, string | null>();
136
+
137
+ /**
138
+ * The broadest domain this page may set cookies on (`example.com` for `my.example.com`,
139
+ * `example.co.uk` for `a.example.co.uk`), found by asking the browser: it refuses a cookie for a
140
+ * public suffix, so no suffix list lives in our code. `null` for an IP, `localhost`, a single label
141
+ * or when cookies are blocked (the cookie is then host-only).
142
+ */
143
+ function detectCookieDomain(): string | null {
144
+ if (!hasWindow() || typeof document === 'undefined') return null;
145
+ const host = window.location.hostname.toLowerCase();
146
+ if (!host || !host.includes('.') || host.includes(':') || /^[0-9.]+$/.test(host)) return null;
147
+ const cached = domainByHost.get(host);
148
+ if (cached !== undefined) return cached;
149
+ const labels = host.split('.');
150
+ const secure = window.location.protocol === 'https:' ? '; Secure' : '';
151
+ let found: string | null = null;
152
+ try {
153
+ for (let i = labels.length - 2; i >= 0 && found === null; i -= 1) {
154
+ const candidate = labels.slice(i).join('.');
155
+ const probe = 'cfg_probe_' + Math.random().toString(36).slice(2);
156
+ document.cookie = probe + '=1; Domain=' + candidate + '; Path=/; Max-Age=30; SameSite=Lax' + secure;
157
+ if (document.cookie.includes(probe + '=1')) found = candidate;
158
+ document.cookie = probe + '=; Domain=' + candidate + '; Path=/; Max-Age=0; SameSite=Lax' + secure;
159
+ }
160
+ } catch {
161
+ found = null;
162
+ }
163
+ domainByHost.set(host, found);
164
+ return found;
165
+ }
166
+
120
167
  function nativeStorage(store: 'local' | 'session'): Storage | null {
121
168
  if (!hasWindow()) return null;
122
169
  try {
@@ -145,6 +192,15 @@ export function createStorage(owner: string, options: StorageOptions = {}): Guar
145
192
  const prefix = `${owner}.`;
146
193
  const pattern = owner === 'cfg' ? /^cfg(\.[a-z0-9_]+)+$/ : new RegExp(`^(cfg|${owner})(\\.[a-z0-9_]+)+$`);
147
194
  const cookieMaxAge = options.cookieMaxAgeSeconds ?? 60 * 60 * 24 * 30;
195
+ let cookiePolicy: 'auto' | string | false = options.cookieDomain ?? 'auto';
196
+ const resolveCookieDomain = (): string | null =>
197
+ cookiePolicy === false ? null : cookiePolicy === 'auto' ? detectCookieDomain() : cookiePolicy;
198
+ // A cookie is identified by name AND Domain: expire the twin without a domain and the one with it.
199
+ const expireCookie = (key: string, domain: string | null) => {
200
+ const secure = window.location.protocol === 'https:' ? '; Secure' : '';
201
+ document.cookie =
202
+ encodeURIComponent(key) + '=; Path=/; Max-Age=0; SameSite=Lax' + (domain ? '; Domain=' + domain : '') + secure;
203
+ };
148
204
  const specs = Object.values(options.keys ?? {});
149
205
  const families = Object.values(options.families ?? {});
150
206
  const specByKey = new Map<string, KeySpec[]>();
@@ -186,12 +242,16 @@ export function createStorage(owner: string, options: StorageOptions = {}): Guar
186
242
  return memory[store].get(key) ?? null;
187
243
  }
188
244
 
189
- function rawSet(store: KeyStore, key: string, value: string): boolean {
245
+ function rawSet(store: KeyStore, key: string, value: string, maxAgeSeconds?: number): boolean {
190
246
  if (!hasWindow()) return false;
191
247
  try {
192
248
  if (store === 'cookie') {
193
249
  const secure = window.location.protocol === 'https:' ? '; Secure' : '';
194
- document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(value)}; Path=/; Max-Age=${cookieMaxAge}; SameSite=Lax${secure}`;
250
+ const domain = resolveCookieDomain();
251
+ const domainPart = domain ? `; Domain=${domain}` : '';
252
+ document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(value)}; Path=/; Max-Age=${Math.max(1, Math.floor(maxAgeSeconds ?? cookieMaxAge))}; SameSite=Lax${domainPart}${secure}`;
253
+ // A host-only cookie written earlier would shadow the shared one: drop that twin.
254
+ if (domain) expireCookie(key, null);
195
255
  if (rawGet('cookie', key) === value && !memory.cookie.has(key)) return true;
196
256
  memory.cookie.set(key, value);
197
257
  return false;
@@ -212,8 +272,9 @@ export function createStorage(owner: string, options: StorageOptions = {}): Guar
212
272
  if (!hasWindow()) return;
213
273
  try {
214
274
  if (store === 'cookie') {
215
- const secure = window.location.protocol === 'https:' ? '; Secure' : '';
216
- document.cookie = `${encodeURIComponent(key)}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
275
+ expireCookie(key, null);
276
+ const domain = resolveCookieDomain();
277
+ if (domain) expireCookie(key, domain);
217
278
  } else {
218
279
  nativeStorage(store)?.removeItem(key);
219
280
  }
@@ -265,10 +326,10 @@ export function createStorage(owner: string, options: StorageOptions = {}): Guar
265
326
  const target = resolveStore(key, store);
266
327
  return rawGet(target, key);
267
328
  },
268
- write(key, value, store) {
329
+ write(key, value, store, writeOptions) {
269
330
  assertOwned(key);
270
331
  const target = resolveStore(key, store);
271
- return rawSet(target, key, value);
332
+ return rawSet(target, key, value, writeOptions?.maxAgeSeconds);
272
333
  },
273
334
  remove(key, store) {
274
335
  assertOwned(key);
@@ -306,6 +367,10 @@ export function createStorage(owner: string, options: StorageOptions = {}): Guar
306
367
  for (const name of cookieNames()) if (name.startsWith(prefix)) rawRemove('cookie', name);
307
368
  }
308
369
  },
370
+ setCookieDomain(domain) {
371
+ cookiePolicy = domain;
372
+ },
373
+ cookieDomain: resolveCookieDomain,
309
374
  onChange(key, listener, store = 'local') {
310
375
  assertValid(key);
311
376
  if (!hasWindow()) return () => {};
@@ -0,0 +1,21 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+
4
+ import { purgeLegacyTokenStorage } from '../hooks/legacyStorage';
5
+
6
+ describe('purgeLegacyTokenStorage', () => {
7
+ beforeEach(() => localStorage.clear());
8
+
9
+ it('removes the pre-cookie token entries and keeps everything else', () => {
10
+ localStorage.setItem('cfg.auth.access_token', 'a');
11
+ localStorage.setItem('cfg.auth.refresh_token', 'r');
12
+ localStorage.setItem('cfg.ui.sidebar_width', '240');
13
+ expect(purgeLegacyTokenStorage().sort()).toEqual(['cfg.auth.access_token', 'cfg.auth.refresh_token']);
14
+ expect(localStorage.getItem('cfg.ui.sidebar_width')).toBe('240');
15
+ expect(localStorage.getItem('cfg.auth.access_token')).toBeNull();
16
+ });
17
+
18
+ it('does nothing when there is nothing to remove', () => {
19
+ expect(purgeLegacyTokenStorage()).toEqual([]);
20
+ });
21
+ });
@@ -91,6 +91,12 @@ interface AccountsProviderProps {
91
91
 
92
92
  export function AccountsProvider({ children }: AccountsProviderProps) {
93
93
  const [profile, setProfile] = useState<User | undefined>(() => {
94
+ // A cached profile is only a head start for a live session; with no session (tokens gone after the move to cookies,
95
+ // or signed out elsewhere) it would render the private UI with nothing behind it.
96
+ if (!auth.isAuthenticated()) {
97
+ clearProfileCache();
98
+ return undefined;
99
+ }
94
100
  const cached = getCachedProfile() as User | null;
95
101
  return cached || undefined;
96
102
  });
@@ -34,6 +34,7 @@ import { CFG_KEYS } from '../../_api/generated/helpers/keys';
34
34
  import { applyRoleLogPolicy } from '../../log-control';
35
35
  import { useCfgRouter, useLocalStorage, useQueryParams, useSession } from '../hooks';
36
36
  import { clearProfileCache } from '../hooks/useProfileCache';
37
+ import { purgeLegacyTokenStorage } from '../hooks/legacyStorage';
37
38
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
38
39
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
39
40
  import { formatAuthError, logAuthFailure } from '../utils/errors';
@@ -144,6 +145,12 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
144
145
  authLogger.warn('Dead session in storage — clearing');
145
146
  auth.clearSession();
146
147
  clearProfileCache();
148
+ } else if (!hasTokens && !auth.isAuthenticated()) {
149
+ // No session at all: a leftover profile cache and the old localStorage tokens (pre-cookie auth) must not keep
150
+ // the private UI alive. Drop them; the page's own guard then sends the user to /auth.
151
+ clearProfileCache();
152
+ purgeLegacyTokenStorage();
153
+ if (accountsRef.current.profile) accountsRef.current.logout();
147
154
  }
148
155
  setProfileSettled(true);
149
156
  return;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * One-time cleanup of what the pre-cookie auth left in the browser.
3
+ *
4
+ * Before 2.1.604 the session tokens lived in `localStorage`. They are cookies now (shared across subdomains), so the old
5
+ * entries are never read again; left alone they are harmless but misleading, and the 4 h profile cache next to them used
6
+ * to keep the UI looking signed in without any token (empty private pages). Cookies are untouched.
7
+ */
8
+
9
+ const LEGACY_TOKEN_KEYS = [
10
+ 'cfg.auth.access_token',
11
+ 'cfg.auth.refresh_token',
12
+ 'auth_token',
13
+ 'refresh_token',
14
+ 'access_token',
15
+ ] as const;
16
+
17
+ /** Removes the old `localStorage` token entries. Safe on the server and with blocked storage. */
18
+ export function purgeLegacyTokenStorage(): string[] {
19
+ const removed: string[] = [];
20
+ try {
21
+ if (typeof window === 'undefined') return removed;
22
+ for (const key of LEGACY_TOKEN_KEYS) {
23
+ if (window.localStorage.getItem(key) !== null) {
24
+ window.localStorage.removeItem(key);
25
+ removed.push(key);
26
+ }
27
+ }
28
+ } catch {
29
+ /* storage blocked: nothing to purge */
30
+ }
31
+ return removed;
32
+ }
package/src/cookies.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Server-safe cookie header parsing, for SSR code that has the raw `Cookie` header
3
+ * (Next.js `headers()`, a Node request) and wants the shared session cookie.
4
+ * No DOM, no dependencies.
5
+ */
6
+
7
+ /** The value of cookie `name` in a `Cookie` request header, or `null`. */
8
+ export function readCookie(cookieHeader: string | null | undefined, name: string): string | null {
9
+ if (!cookieHeader) return null;
10
+ const wanted = encodeURIComponent(name);
11
+ for (const part of cookieHeader.split(';')) {
12
+ const eq = part.indexOf('=');
13
+ if (eq < 0) continue;
14
+ if (part.slice(0, eq).trim() !== wanted) continue;
15
+ try {
16
+ return decodeURIComponent(part.slice(eq + 1).trim());
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+ return null;
22
+ }
package/src/storage.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * read/write or `createStorage('<owner>')` import this, not the generated SDK.
6
6
  * The source is the generated `helpers/keys.ts`.
7
7
  */
8
+ export { readCookie } from './cookies';
8
9
  export {
9
10
  CFG_FAMILIES,
10
11
  CFG_KEYS,
@@ -18,4 +19,5 @@ export type {
18
19
  KeySpec,
19
20
  KeyStore,
20
21
  StorageOptions,
22
+ WriteOptions,
21
23
  } from './_api/generated/helpers/keys';