lambder 3.3.3 → 3.4.2

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/Readme.md CHANGED
@@ -275,6 +275,35 @@ lambder
275
275
 
276
276
  See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
277
277
 
278
+ #### Keeping session data fresh (`dataRefresh`)
279
+
280
+ Session data often caches values derived from external state: roles, permissions, feature flags. Opt in to `dataRefresh` to give that data a shelf life. Every session read checks it, and once `ttlSeconds` have passed your `refresh` callback rebuilds the data, which is persisted onto the same session record: same tokens, same cookies, the session itself is untouched. Changes to the source of truth then reach every live session within `ttlSeconds`, with no mass session invalidation.
281
+
282
+ ```typescript
283
+ lambder.enableDdbSession({
284
+ tableName: "website-session",
285
+ tableRegion: "us-east-1",
286
+ sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
287
+ dataRefresh: {
288
+ ttlSeconds: 600, // data is renewed at most every 10 minutes
289
+ refresh: async (session) => {
290
+ const user = await loadUser(session.data.userId);
291
+ if (!user || user.disabled) return null; // null ends the session
292
+ return buildSessionData(user);
293
+ },
294
+ },
295
+ });
296
+ ```
297
+
298
+ Semantics:
299
+
300
+ - The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
301
+ - Returning `null` deletes the session; the request is answered as session-expired.
302
+ - Thrown errors fail the request as a `LambderSessionDataRefreshError` and leave the session untouched (they are never mistaken for a logout). Catch inside and return `session.data` to explicitly serve stale instead.
303
+ - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
304
+ - Records created before `dataRefresh` was enabled renew on their first read.
305
+ - `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
306
+
278
307
  #### Session Controller
279
308
 
280
309
  Access the session controller with `lambder.getSessionController(ctx)`:
@@ -285,8 +314,10 @@ Access the session controller with `lambder.getSessionController(ctx)`:
285
314
  | `fetchSession()` | Fetch & validate existing session (throws if not found) |
286
315
  | `fetchSessionIfExists()` | Returns session or null |
287
316
  | `updateSessionData(newData)` | Update session data in DDB |
317
+ | `refreshSessionData()` | Run the `dataRefresh` callback now, regardless of TTL |
288
318
  | `endSession()` | End session, delete from DDB |
289
319
  | `endSessionAll()` | End all sessions for this sessionKey (all devices) |
320
+ | `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
290
321
  | `regenerateSession()` | Regenerate token (use after password change) |
291
322
 
292
323
  ### Type-Safe Templating (html / xml)
package/dist/Lambder.d.ts CHANGED
@@ -5,6 +5,7 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
5
5
  import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
6
6
  import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
7
7
  import { type LambderCorsConfig } from "./LambderCors.js";
8
+ import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
8
9
  import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
9
10
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
10
11
  import type { MergeContract } from "./LambderApiContract.js";
@@ -123,7 +124,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
123
124
  private sessionCsrfCookieKey;
124
125
  constructor(options?: LambderConstructorOptions);
125
126
  enableCors(config: boolean | LambderCorsConfig): this;
126
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }: {
127
+ enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }: {
127
128
  tableName: string;
128
129
  tableRegion: string;
129
130
  sessionSalt: string;
@@ -134,6 +135,15 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
134
135
  cookie?: LambderSessionCookieOptions;
135
136
  partitionKey?: string;
136
137
  sortKey?: string;
138
+ /**
139
+ * Opt-in freshness for session.data derived from external state
140
+ * (roles, permissions, feature flags...). Every session read
141
+ * renews data past its ttlSeconds via your refresh callback,
142
+ * persisting in place on the same record: same tokens, same
143
+ * cookies. Return null from refresh to end the session. See
144
+ * LambderSessionDataRefreshConfig for the exact semantics.
145
+ */
146
+ dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
137
147
  }): this;
138
148
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
139
149
  setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
package/dist/Lambder.js CHANGED
@@ -71,12 +71,13 @@ export default class Lambder {
71
71
  this.corsConfig = config === true ? {} : (config === false ? null : config);
72
72
  return this;
73
73
  }
