lambder 1.0.127 → 1.0.129

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.
@@ -10,6 +10,7 @@ export type LambderSessionContext = {
10
10
  data: any;
11
11
  createdAt: number;
12
12
  expiresAt: number;
13
+ lastAccessedAt: number;
13
14
  ttlInSeconds: number;
14
15
  };
15
16
 
@@ -20,22 +21,26 @@ export default class LambderSessionManager{
20
21
  private partitionKey: string;
21
22
  private sortKey: string;
22
23
  private ddbDocumentClient: DynamoDBDocumentClient;
24
+ private enableSlidingExpiration: boolean;
23
25
 
24
26
  constructor(
25
27
  {
26
28
  tableName, tableRegion,
27
29
  partitionKey, sortKey,
28
30
  sessionSalt,
31
+ enableSlidingExpiration = true,
29
32
  }: {
30
33
  tableName: string, tableRegion: string,
31
34
  partitionKey: string, sortKey: string,
32
35
  sessionSalt: string,
36
+ enableSlidingExpiration?: boolean,
33
37
  }
34
38
  ){
35
39
  this.tableName = tableName;
36
40
  this.sessionSalt = sessionSalt;
37
41
  this.partitionKey = partitionKey;
38
42
  this.sortKey = sortKey;
43
+ this.enableSlidingExpiration = enableSlidingExpiration;
39
44
 
40
45
  const ddbClient = new DynamoDBClient({ region: tableRegion });
41
46
  this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
@@ -47,6 +52,13 @@ export default class LambderSessionManager{
47
52
  .digest("hex");
48
53
  }
49
54
 
55
+ private constantTimeCompare(a: string, b: string): boolean {
56
+ if (a.length !== b.length) return false;
57
+ const bufferA = Buffer.from(a, 'utf8');
58
+ const bufferB = Buffer.from(b, 'utf8');
59
+ return crypto.timingSafeEqual(new Uint8Array(bufferA), new Uint8Array(bufferB));
60
+ }
61
+
50
62
  private async ddbGetItem<T=any>(key:Record<string,string|number>){
51
63
  const response = await this.ddbDocumentClient.send(
52
64
  new GetCommand({ TableName: this.tableName, Key: key, ConsistentRead: true })
@@ -101,8 +113,9 @@ export default class LambderSessionManager{
101
113
  const sessionKeyHash = this.sessionUserKeyHasher(sessionKey);
102
114
  const sessionSortKey = crypto.randomBytes(32).toString("hex");
103
115
  const sessionToken = `${sessionKeyHash}:${sessionSortKey}`;
104
- const csrfToken = crypto.randomBytes(8).toString("hex");
116
+ const csrfToken = crypto.randomBytes(32).toString("hex");
105
117
  const createdAt = Math.floor(Date.now()/1000);
118
+ const lastAccessedAt = createdAt;
106
119
  const expiresAt = Number(createdAt) + Number(ttlInSeconds);
107
120
 
108
121
  const session = {
@@ -110,7 +123,7 @@ export default class LambderSessionManager{
110
123
  [this.sortKey]: sessionSortKey,
111
124
  sessionToken, csrfToken,
112
125
  sessionKey, data,
113
- createdAt, expiresAt, ttlInSeconds
126
+ createdAt, lastAccessedAt, expiresAt, ttlInSeconds
114
127
  };
115
128
  await this.ddbPutItem(session);
116
129
  return session;
@@ -122,6 +135,13 @@ export default class LambderSessionManager{
122
135
  ): Promise<LambderSessionContext> {
123
136
  if(!session) throw new Error("Invalid session");
124
137
  session.data = newData;
138
+ session.lastAccessedAt = Math.floor(Date.now()/1000);
139
+
140
+ // Update expiration if sliding expiration is enabled
141
+ if(this.enableSlidingExpiration){
142
+ session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
143
+ }
144
+
125
145
  await this.ddbPutItem(session);
126
146
  return session;
127
147
  }
@@ -134,12 +154,23 @@ export default class LambderSessionManager{
134
154
  [this.partitionKey]: sessionKeyHash,
135
155
  [this.sortKey]: sessionSortKey
136
156
  });
137
- if(!session) throw new Error("Session not found");
138
- if(!session.sessionToken || session.sessionToken !== sessionToken) throw new Error("Not found: session.sessionToken");
139
- if(!session.csrfToken) throw new Error("Not found: session.csrfToken");
140
- if(!session.sessionKey) throw new Error("Not found: session.sessionKey");
141
- if(!session.createdAt) throw new Error("Not found: session.createdAt");
142
- if(!session.expiresAt || session.expiresAt < Date.now()/1000) throw new Error("Not found: session.expiresAt");
157
+
158
+ // Use constant error response to prevent timing attacks
159
+ if(!session) return null;
160
+ if(!session.sessionToken || !this.constantTimeCompare(session.sessionToken, sessionToken)) return null;
161
+ if(!session.csrfToken) return null;
162
+ if(!session.sessionKey) return null;
163
+ if(!session.createdAt) return null;
164
+ if(!session.expiresAt || session.expiresAt < Date.now()/1000) return null;
165
+
166
+ // Update last accessed time if sliding expiration is enabled
167
+ if(this.enableSlidingExpiration){
168
+ session.lastAccessedAt = Math.floor(Date.now()/1000);
169
+ session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
170
+ // Fire and forget - don't wait for the update
171
+ this.ddbPutItem(session).catch(() => {});
172
+ }
173
+
143
174
  return session;
144
175
  }catch(err){
145
176
  return null;
@@ -149,14 +180,14 @@ export default class LambderSessionManager{
149
180
  public isSessionValid(session: any, sessionToken: any, csrfToken: any, skipCsrfTokenCheck = false){
150
181
  if(!session) return false;
151
182
  if(!sessionToken || typeof sessionToken !== "string") return false;
152
- if(session.sessionToken !== sessionToken) return false;
183
+ if(!this.constantTimeCompare(session.sessionToken, sessionToken)) return false;
153
184
  if(!session.csrfToken) return false;
154
185
  if(!session.sessionKey) return false;
155
186
  if(!session.createdAt) return false;
156
187
  if(!session.expiresAt || session.expiresAt < Date.now()/1000) return false;
157
188
  if(!skipCsrfTokenCheck){
158
189
  if(!csrfToken || typeof csrfToken !== "string") return false;
159
- if(session.csrfToken !== csrfToken) return false;
190
+ if(!this.constantTimeCompare(session.csrfToken, csrfToken)) return false;
160
191
  }
161
192
  return true;
162
193
  }
@@ -173,4 +204,14 @@ export default class LambderSessionManager{
173
204
  await this.ddbDeleteAllByPartitionKey(session[this.partitionKey])
174
205
  return true;
175
206
  };
207
+
208
+ public async regenerateSession(session: LambderSessionContext): Promise<LambderSessionContext> {
209
+ if(!session) throw new Error("Invalid session");
210
+
211
+ // Delete old session
212
+ await this.deleteSession(session);
213
+
214
+ // Create new session with same sessionKey and data but new tokens
215
+ return await this.createSession(session.sessionKey, session.data, session.ttlInSeconds);
216
+ }
176
217
  };
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export { default as LambderSessionManager } from "./LambderSessionManager.js";
8
8
 
9
9
  // Type-safe API contract utilities
10
10
  export {
11
+ type ApiContractShape,
11
12
  type ApiContract,
12
13
  type ApiInput,
13
14
  type ApiOutput,
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Output Type Enforcement Runtime Tests
3
+ *
4
+ * These tests verify that the typed APIs work correctly at runtime
5
+ * while maintaining type safety at compile time.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { APIGatewayProxyEvent, Context } from 'aws-lambda';
10
+ import Lambder from '../src/Lambder.js';
11
+ import type { ApiContract } from '../src/index.js';
12
+
13
+ // Mock AWS Lambda event and context
14
+ const createMockEvent = (apiName: string, payload: any): APIGatewayProxyEvent => ({
15
+ body: JSON.stringify({ apiName, payload }),
16
+ headers: { Host: 'localhost' },
17
+ multiValueHeaders: {},
18
+ httpMethod: 'POST',
19
+ isBase64Encoded: false,
20
+ path: '/api',
21
+ pathParameters: null,
22
+ queryStringParameters: null,
23
+ multiValueQueryStringParameters: null,
24
+ stageVariables: null,
25
+ requestContext: {} as any,
26
+ resource: '',
27
+ });
28
+
29
+ const createMockContext = (): Context => ({
30
+ callbackWaitsForEmptyEventLoop: false,
31
+ functionName: 'test',
32
+ functionVersion: '1',
33
+ invokedFunctionArn: 'arn',
34
+ memoryLimitInMB: '128',
35
+ awsRequestId: '123',
36
+ logGroupName: 'group',
37
+ logStreamName: 'stream',
38
+ getRemainingTimeInMillis: () => 1000,
39
+ done: () => {},
40
+ fail: () => {},
41
+ succeed: () => {},
42
+ });
43
+
44
+ // Test contract
45
+ type TestContract = ApiContract<{
46
+ echo: { input: { message: string }, output: { echo: string } },
47
+ add: { input: { a: number, b: number }, output: number },
48
+ getUser: { input: { userId: string }, output: { id: string, name: string, age: number } },
49
+ listUsers: { input: void, output: Array<{ id: string, name: string }> },
50
+ findUser: { input: { email: string }, output: { id: string, name: string } | null },
51
+ deleteUser: { input: { userId: string }, output: boolean },
52
+ }>;
53
+
54
+ // ============================================================================
55
+ // Runtime Tests
56
+ // ============================================================================
57
+
58
+ describe('Output Type Enforcement - Runtime', () => {
59
+ it('should return correct primitive types', async () => {
60
+ const lambder = new Lambder<TestContract>({
61
+ publicPath: './public',
62
+ apiPath: '/api',
63
+ });
64
+
65
+ lambder.addApi('add', async (ctx, resolver) => {
66
+ const { a, b } = ctx.apiPayload;
67
+ const sum = a + b;
68
+ return resolver.api(sum);
69
+ });
70
+
71
+ const event = createMockEvent('add', { a: 5, b: 3 });
72
+ const context = createMockContext();
73
+ const response = await lambder.render(event, context);
74
+
75
+ expect(response.statusCode).toBe(200);
76
+ const body = JSON.parse(response.body || '{}');
77
+ expect(body.payload).toBe(8);
78
+ });
79
+
80
+ it('should return correct object types', async () => {
81
+ const lambder = new Lambder<TestContract>({
82
+ publicPath: './public',
83
+ apiPath: '/api',
84
+ });
85
+
86
+ lambder.addApi('getUser', async (ctx, resolver) => {
87
+ const userId = ctx.apiPayload.userId;
88
+ return resolver.api({
89
+ id: userId,
90
+ name: 'John Doe',
91
+ age: 30
92
+ });
93
+ });
94
+
95
+ const event = createMockEvent('getUser', { userId: '123' });
96
+ const context = createMockContext();
97
+ const response = await lambder.render(event, context);
98
+
99
+ expect(response.statusCode).toBe(200);
100
+ const body = JSON.parse(response.body || '{}');
101
+ expect(body.payload).toEqual({
102
+ id: '123',
103
+ name: 'John Doe',
104
+ age: 30
105
+ });
106
+ });
107
+
108
+ it('should return correct array types', async () => {
109
+ const lambder = new Lambder<TestContract>({
110
+ publicPath: './public',
111
+ apiPath: '/api',
112
+ });
113
+
114
+ lambder.addApi('listUsers', async (ctx, resolver) => {
115
+ return resolver.api([
116
+ { id: '1', name: 'Alice' },
117
+ { id: '2', name: 'Bob' }
118
+ ]);
119
+ });
120
+
121
+ const event = createMockEvent('listUsers', undefined);
122
+ const context = createMockContext();
123
+ const response = await lambder.render(event, context);
124
+
125
+ expect(response.statusCode).toBe(200);
126
+ const body = JSON.parse(response.body || '{}');
127
+ expect(body.payload).toEqual([
128
+ { id: '1', name: 'Alice' },
129
+ { id: '2', name: 'Bob' }
130
+ ]);
131
+ });
132
+
133
+ it('should handle null returns correctly', async () => {
134
+ const lambder = new Lambder<TestContract>({
135
+ publicPath: './public',
136
+ apiPath: '/api',
137
+ });
138
+
139
+ lambder.addApi('findUser', async (ctx, resolver) => {
140
+ const email = ctx.apiPayload.email;
141
+ if (email === 'notfound@example.com') {
142
+ return resolver.api(null);
143
+ }
144
+ return resolver.api({ id: '1', name: 'Found User' });
145
+ });
146
+
147
+ // Test null case
148
+ const event1 = createMockEvent('findUser', { email: 'notfound@example.com' });
149
+ const context1 = createMockContext();
150
+ const response1 = await lambder.render(event1, context1);
151
+
152
+ expect(response1.statusCode).toBe(200);
153
+ const body1 = JSON.parse(response1.body || '{}');
154
+ expect(body1.payload).toBeNull();
155
+
156
+ // Test found case
157
+ const event2 = createMockEvent('findUser', { email: 'found@example.com' });
158
+ const context2 = createMockContext();
159
+ const response2 = await lambder.render(event2, context2);
160
+
161
+ expect(response2.statusCode).toBe(200);
162
+ const body2 = JSON.parse(response2.body || '{}');
163
+ expect(body2.payload).toEqual({ id: '1', name: 'Found User' });
164
+ });
165
+
166
+ it('should return boolean types correctly', async () => {
167
+ const lambder = new Lambder<TestContract>({
168
+ publicPath: './public',
169
+ apiPath: '/api',
170
+ });
171
+
172
+ lambder.addApi('deleteUser', async (ctx, resolver) => {
173
+ const userId = ctx.apiPayload.userId;
174
+ // Mock deletion
175
+ const success = userId !== '';
176
+ return resolver.api(success);
177
+ });
178
+
179
+ const event = createMockEvent('deleteUser', { userId: '123' });
180
+ const context = createMockContext();
181
+ const response = await lambder.render(event, context);
182
+
183
+ expect(response.statusCode).toBe(200);
184
+ const body = JSON.parse(response.body || '{}');
185
+ expect(body.payload).toBe(true);
186
+ });
187
+
188
+ it('should work with die.api()', async () => {
189
+ const lambder = new Lambder<TestContract>({
190
+ publicPath: './public',
191
+ apiPath: '/api',
192
+ });
193
+
194
+ lambder.addApi('echo', async (ctx, resolver) => {
195
+ return resolver.die.api({ echo: ctx.apiPayload.message });
196
+ });
197
+
198
+ const event = createMockEvent('echo', { message: 'Hello!' });
199
+ const context = createMockContext();
200
+ const response = await lambder.render(event, context);
201
+
202
+ expect(response.statusCode).toBe(200);
203
+ const body = JSON.parse(response.body || '{}');
204
+ expect(body.payload).toEqual({ echo: 'Hello!' });
205
+ });
206
+
207
+ it('should work with session APIs', async () => {
208
+ const lambder = new Lambder<TestContract>({
209
+ publicPath: './public',
210
+ apiPath: '/api',
211
+ });
212
+
213
+ // Note: Session will fail without DynamoDB setup, but we're testing type enforcement
214
+ lambder.addSessionApi('getUser', async (ctx, resolver) => {
215
+ return resolver.api({
216
+ id: ctx.apiPayload.userId,
217
+ name: 'Session User',
218
+ age: 25
219
+ });
220
+ });
221
+
222
+ // This will return 500 error because session is not configured
223
+ const event = createMockEvent('getUser', { userId: '123' });
224
+ const context = createMockContext();
225
+
226
+ const response = await lambder.render(event, context);
227
+
228
+ // Should return 500 error when session is not configured
229
+ expect(response.statusCode).toBe(500);
230
+ expect(response.body).toBe('Internal Server Error.');
231
+ });
232
+ });
233
+
234
+ // ============================================================================
235
+ // Input Type Tests
236
+ // ============================================================================
237
+
238
+ describe('Input Type Enforcement - Runtime', () => {
239
+ it('should receive correctly typed input', async () => {
240
+ const lambder = new Lambder<TestContract>({
241
+ publicPath: './public',
242
+ apiPath: '/api',
243
+ });
244
+
245
+ lambder.addApi('echo', async (ctx, resolver) => {
246
+ // Verify input is correctly typed at runtime
247
+ const message: string = ctx.apiPayload.message;
248
+ expect(typeof message).toBe('string');
249
+ return resolver.api({ echo: message });
250
+ });
251
+
252
+ const event = createMockEvent('echo', { message: 'Test Message' });
253
+ const context = createMockContext();
254
+ await lambder.render(event, context);
255
+ });
256
+
257
+ it('should handle void input correctly', async () => {
258
+ const lambder = new Lambder<TestContract>({
259
+ publicPath: './public',
260
+ apiPath: '/api',
261
+ });
262
+
263
+ lambder.addApi('listUsers', async (ctx, resolver) => {
264
+ // apiPayload should be undefined for void input
265
+ expect(ctx.apiPayload).toBeUndefined();
266
+ return resolver.api([]);
267
+ });
268
+
269
+ const event = createMockEvent('listUsers', undefined);
270
+ const context = createMockContext();
271
+ await lambder.render(event, context);
272
+ });
273
+
274
+ it('should handle complex input objects', async () => {
275
+ const lambder = new Lambder<TestContract>({
276
+ publicPath: './public',
277
+ apiPath: '/api',
278
+ });
279
+
280
+ lambder.addApi('add', async (ctx, resolver) => {
281
+ const { a, b } = ctx.apiPayload;
282
+ expect(typeof a).toBe('number');
283
+ expect(typeof b).toBe('number');
284
+ return resolver.api(a + b);
285
+ });
286
+
287
+ const event = createMockEvent('add', { a: 10, b: 20 });
288
+ const context = createMockContext();
289
+ const response = await lambder.render(event, context);
290
+
291
+ const body = JSON.parse(response.body || '{}');
292
+ expect(body.payload).toBe(30);
293
+ });
294
+ });
295
+
296
+ // ============================================================================
297
+ // Edge Cases
298
+ // ============================================================================
299
+
300
+ describe('Edge Cases', () => {
301
+ it('should handle empty arrays', async () => {
302
+ const lambder = new Lambder<TestContract>({
303
+ publicPath: './public',
304
+ apiPath: '/api',
305
+ });
306
+
307
+ lambder.addApi('listUsers', async (ctx, resolver) => {
308
+ return resolver.api([]);
309
+ });
310
+
311
+ const event = createMockEvent('listUsers', undefined);
312
+ const context = createMockContext();
313
+ const response = await lambder.render(event, context);
314
+
315
+ expect(response.statusCode).toBe(200);
316
+ const body = JSON.parse(response.body || '{}');
317
+ expect(body.payload).toEqual([]);
318
+ });
319
+
320
+ it('should handle zero as a valid number', async () => {
321
+ const lambder = new Lambder<TestContract>({
322
+ publicPath: './public',
323
+ apiPath: '/api',
324
+ });
325
+
326
+ lambder.addApi('add', async (ctx, resolver) => {
327
+ return resolver.api(0);
328
+ });
329
+
330
+ const event = createMockEvent('add', { a: 0, b: 0 });
331
+ const context = createMockContext();
332
+ const response = await lambder.render(event, context);
333
+
334
+ expect(response.statusCode).toBe(200);
335
+ const body = JSON.parse(response.body || '{}');
336
+ expect(body.payload).toBe(0);
337
+ });
338
+
339
+ it('should handle empty strings in objects', async () => {
340
+ const lambder = new Lambder<TestContract>({
341
+ publicPath: './public',
342
+ apiPath: '/api',
343
+ });
344
+
345
+ lambder.addApi('getUser', async (ctx, resolver) => {
346
+ return resolver.api({
347
+ id: '',
348
+ name: '',
349
+ age: 0
350
+ });
351
+ });
352
+
353
+ const event = createMockEvent('getUser', { userId: '' });
354
+ const context = createMockContext();
355
+ const response = await lambder.render(event, context);
356
+
357
+ expect(response.statusCode).toBe(200);
358
+ const body = JSON.parse(response.body || '{}');
359
+ expect(body.payload).toEqual({
360
+ id: '',
361
+ name: '',
362
+ age: 0
363
+ });
364
+ });
365
+ });