Skip to content

Commit 0736eaf

Browse files
committed
session now has cookie options per session name
1 parent 645cda9 commit 0736eaf

2 files changed

Lines changed: 159 additions & 6 deletions

File tree

src/router/Session.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,40 @@
11
//data
22
import ReadonlyMap from '../data/ReadonlyMap.js';
3-
//local
4-
import type { Revision, CallableSession } from '../types.js';
3+
//client
4+
import type {
5+
CallableSession,
6+
CookieOptions,
7+
Revision
8+
} from '../types.js';
59

610
/**
711
* Readonly session controller
812
*/
913
export class ReadSession extends ReadonlyMap<string, string|string[]> {
14+
//Every cookie setting needs a home base of its own so each key can
15+
//carry its own marching orders instead of borrowing one global plan.
16+
protected readonly _options = new Map<string, CookieOptions>();
17+
1018
/**
1119
* Returns the session data
1220
*/
1321
public get data() {
1422
return Object.fromEntries(this._map);
1523
}
24+
25+
/**
26+
* Returns the stored cookie settings
27+
*/
28+
public get options() {
29+
return Object.fromEntries(this._options);
30+
}
31+
32+
/**
33+
* Returns the cookie settings for a single key
34+
*/
35+
public getOptions(name: string) {
36+
return this._options.get(name);
37+
}
1638
}
1739

1840
/**
@@ -26,8 +48,11 @@ export class WriteSession extends ReadSession {
2648
* Clear the session
2749
*/
2850
public clear(): void {
51+
//When the whole line retreats, clear both the session payload and the
52+
//cookie orders so nothing stale gets left behind on the field.
2953
for (const name of this.keys()) {
3054
this.revisions.set(name, { action: 'remove' });
55+
this._options.delete(name);
3156
}
3257
this._map.clear();
3358
}
@@ -36,15 +61,36 @@ export class WriteSession extends ReadSession {
3661
* Delete a session entry
3762
*/
3863
public delete(name: string) {
64+
//If a cookie key is removed, retire its settings too so the next write
65+
//does not accidentally reuse an old path, age, or security policy.
3966
this.revisions.set(name, { action: 'remove' });
67+
this._options.delete(name);
4068
return this._map.delete(name);
4169
}
4270

4371
/**
4472
* Set a session entry
4573
*/
46-
public set(name: string, value: string|string[]) {
47-
this.revisions.set(name, { action: 'set', value });
74+
public set(
75+
name: string,
76+
value: string|string[],
77+
options?: CookieOptions
78+
) {
79+
//Each cookie gets its own shield here so one session key can be strict
80+
//and another can stay flexible without either stepping on the other.
81+
if (options) {
82+
this._options.set(name, { ...options });
83+
}
84+
85+
//Record both the value and the keyed cookie settings so the response
86+
//layer can serialize exactly what changed when the dust settles.
87+
const revisionOptions = this._options.get(name);
88+
this.revisions.set(name, {
89+
action: 'set',
90+
...(revisionOptions ? { options: revisionOptions } : {}),
91+
value
92+
});
93+
4894
return this._map.set(name, value);
4995
}
5096
}
@@ -57,22 +103,30 @@ export function session(data?: [string, string|string[]][]): CallableSession {
57103
const callable = Object.assign(
58104
(name: string) => store.get(name),
59105
{
106+
//Expose the same field controls on the callable wrapper so callers do
107+
//not need to care whether they got a class instance or the helper.
60108
clear: () => store.clear(),
61109
delete: (name: string) => store.delete(name),
62110
entries: () => store.entries(),
63111
forEach: (
64112
callback: (value: string|string[], key: string, map: Map<string, string|string[]>) => void
65113
) => store.forEach(callback),
66114
get: (name: string) => store.get(name),
115+
getOptions: (name: string) => store.getOptions(name),
67116
has: (name: string) => store.has(name),
68117
keys: () => store.keys(),
69-
set: (name: string, value: string|string[]) => store.set(name, value),
118+
set: (
119+
name: string,
120+
value: string|string[],
121+
options?: CookieOptions
122+
) => store.set(name, value, options),
70123
values: () => store.values()
71124
} as WriteSession
72125
);
73126
//magic size/data property
74127
Object.defineProperty(callable, 'size', { get: () => store.size });
75128
Object.defineProperty(callable, 'data', { get: () => store.data });
129+
Object.defineProperty(callable, 'options', { get: () => store.options });
76130
Object.defineProperty(callable, 'revisions', { get: () => store.revisions });
77131
return callable;
78-
}
132+
}