74
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }) {
74
+ enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }) {
75
75
  this.lambderSessionManager = new LambderSessionManager({
76
76
  tableName, tableRegion,
77
77
  partitionKey: partitionKey ?? "pk",
78
78
  sortKey: sortKey ?? "sk",
79
79
  sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds,
80
+ dataRefresh,
80
81
  });
81
82
  this.sessionCookieOptions = cookie ?? {};
82
83
  return this;
@@ -1,6 +1,6 @@
1
1
  import { LambderRenderContext, LambderSessionRenderContext } from "./LambderContext.js";
2
2
  import type LambderSessionManager from "./LambderSessionManager.js";
3
- import type { LambderSessionContext } from "./LambderSessionManager.js";
3
+ import { type LambderSessionContext } from "./LambderSessionManager.js";
4
4
  export type LambderSessionCookieOptions = {
5
5
  /**
6
6
  * e.g. ".example.com" to share sessions across subdomains. Pass a function to
@@ -32,9 +32,22 @@ export default class LambderSessionController<TSessionData = any> {
32
32
  createSession(sessionKey: string, data?: TSessionData, ttlInSeconds?: number): Promise<LambderSessionContext<TSessionData>>;
33
33
  regenerateSession(): Promise<LambderSessionContext<TSessionData>>;
34
34
  fetchSession(): Promise<LambderSessionContext<TSessionData>>;
35
- fetchSessionIfExists(): Promise<LambderSessionContext | null>;
35
+ fetchSessionIfExists(): Promise<LambderSessionContext<TSessionData> | null>;
36
36
  isSessionValid(session: any): boolean;
37
37
  updateSessionData(newData: any): Promise<LambderSessionContext>;
38
+ /**
39
+ * Force-runs the dataRefresh callback now (see enableDdbSession) and
40
+ * persists the result onto the current session. Returns the updated
41
+ * session, or null when the callback ended it: the record is deleted and
42
+ * the session cookies are cleared.
43
+ */
44
+ refreshSessionData(): Promise<LambderSessionContext<TSessionData> | null>;
45
+ /**
46
+ * Deletes every session of the given sessionKey (e.g. a user id): "log
47
+ * this subject out everywhere". Unlike endSessionAll it needs no fetched
48
+ * session and touches no cookies, so it works on any subject.
49
+ */
50
+ deleteSessionAllByKey(sessionKey: string): Promise<void>;
38
51
  endSession(): Promise<void>;
39
52
  endSessionAll(): Promise<void>;
40
53
  }
@@ -1,3 +1,4 @@
1
+ import { LambderSessionDataRefreshError } from "./LambderSessionManager.js";
1
2
  export default class LambderSessionController {
2
3
  lambderSessionManager;
3
4
  sessionTokenCookieKey;
@@ -90,6 +91,10 @@ export default class LambderSessionController {
90
91
  return await this.fetchSession();
91
92
  }
92
93
  catch (err) {
94
+ // Missing or invalid sessions become null, but a failing
95
+ // dataRefresh callback must not masquerade as a logout.
96
+ if (err instanceof LambderSessionDataRefreshError)
97
+ throw err;
93
98
  return null;
94
99
  }
95
100
  }
@@ -113,6 +118,34 @@ export default class LambderSessionController {
113
118
  return this.ctx.session;
114
119
  }
115
120
  ;
121
+ /**
122
+ * Force-runs the dataRefresh callback now (see enableDdbSession) and
123
+ * persists the result onto the current session. Returns the updated
124
+ * session, or null when the callback ended it: the record is deleted and
125
+ * the session cookies are cleared.
126
+ */
127
+ async refreshSessionData() {
128
+ if (!this.ctx.session)
129
+ throw new Error("Session not found.");
130
+ const refreshed = await this.lambderSessionManager.refreshSessionData(this.ctx.session);
131
+ if (!refreshed) {
132
+ this.clearSessionCookies();
133
+ this.ctx.session = null;
134
+ return null;
135
+ }
136
+ this.ctx.session = refreshed;
137
+ return this.ctx.session;
138
+ }
139
+ ;
140
+ /**
141
+ * Deletes every session of the given sessionKey (e.g. a user id): "log
142
+ * this subject out everywhere". Unlike endSessionAll it needs no fetched
143
+ * session and touches no cookies, so it works on any subject.
144
+ */
145
+ async deleteSessionAllByKey(sessionKey) {
146
+ await this.lambderSessionManager.deleteSessionAllByKey(sessionKey);
147
+ }
148
+ ;
116
149
  async endSession() {
117
150
  if (!this.ctx.session)
118
151
  throw new Error("Session not found.");
@@ -8,7 +8,43 @@ export type LambderSessionContext<SessionData = any> = {
8
8
  expiresAt: number;
9
9
  lastAccessedAt: number;
10
10
  ttlInSeconds: number;
11
+ /**
12
+ * When `data` must be renewed via the dataRefresh callback (epoch seconds).
13
+ * Only present when dataRefresh is configured; independent of the
14
+ * session's own expiresAt.
15
+ */
16
+ dataExpiresAt?: number;
11
17
  };
18
+ /**
19
+ * Opt-in freshness for session.data that is derived from external state
20
+ * (roles, permissions, feature flags...). When configured, every session read
21
+ * checks dataExpiresAt and calls `refresh` past it, persisting the result
22
+ * onto the same session record: same tokens, same cookies, the session
23
+ * itself is untouched. The refresh write and the sliding-expiration write
24
+ * share a single DynamoDB put when both are due.
25
+ */
26
+ export type LambderSessionDataRefreshConfig<SessionData = any> = {
27
+ /** Seconds session.data stays valid before refresh() runs on read. */
28
+ ttlSeconds: number;
29
+ /**
30
+ * Rebuild session.data from its source of truth. Must be a pure
31
+ * derivation (concurrent reads may run it in parallel; last write wins).
32
+ * Return null to end the session: the record is deleted and the read
33
+ * reports no session. Thrown errors fail the read as a
34
+ * LambderSessionDataRefreshError and leave the session untouched; catch
35
+ * inside and return session.data to explicitly serve stale instead.
36
+ */
37
+ refresh: (session: LambderSessionContext<SessionData>) => Promise<SessionData | null>;
38
+ };
39
+ /**
40
+ * Wraps errors thrown by the dataRefresh callback so they stay
41
+ * distinguishable from "no session": fetchSessionIfExists() swallows missing
42
+ * or invalid sessions but rethrows this, otherwise a transient failure in
43
+ * the refresh source would masquerade as a logout.
44
+ */
45
+ export declare class LambderSessionDataRefreshError extends Error {
46
+ constructor(cause: unknown);
47
+ }
12
48
  export default class LambderSessionManager {
13
49
  private tableName;
14
50
  private sessionSalt;
@@ -17,7 +53,8 @@ export default class LambderSessionManager {
17
53
  private ddbDocumentClient;
18
54
  private enableSlidingExpiration;
19
55
  private slidingWriteIntervalSeconds;
20
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, }: {
56
+ private dataRefresh;
57
+ constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, }: {
21
58
  tableName: string;
22
59
  tableRegion: string;
23
60
  partitionKey: string;
@@ -25,6 +62,7 @@ export default class LambderSessionManager {
25
62
  sessionSalt: string;
26
63
  enableSlidingExpiration?: boolean;
27
64
  slidingWriteIntervalSeconds?: number;
65
+ dataRefresh?: LambderSessionDataRefreshConfig;
28
66
  });
29
67
  private sessionUserKeyHasher;
30
68
  private constantTimeCompare;
@@ -33,11 +71,27 @@ export default class LambderSessionManager {
33
71
  private ddbDeleteItem;
34
72
  private ddbQueryAllByPartitionKey;
35
73
  private ddbDeleteAllByPartitionKey;
36
- createSession(sessionKey: string, data?: any, ttlInSeconds?: number): Promise<LambderSessionContext>;
74
+ createSession(sessionKey: string, data?: any, ttlInSeconds?: number, options?: {
75
+ /** Carries an existing data freshness stamp over (used by regenerateSession). */
76
+ dataExpiresAt?: number;
77
+ }): Promise<LambderSessionContext>;
37
78
  updateSessionData(session: LambderSessionContext, newData?: any): Promise<LambderSessionContext>;
38
79
  getSession(sessionToken: string): Promise<LambderSessionContext | null>;
80
+ /**
81
+ * Runs the dataRefresh callback now, regardless of dataExpiresAt, and
82
+ * persists the result onto the same record. Returns the updated session,
83
+ * or null when the callback ended it (the record is deleted). Requires
84
+ * dataRefresh to be configured.
85
+ */
86
+ refreshSessionData(session: LambderSessionContext): Promise<LambderSessionContext | null>;
39
87
  isSessionValid(session: any, sessionToken: any, csrfToken: any, skipCsrfTokenCheck?: boolean): boolean;
40
88
  deleteSession(session: Record<string, any>): Promise<boolean>;
41
89
  deleteSessionAll(session: Record<string, any>): Promise<boolean>;
90
+ /**
91
+ * Deletes every session created for the given sessionKey (e.g. a user
92
+ * id): "log this subject out everywhere", without needing a fetched
93
+ * session record.
94
+ */
95
+ deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
42
96
  regenerateSession(session: LambderSessionContext): Promise<LambderSessionContext>;
43
97
  }
@@ -1,6 +1,18 @@
1
1
  import crypto from "crypto";
2
2
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
3
3
  import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
4
+ /**
5
+ * Wraps errors thrown by the dataRefresh callback so they stay
6
+ * distinguishable from "no session": fetchSessionIfExists() swallows missing
7
+ * or invalid sessions but rethrows this, otherwise a transient failure in
8
+ * the refresh source would masquerade as a logout.
9
+ */
10
+ export class LambderSessionDataRefreshError extends Error {
11
+ constructor(cause) {
12
+ super(`Session dataRefresh failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
13
+ this.name = "LambderSessionDataRefreshError";
14
+ }
15
+ }
4
16
  export default class LambderSessionManager {
5
17
  tableName;
6
18
  sessionSalt;
@@ -9,13 +21,15 @@ export default class LambderSessionManager {
9
21
  ddbDocumentClient;
10
22
  enableSlidingExpiration;
11
23
  slidingWriteIntervalSeconds;
12
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, }) {
24
+ dataRefresh;
25
+ constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, }) {
13
26
  this.tableName = tableName;
14
27
  this.sessionSalt = sessionSalt;
15
28
  this.partitionKey = partitionKey;
16
29
  this.sortKey = sortKey;
17
30
  this.enableSlidingExpiration = enableSlidingExpiration;
18
31
  this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds ?? null;
32
+ this.dataRefresh = dataRefresh ?? null;
19
33
  const ddbClient = new DynamoDBClient({ region: tableRegion });
20
34
  this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
21
35
  }
@@ -74,7 +88,7 @@ export default class LambderSessionManager {
74
88
  }));
75
89
  }
76
90
  }
77
- async createSession(sessionKey, data = {}, ttlInSeconds = 30 * 24 * 60 * 60) {
91
+ async createSession(sessionKey, data = {}, ttlInSeconds = 30 * 24 * 60 * 60, options) {
78
92
  const sessionKeyHash = this.sessionUserKeyHasher(sessionKey);
79
93
  const sessionSortKey = crypto.randomBytes(32).toString("hex");
80
94
  const sessionToken = `${sessionKeyHash}:${sessionSortKey}`;
@@ -87,7 +101,8 @@ export default class LambderSessionManager {
87
101
  [this.sortKey]: sessionSortKey,
88
102
  sessionToken, csrfToken,
89
103
  sessionKey, data,
90
- createdAt, lastAccessedAt, expiresAt, ttlInSeconds
104
+ createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
105
+ ...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
91
106
  };
92
107
  await this.ddbPutItem(session);
93
108
  return session;
@@ -97,6 +112,10 @@ export default class LambderSessionManager {
97
112
  throw new Error("Invalid session");
98
113
  session.data = newData;
99
114
  session.lastAccessedAt = Math.floor(Date.now() / 1000);
115
+ // Explicitly written data is fresh by definition.
116
+ if (this.dataRefresh) {
117
+ session.dataExpiresAt = session.lastAccessedAt + this.dataRefresh.ttlSeconds;
118
+ }
100
119
  // Update expiration if sliding expiration is enabled
101
120
  if (this.enableSlidingExpiration) {
102
121
  session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
@@ -108,43 +127,105 @@ export default class LambderSessionManager {
108
127
  const [sessionKeyHash, sessionSortKey] = sessionToken.split(":");
109
128
  if (!sessionKeyHash || !sessionSortKey)
110
129
  return null;
130
+ let session;
111
131
  try {
112
- let session = await this.ddbGetItem({
132
+ session = await this.ddbGetItem({
113
133
  [this.partitionKey]: sessionKeyHash,
114
134
  [this.sortKey]: sessionSortKey
115
135
  });
116
- // Use constant error response to prevent timing attacks
117
- if (!session)
118
- return null;
119
- if (!session.sessionToken || !this.constantTimeCompare(session.sessionToken, sessionToken))
120
- return null;
121
- if (!session.csrfToken)
122
- return null;
123
- if (!session.sessionKey)
124
- return null;
125
- if (!session.createdAt)
126
- return null;
127
- if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
136
+ }
137
+ catch (err) {
138
+ return null;
139
+ }
140
+ // Use constant error response to prevent timing attacks
141
+ if (!session)
142
+ return null;
143
+ if (!session.sessionToken || !this.constantTimeCompare(session.sessionToken, sessionToken))
144
+ return null;
145
+ if (!session.csrfToken)
146
+ return null;
147
+ if (!session.sessionKey)
148
+ return null;
149
+ if (!session.createdAt)
150
+ return null;
151
+ if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
152
+ return null;
153
+ const now = Math.floor(Date.now() / 1000);
154
+ let needsWrite = false;
155
+ // Renew session.data once its shelf life has passed (opt-in
156
+ // dataRefresh). Records from before the feature was enabled have no
157
+ // dataExpiresAt, so they renew on first read.
158
+ if (this.dataRefresh && (session.dataExpiresAt ?? 0) <= now) {
159
+ let newData;
160
+ try {
161
+ newData = await this.dataRefresh.refresh(session);
162
+ }
163
+ catch (err) {
164
+ // A failing refresh must fail this read, not masquerade as a
165
+ // missing session or silently serve stale data.
166
+ throw new LambderSessionDataRefreshError(err);
167
+ }
168
+ if (newData === null) {
169
+ await this.deleteSession(session);
128
170
  return null;
129
- // Update last accessed time if sliding expiration is enabled.
130
- // Throttled: skip the DynamoDB write when the session was refreshed
131
- // recently, to avoid a write on every request.
132
- if (this.enableSlidingExpiration) {
133
- const now = Math.floor(Date.now() / 1000);
134
- const minInterval = this.slidingWriteIntervalSeconds
135
- ?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
136
- if (now - (session.lastAccessedAt || 0) >= minInterval) {
137
- session.lastAccessedAt = now;
138
- session.expiresAt = now + session.ttlInSeconds;
139
- // Wait for the update to ensure it persists before Lambda freezes
140
- await this.ddbPutItem(session).catch(() => { });
141
- }
142
171
  }
143
- return session;
172
+ session.data = newData;
173
+ session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
174
+ needsWrite = true;
175
+ }
176
+ // Update last accessed time if sliding expiration is enabled.
177
+ // Throttled: skip the DynamoDB write when the session was refreshed
178
+ // recently, to avoid a write on every request. A due data renewal
179
+ // above forces the write anyway, so both updates share one put.
180
+ if (this.enableSlidingExpiration) {
181
+ const minInterval = this.slidingWriteIntervalSeconds
182
+ ?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
183
+ if (needsWrite || now - (session.lastAccessedAt || 0) >= minInterval) {
184
+ session.lastAccessedAt = now;
185
+ session.expiresAt = now + session.ttlInSeconds;
186
+ needsWrite = true;
187
+ }
188
+ }
189
+ if (needsWrite) {
190
+ // Wait for the update to ensure it persists before Lambda freezes.
191
+ // A failed put is not fatal: the data served is fresh, and an
192
+ // unpersisted renewal simply runs again on the next read.
193
+ await this.ddbPutItem(session).catch(() => { });
194
+ }
195
+ return session;
196
+ }
197
+ ;
198
+ /**
199
+ * Runs the dataRefresh callback now, regardless of dataExpiresAt, and
200
+ * persists the result onto the same record. Returns the updated session,
201
+ * or null when the callback ended it (the record is deleted). Requires
202
+ * dataRefresh to be configured.
203
+ */
204
+ async refreshSessionData(session) {
205
+ if (!this.dataRefresh)
206
+ throw new Error("dataRefresh is not configured. Pass dataRefresh to enableDdbSession(...) to enable.");
207
+ if (!session)
208
+ throw new Error("Invalid session");
209
+ let newData;
210
+ try {
211
+ newData = await this.dataRefresh.refresh(session);
144
212
  }
145
213
  catch (err) {
214
+ throw new LambderSessionDataRefreshError(err);
215
+ }
216
+ if (newData === null) {
217
+ await this.deleteSession(session);
146
218
  return null;
147
219
  }
220
+ const now = Math.floor(Date.now() / 1000);
221
+ session.data = newData;
222
+ session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
223
+ session.lastAccessedAt = now;
224
+ if (this.enableSlidingExpiration) {
225
+ session.expiresAt = now + session.ttlInSeconds;
226
+ }
227
+ await this.ddbPutItem(session);
228
+ return session;
148
229
  }
149
230
  ;
150
231
  isSessionValid(session, sessionToken, csrfToken, skipCsrfTokenCheck = false) {
@@ -183,13 +264,25 @@ export default class LambderSessionManager {
183
264
  return true;
184
265
  }
185
266
  ;
267
+ /**
268
+ * Deletes every session created for the given sessionKey (e.g. a user
269
+ * id): "log this subject out everywhere", without needing a fetched
270
+ * session record.
271
+ */
272
+ async deleteSessionAllByKey(sessionKey) {
273
+ await this.ddbDeleteAllByPartitionKey(this.sessionUserKeyHasher(sessionKey));
274
+ return true;
275
+ }
276
+ ;
186
277
  async regenerateSession(session) {
187
278
  if (!session)
188
279
  throw new Error("Invalid session");
189
280
  // Delete old session
190
281
  await this.deleteSession(session);
191
- // Create new session with same sessionKey and data but new tokens
192
- return await this.createSession(session.sessionKey, session.data, session.ttlInSeconds);
282
+ // Create new session with same sessionKey and data but new tokens.
283
+ // The data freshness stamp carries over: rotating tokens must not
284
+ // extend how long dataRefresh-managed data may stay unrenewed.
285
+ return await this.createSession(session.sessionKey, session.data, session.ttlInSeconds, session.dataExpiresAt !== undefined ? { dataExpiresAt: session.dataExpiresAt } : undefined);
193
286
  }
194
287
  }
195
288
  ;
package/dist/index.d.ts CHANGED
@@ -16,7 +16,8 @@ export type { LambderRouteMatcher, LambderCorsConfig, LambderConstructorOptions,
16
16
  export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
17
17
  export type { LambderPublicFilesOptions } from "./LambderPublicFiles.js";
18
18
  export type { LambderSessionCookieOptions } from "./LambderSessionController.js";
19
- export type { LambderSessionContext } from "./LambderSessionManager.js";
19
+ export type { LambderSessionContext, LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
20
+ export { LambderSessionDataRefreshError } from "./LambderSessionManager.js";
20
21
  export { LambderDdbCache } from "./LambderDdbCache.js";
21
22
  export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./LambderDdbCache.js";
22
23
  export { LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
14
14
  export { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
15
15
  // Public file serving
16
16
  export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
17
+ export { LambderSessionDataRefreshError } from "./LambderSessionManager.js";
17
18
  // DynamoDB-backed compressed cache (standalone, server-only)
18
19
  export { LambderDdbCache } from "./LambderDdbCache.js";
19
20
  // DynamoDB-backed fixed-window rate limiter (standalone, server-only)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "3.3.3",
3
+ "version": "3.4.2",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",