@djangocfg/api 2.1.603 → 2.1.604
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/dist/auth.cjs +94 -13
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.mjs +94 -13
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +94 -13
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.mjs +94 -13
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +94 -13
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +94 -13
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +94 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.mjs +94 -13
- package/dist/index.mjs.map +1 -1
- package/dist/storage.cjs +67 -9
- package/dist/storage.cjs.map +1 -1
- package/dist/storage.d.cts +24 -2
- package/dist/storage.d.ts +24 -2
- package/dist/storage.mjs +67 -9
- package/dist/storage.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/sharedCookie.test.ts +112 -0
- package/src/_api/generated/helpers/auth.ts +58 -9
- package/src/_api/generated/helpers/keys.ts +74 -9
- package/src/cookies.ts +22 -0
- package/src/storage.ts +2 -0
|
@@ -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
|
|
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
|
|
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`
|
|
52
|
-
*
|
|
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 = '
|
|
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 ??
|
|
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
|
-
|
|
504
|
+
_apiKeyStore.set(API_KEY_KEY, key);
|
|
456
505
|
},
|
|
457
|
-
clearApiKey(): void { _apiKeyOverride = 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', '
|
|
44
|
-
refreshToken: k('cfg.auth.refresh_token', '
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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 () => {};
|
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';
|