tests/Session.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,84 @@ describe('WriteSession', () => {
6767
value: ['item3', 'item4']
6868
});
6969
});
70+
71+
it('should store cookie settings per key', () => {
72+
writeSession.set('sessionId', 'abc123', {
73+
httpOnly: true,
74+
path: '/',
75+
sameSite: 'lax'
76+
});
77+
writeSession.set('theme', 'dark', {
78+
maxAge: 31536000,
79+
path: '/preferences'
80+
});
81+
82+
expect(writeSession.getOptions('sessionId')).to.deep.equal({
83+
httpOnly: true,
84+
path: '/',
85+
sameSite: 'lax'
86+
});
87+
expect(writeSession.getOptions('theme')).to.deep.equal({
88+
maxAge: 31536000,
89+
path: '/preferences'
90+
});
91+
expect(writeSession.revisions.get('sessionId')).to.deep.equal({
92+
action: 'set',
93+
options: {
94+
httpOnly: true,
95+
path: '/',
96+
sameSite: 'lax'
97+
},
98+
value: 'abc123'
99+
});
100+
expect(writeSession.revisions.get('theme')).to.deep.equal({
101+
action: 'set',
102+
options: {
103+
maxAge: 31536000,
104+
path: '/preferences'
105+
},
106+
value: 'dark'
107+
});
108+
});
109+
110+
it('should clear cookie settings when an entry is removed', () => {
111+
writeSession.set('sessionId', 'abc123', {
112+
httpOnly: true,
113+
path: '/'
114+
});
115+
116+
writeSession.delete('sessionId');
117+
118+
expect(writeSession.getOptions('sessionId')).to.be.undefined;
119+
expect(writeSession.revisions.get('sessionId')).to.deep.equal({
120+
action: 'remove'
121+
});
122+
});
123+
124+
it('should preserve existing cookie settings when only the value changes', () => {
125+
writeSession.set('sessionId', 'abc123', {
126+
httpOnly: true,
127+
path: '/',
128+
sameSite: 'strict'
129+
});
130+
131+
writeSession.set('sessionId', 'def456');
132+
133+
expect(writeSession.getOptions('sessionId')).to.deep.equal({
134+
httpOnly: true,
135+
path: '/',
136+
sameSite: 'strict'
137+
});
138+
expect(writeSession.revisions.get('sessionId')).to.deep.equal({
139+
action: 'set',
140+
options: {
141+
httpOnly: true,
142+
path: '/',
143+
sameSite: 'strict'
144+
},
145+
value: 'def456'
146+
});
147+
});
70148
});
71149

72150
describe('session function', () => {
@@ -109,6 +187,27 @@ describe('session function', () => {
109187
]);
110188
});
111189

190+
it('should expose cookie settings through the callable wrapper', () => {
191+
sessionInstance.set('token', 'shield', {
192+
httpOnly: true,
193+
path: '/',
194+
secure: true
195+
});
196+
197+
expect(sessionInstance.getOptions('token')).to.deep.equal({
198+
httpOnly: true,
199+
path: '/',
200+
secure: true
201+
});
202+
expect(Object.entries(sessionInstance.options)).to.deep.equal([
203+
['token', {
204+
httpOnly: true,
205+
path: '/',
206+
secure: true
207+
}]
208+
]);
209+
});
210+
112211
it('should support iteration methods', () => {
113212
const entries = Array.from(sessionInstance.entries());
114213
expect(entries).to.deep.equal([

0 commit comments

Comments
 (0)