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.
- package/OUTPUT_TYPE_ENFORCEMENT_SUMMARY.md +90 -0
- package/dist/Lambder.d.ts +5 -4
- package/dist/Lambder.js +2 -2
- package/dist/LambderResolver.d.ts +17 -15
- package/dist/LambderResolver.js +4 -0
- package/dist/LambderResponseBuilder.d.ts +2 -1
- package/dist/LambderSessionController.d.ts +1 -0
- package/dist/LambderSessionController.js +10 -0
- package/dist/LambderSessionManager.d.ts +6 -1
- package/dist/LambderSessionManager.js +43 -12
- package/dist/index.d.ts +1 -1
- package/docs/DYNAMODB_SETUP.md +96 -0
- package/docs/TYPE_SAFE_QUICK_START.md +48 -4
- package/examples/output-type-enforcement-example.ts +218 -0
- package/examples/secure-session-example.ts +191 -0
- package/examples/test-output-type-enforcement.ts +101 -0
- package/package.json +1 -1
- package/src/Lambder.ts +4 -4
- package/src/LambderResolver.ts +32 -15
- package/src/LambderResponseBuilder.ts +2 -1
- package/src/LambderSessionController.ts +9 -0
- package/src/LambderSessionManager.ts +51 -10
- package/src/index.ts +1 -0
- package/tests/output-type-runtime.test.ts +365 -0
- package/tests/type-safety.test.ts +312 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Output Type Enforcement - Implementation Summary
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
The type system was only enforcing input types for `ctx.apiPayload`, but not enforcing that the output returned by `resolver.api()` matched the contract's output type.
|
|
5
|
+
|
|
6
|
+
## Solution
|
|
7
|
+
Made the following components generic to propagate type information:
|
|
8
|
+
|
|
9
|
+
### 1. LambderResponseBuilder
|
|
10
|
+
- Added generic parameter: `LambderResponseBuilder<TContract extends ApiContractShape = any>`
|
|
11
|
+
- The `api()` method now uses the generic type for type checking
|
|
12
|
+
|
|
13
|
+
### 2. LambderResolver
|
|
14
|
+
- Added generic parameters: `LambderResolver<TContract extends ApiContractShape = any, TApiName extends keyof TContract & string = any>`
|
|
15
|
+
- Overrides the `api()` method to enforce output type: `api(payload: ApiOutput<TContract, TApiName> | null, ...)`
|
|
16
|
+
- The `die.api()` method also enforces the output type through the interface
|
|
17
|
+
|
|
18
|
+
### 3. Lambder addApi/addSessionApi
|
|
19
|
+
- Updated type signatures to pass `LambderResolver<TContract, TApiName>` to the action function
|
|
20
|
+
- Now both methods enforce:
|
|
21
|
+
- ✅ Input type via `ctx.apiPayload: TContract[TApiName]['input']`
|
|
22
|
+
- ✅ Output type via `resolver.api(payload: TContract[TApiName]['output'] | null)`
|
|
23
|
+
|
|
24
|
+
## Type Flow
|
|
25
|
+
```
|
|
26
|
+
Contract Definition
|
|
27
|
+
↓
|
|
28
|
+
Lambder<TContract>
|
|
29
|
+
↓
|
|
30
|
+
addApi<TApiName>(apiName, actionFn)
|
|
31
|
+
↓
|
|
32
|
+
actionFn(ctx: LambderRenderContext<Input>, resolver: LambderResolver<TContract, TApiName>)
|
|
33
|
+
↓
|
|
34
|
+
resolver.api(payload: Output | null)
|
|
35
|
+
↓
|
|
36
|
+
Type validation at compile time!
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## What's Enforced Now
|
|
40
|
+
|
|
41
|
+
### Input Types (already working)
|
|
42
|
+
```typescript
|
|
43
|
+
lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
44
|
+
ctx.apiPayload.userId; // ✅ TypeScript knows this is string
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Output Types (NEW!)
|
|
49
|
+
```typescript
|
|
50
|
+
lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
51
|
+
const user = { id: "123", name: "John", age: 30 };
|
|
52
|
+
return resolver.api(user); // ✅ TypeScript validates user matches User type
|
|
53
|
+
|
|
54
|
+
// return resolver.api("wrong"); // ❌ TypeScript error!
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
- ✅ Works with `resolver.api()`
|
|
60
|
+
- ✅ Works with `resolver.die.api()`
|
|
61
|
+
- ✅ Works with `addApi()`
|
|
62
|
+
- ✅ Works with `addSessionApi()`
|
|
63
|
+
- ✅ Supports nullable outputs (`output: User | null`)
|
|
64
|
+
- ✅ Supports complex nested types
|
|
65
|
+
- ✅ Supports arrays
|
|
66
|
+
- ✅ Supports primitive types
|
|
67
|
+
- ✅ Zero runtime overhead - all validation is at compile time
|
|
68
|
+
- ✅ Backward compatible - untyped APIs still work
|
|
69
|
+
|
|
70
|
+
## Files Changed
|
|
71
|
+
1. `src/LambderResponseBuilder.ts` - Made generic, imported ApiContractShape
|
|
72
|
+
2. `src/LambderResolver.ts` - Made generic, added typed `api()` override
|
|
73
|
+
3. `src/Lambder.ts` - Updated addApi/addSessionApi type signatures
|
|
74
|
+
4. `docs/TYPE_SAFE_QUICK_START.md` - Updated documentation with output type examples
|
|
75
|
+
|
|
76
|
+
## Examples Created
|
|
77
|
+
1. `examples/test-output-type-enforcement.ts` - Unit test for type enforcement
|
|
78
|
+
2. `examples/output-type-enforcement-example.ts` - Comprehensive demonstration
|
|
79
|
+
|
|
80
|
+
## Testing
|
|
81
|
+
All existing examples and tests compile without errors:
|
|
82
|
+
- ✅ `examples/simplified-typed-api-example.ts`
|
|
83
|
+
- ✅ `tests/type-safety.test.ts`
|
|
84
|
+
- ✅ No TypeScript compilation errors in project
|
|
85
|
+
|
|
86
|
+
## Backward Compatibility
|
|
87
|
+
The changes are fully backward compatible:
|
|
88
|
+
- Untyped usage still works: `new Lambder({ ... })` without generic
|
|
89
|
+
- Typed usage is opt-in: `new Lambder<MyContract>({ ... })`
|
|
90
|
+
- Existing code continues to work unchanged
|
package/dist/Lambder.d.ts
CHANGED
|
@@ -66,10 +66,11 @@ export default class Lambder<TContract extends ApiContractShape = any> {
|
|
|
66
66
|
apiVersion?: string;
|
|
67
67
|
});
|
|
68
68
|
enableCors(isCorsEnabled: boolean): void;
|
|
69
|
-
enableDdbSession({ tableName, tableRegion, sessionSalt }: {
|
|
69
|
+
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration }: {
|
|
70
70
|
tableName: string;
|
|
71
71
|
tableRegion: string;
|
|
72
72
|
sessionSalt: string;
|
|
73
|
+
enableSlidingExpiration?: boolean;
|
|
73
74
|
}, { partitionKey, sortKey }?: {
|
|
74
75
|
partitionKey: string;
|
|
75
76
|
sortKey: string;
|
|
@@ -88,17 +89,17 @@ export default class Lambder<TContract extends ApiContractShape = any> {
|
|
|
88
89
|
addRoute(condition: Path | ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
89
90
|
addSessionRoute(condition: Path | ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
90
91
|
addApi(apiName: ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
91
|
-
addApi<TApiName extends keyof TContract & string>(apiName: TApiName, actionFn: (ctx: LambderRenderContext<TContract[TApiName]['input']>, resolver: LambderResolver) => LambderResolverResponse | Promise<LambderResolverResponse>): void;
|
|
92
|
+
addApi<TApiName extends keyof TContract & string>(apiName: TApiName, actionFn: (ctx: LambderRenderContext<TContract[TApiName]['input']>, resolver: LambderResolver<TContract, TApiName>) => LambderResolverResponse | Promise<LambderResolverResponse>): void;
|
|
92
93
|
addApi(apiName: string, actionFn: ActionFunction): void;
|
|
93
94
|
addSessionApi(apiName: ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
94
|
-
addSessionApi<TApiName extends keyof TContract & string>(apiName: TApiName, actionFn: (ctx: LambderRenderContext<TContract[TApiName]['input']>, resolver: LambderResolver) => LambderResolverResponse | Promise<LambderResolverResponse>): void;
|
|
95
|
+
addSessionApi<TApiName extends keyof TContract & string>(apiName: TApiName, actionFn: (ctx: LambderRenderContext<TContract[TApiName]['input']>, resolver: LambderResolver<TContract, TApiName>) => LambderResolverResponse | Promise<LambderResolverResponse>): void;
|
|
95
96
|
addSessionApi(apiName: string, actionFn: ActionFunction): void;
|
|
96
97
|
addHook(hookEvent: 'created', hookFn: HookCreatedFunction, priority?: number): Promise<void>;
|
|
97
98
|
addHook(hookEvent: 'beforeRender', hookFn: HookBeforeRenderFunction, priority?: number): Promise<void>;
|
|
98
99
|
addHook(hookEvent: 'afterRender', hookFn: HookAfterRenderFunction, priority?: number): Promise<void>;
|
|
99
100
|
addHook(hookEvent: 'fallback', hookFn: HookFallbackFunction, priority?: number): Promise<void>;
|
|
100
101
|
getSessionController(ctx: LambderRenderContext<any>): LambderSessionController;
|
|
101
|
-
getResponseBuilder(): LambderResponseBuilder
|
|
102
|
+
getResponseBuilder(): LambderResponseBuilder<any>;
|
|
102
103
|
private getResolver;
|
|
103
104
|
render(event: APIGatewayProxyEvent, lambdaContext: Context): Promise<LambderResolverResponse>;
|
|
104
105
|
}
|
package/dist/Lambder.js
CHANGED
|
@@ -75,9 +75,9 @@ export default class Lambder {
|
|
|
75
75
|
enableCors(isCorsEnabled) {
|
|
76
76
|
this.isCorsEnabled = isCorsEnabled;
|
|
77
77
|
}
|
|
78
|
-
enableDdbSession({ tableName, tableRegion, sessionSalt }, { partitionKey, sortKey } = { partitionKey: "pk", sortKey: "sk" }) {
|
|
78
|
+
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration }, { partitionKey, sortKey } = { partitionKey: "pk", sortKey: "sk" }) {
|
|
79
79
|
this.lambderSessionManager = new LambderSessionManager({
|
|
80
|
-
tableName, tableRegion, partitionKey, sortKey, sessionSalt
|
|
80
|
+
tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
83
|
setSessionCookieKey(sessionTokenCookieKey, sessionCsrfCookieKey) {
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import type { LambderRenderContext } from "./Lambder.js";
|
|
2
2
|
import LambderResponseBuilder, { LambderResolverResponse } from "./LambderResponseBuilder.js";
|
|
3
3
|
import LambderUtils from "./LambderUtils.js";
|
|
4
|
+
import type { ApiContractShape, ApiOutput } from "./LambderApiContract.js";
|
|
4
5
|
type MethodType<T, M extends keyof T> = T[M] extends (...args: any[]) => any ? T[M] : never;
|
|
5
|
-
interface DieResolverMethods {
|
|
6
|
-
raw: MethodType<LambderResponseBuilder
|
|
7
|
-
json: MethodType<LambderResponseBuilder
|
|
8
|
-
xml: MethodType<LambderResponseBuilder
|
|
9
|
-
html: MethodType<LambderResponseBuilder
|
|
10
|
-
status301: MethodType<LambderResponseBuilder
|
|
11
|
-
status404: MethodType<LambderResponseBuilder
|
|
12
|
-
cors: MethodType<LambderResponseBuilder
|
|
13
|
-
fileBase64: MethodType<LambderResponseBuilder
|
|
14
|
-
file: MethodType<LambderResponseBuilder
|
|
15
|
-
ejsFile: MethodType<LambderResponseBuilder
|
|
16
|
-
ejsTemplate: MethodType<LambderResponseBuilder
|
|
17
|
-
api:
|
|
6
|
+
interface DieResolverMethods<TContract extends ApiContractShape, TApiName extends keyof TContract & string> {
|
|
7
|
+
raw: MethodType<LambderResponseBuilder<TContract>, 'raw'>;
|
|
8
|
+
json: MethodType<LambderResponseBuilder<TContract>, 'json'>;
|
|
9
|
+
xml: MethodType<LambderResponseBuilder<TContract>, 'xml'>;
|
|
10
|
+
html: MethodType<LambderResponseBuilder<TContract>, 'html'>;
|
|
11
|
+
status301: MethodType<LambderResponseBuilder<TContract>, 'status301'>;
|
|
12
|
+
status404: MethodType<LambderResponseBuilder<TContract>, 'status404'>;
|
|
13
|
+
cors: MethodType<LambderResponseBuilder<TContract>, 'cors'>;
|
|
14
|
+
fileBase64: MethodType<LambderResponseBuilder<TContract>, 'fileBase64'>;
|
|
15
|
+
file: MethodType<LambderResponseBuilder<TContract>, 'file'>;
|
|
16
|
+
ejsFile: MethodType<LambderResponseBuilder<TContract>, 'ejsFile'>;
|
|
17
|
+
ejsTemplate: MethodType<LambderResponseBuilder<TContract>, 'ejsTemplate'>;
|
|
18
|
+
api: (payload: ApiOutput<TContract, TApiName> | null, config?: Parameters<LambderResponseBuilder<TContract>['api']>[1], headers?: Parameters<LambderResponseBuilder<TContract>['api']>[2]) => LambderResolverResponse;
|
|
18
19
|
}
|
|
19
|
-
export default class LambderResolver extends LambderResponseBuilder {
|
|
20
|
+
export default class LambderResolver<TContract extends ApiContractShape = any, TApiName extends keyof TContract & string = any> extends LambderResponseBuilder<TContract> {
|
|
20
21
|
resolve: (response: LambderResolverResponse) => void;
|
|
21
22
|
reject: (err: Error) => void;
|
|
22
|
-
die: DieResolverMethods
|
|
23
|
+
die: DieResolverMethods<TContract, TApiName>;
|
|
23
24
|
constructor({ isCorsEnabled, publicPath, apiVersion, lambderUtils, ctx, resolve, reject }: {
|
|
24
25
|
isCorsEnabled: boolean;
|
|
25
26
|
publicPath: string;
|
|
@@ -29,6 +30,7 @@ export default class LambderResolver extends LambderResponseBuilder {
|
|
|
29
30
|
resolve: (response: LambderResolverResponse) => void;
|
|
30
31
|
reject: (err: Error) => void;
|
|
31
32
|
});
|
|
33
|
+
api(payload: ApiOutput<TContract, TApiName> | null, config?: Parameters<LambderResponseBuilder<TContract>['api']>[1], headers?: Parameters<LambderResponseBuilder<TContract>['api']>[2]): LambderResolverResponse;
|
|
32
34
|
private autoResolve;
|
|
33
35
|
private autoResolvePromise;
|
|
34
36
|
}
|
package/dist/LambderResolver.js
CHANGED
|
@@ -22,6 +22,10 @@ export default class LambderResolver extends LambderResponseBuilder {
|
|
|
22
22
|
api: this.autoResolve(this.api),
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
// Override api method with proper typing
|
|
26
|
+
api(payload, config, headers) {
|
|
27
|
+
return super.api(payload, config, headers);
|
|
28
|
+
}
|
|
25
29
|
autoResolve(method) {
|
|
26
30
|
return (...args) => {
|
|
27
31
|
const result = method.apply(this, args);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import LambderUtils from "./LambderUtils.js";
|
|
2
2
|
import { LambderRenderContext } from "./Lambder.js";
|
|
3
|
+
import type { ApiContractShape } from "./LambderApiContract.js";
|
|
3
4
|
export type LambderResolverResponse = {
|
|
4
5
|
statusCode: number;
|
|
5
6
|
multiValueHeaders?: Record<string, string[]>;
|
|
@@ -17,7 +18,7 @@ export type LambderApiResponseConfig = {
|
|
|
17
18
|
export type LambderApiResponse<T> = LambderApiResponseConfig & {
|
|
18
19
|
payload?: T | null;
|
|
19
20
|
};
|
|
20
|
-
export default class LambderResponseBuilder {
|
|
21
|
+
export default class LambderResponseBuilder<TContract extends ApiContractShape = any> {
|
|
21
22
|
private isCorsEnabled;
|
|
22
23
|
private publicPath;
|
|
23
24
|
private apiVersion;
|
|
@@ -14,6 +14,7 @@ export default class LambderSessionController {
|
|
|
14
14
|
});
|
|
15
15
|
private areRequestSessionTokensValid;
|
|
16
16
|
createSession(sessionKey: string, data?: any, ttlInSeconds?: number): Promise<LambderSessionContext>;
|
|
17
|
+
regenerateSession(): Promise<LambderSessionContext>;
|
|
17
18
|
fetchSession(): Promise<LambderSessionContext>;
|
|
18
19
|
fetchSessionIfExists(): Promise<LambderSessionContext | null>;
|
|
19
20
|
isSessionValid(session: any): boolean;
|
|
@@ -31,6 +31,16 @@ export default class LambderSessionController {
|
|
|
31
31
|
return this.ctx.session;
|
|
32
32
|
}
|
|
33
33
|
;
|
|
34
|
+
async regenerateSession() {
|
|
35
|
+
if (!this.ctx.session)
|
|
36
|
+
throw new Error("Session not found.");
|
|
37
|
+
const newSession = await this.lambderSessionManager.regenerateSession(this.ctx.session);
|
|
38
|
+
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionTokenCookieKey}=${newSession.sessionToken}; Expires=${new Date(newSession.expiresAt * 1000).toUTCString()}; Path=/; HttpOnly; SameSite=Lax; Secure` });
|
|
39
|
+
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionCsrfCookieKey}=${newSession.csrfToken}; Expires=${new Date(newSession.expiresAt * 1000).toUTCString()}; Path=/; SameSite=Lax; Secure` });
|
|
40
|
+
this.ctx.session = newSession;
|
|
41
|
+
return this.ctx.session;
|
|
42
|
+
}
|
|
43
|
+
;
|
|
34
44
|
async fetchSession() {
|
|
35
45
|
if (!this.areRequestSessionTokensValid()) {
|
|
36
46
|
throw new Error("Session tokens are invalid");
|
|
@@ -6,6 +6,7 @@ export type LambderSessionContext = {
|
|
|
6
6
|
data: any;
|
|
7
7
|
createdAt: number;
|
|
8
8
|
expiresAt: number;
|
|
9
|
+
lastAccessedAt: number;
|
|
9
10
|
ttlInSeconds: number;
|
|
10
11
|
};
|
|
11
12
|
export default class LambderSessionManager {
|
|
@@ -14,14 +15,17 @@ export default class LambderSessionManager {
|
|
|
14
15
|
private partitionKey;
|
|
15
16
|
private sortKey;
|
|
16
17
|
private ddbDocumentClient;
|
|
17
|
-
|
|
18
|
+
private enableSlidingExpiration;
|
|
19
|
+
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, }: {
|
|
18
20
|
tableName: string;
|
|
19
21
|
tableRegion: string;
|
|
20
22
|
partitionKey: string;
|
|
21
23
|
sortKey: string;
|
|
22
24
|
sessionSalt: string;
|
|
25
|
+
enableSlidingExpiration?: boolean;
|
|
23
26
|
});
|
|
24
27
|
private sessionUserKeyHasher;
|
|
28
|
+
private constantTimeCompare;
|
|
25
29
|
private ddbGetItem;
|
|
26
30
|
private ddbPutItem;
|
|
27
31
|
private ddbDeleteItem;
|
|
@@ -33,4 +37,5 @@ export default class LambderSessionManager {
|
|
|
33
37
|
isSessionValid(session: any, sessionToken: any, csrfToken: any, skipCsrfTokenCheck?: boolean): boolean;
|
|
34
38
|
deleteSession(session: Record<string, any>): Promise<boolean>;
|
|
35
39
|
deleteSessionAll(session: Record<string, any>): Promise<boolean>;
|
|
40
|
+
regenerateSession(session: LambderSessionContext): Promise<LambderSessionContext>;
|
|
36
41
|
}
|
|
@@ -7,11 +7,13 @@ export default class LambderSessionManager {
|
|
|
7
7
|
partitionKey;
|
|
8
8
|
sortKey;
|
|
9
9
|
ddbDocumentClient;
|
|
10
|
-
|
|
10
|
+
enableSlidingExpiration;
|
|
11
|
+
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, }) {
|
|
11
12
|
this.tableName = tableName;
|
|
12
13
|
this.sessionSalt = sessionSalt;
|
|
13
14
|
this.partitionKey = partitionKey;
|
|
14
15
|
this.sortKey = sortKey;
|
|
16
|
+
this.enableSlidingExpiration = enableSlidingExpiration;
|
|
15
17
|
const ddbClient = new DynamoDBClient({ region: tableRegion });
|
|
16
18
|
this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
|
|
17
19
|
}
|
|
@@ -20,6 +22,13 @@ export default class LambderSessionManager {
|
|
|
20
22
|
.update(`${password}${this.sessionSalt}`)
|
|
21
23
|
.digest("hex");
|
|
22
24
|
}
|
|
25
|
+
constantTimeCompare(a, b) {
|
|
26
|
+
if (a.length !== b.length)
|
|
27
|
+
return false;
|
|
28
|
+
const bufferA = Buffer.from(a, 'utf8');
|
|
29
|
+
const bufferB = Buffer.from(b, 'utf8');
|
|
30
|
+
return crypto.timingSafeEqual(new Uint8Array(bufferA), new Uint8Array(bufferB));
|
|
31
|
+
}
|
|
23
32
|
async ddbGetItem(key) {
|
|
24
33
|
const response = await this.ddbDocumentClient.send(new GetCommand({ TableName: this.tableName, Key: key, ConsistentRead: true }));
|
|
25
34
|
if (response.Item)
|
|
@@ -67,15 +76,16 @@ export default class LambderSessionManager {
|
|
|
67
76
|
const sessionKeyHash = this.sessionUserKeyHasher(sessionKey);
|
|
68
77
|
const sessionSortKey = crypto.randomBytes(32).toString("hex");
|
|
69
78
|
const sessionToken = `${sessionKeyHash}:${sessionSortKey}`;
|
|
70
|
-
const csrfToken = crypto.randomBytes(
|
|
79
|
+
const csrfToken = crypto.randomBytes(32).toString("hex");
|
|
71
80
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
81
|
+
const lastAccessedAt = createdAt;
|
|
72
82
|
const expiresAt = Number(createdAt) + Number(ttlInSeconds);
|
|
73
83
|
const session = {
|
|
74
84
|
[this.partitionKey]: sessionKeyHash,
|
|
75
85
|
[this.sortKey]: sessionSortKey,
|
|
76
86
|
sessionToken, csrfToken,
|
|
77
87
|
sessionKey, data,
|
|
78
|
-
createdAt, expiresAt, ttlInSeconds
|
|
88
|
+
createdAt, lastAccessedAt, expiresAt, ttlInSeconds
|
|
79
89
|
};
|
|
80
90
|
await this.ddbPutItem(session);
|
|
81
91
|
return session;
|
|
@@ -84,6 +94,11 @@ export default class LambderSessionManager {
|
|
|
84
94
|
if (!session)
|
|
85
95
|
throw new Error("Invalid session");
|
|
86
96
|
session.data = newData;
|
|
97
|
+
session.lastAccessedAt = Math.floor(Date.now() / 1000);
|
|
98
|
+
// Update expiration if sliding expiration is enabled
|
|
99
|
+
if (this.enableSlidingExpiration) {
|
|
100
|
+
session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
|
|
101
|
+
}
|
|
87
102
|
await this.ddbPutItem(session);
|
|
88
103
|
return session;
|
|
89
104
|
}
|
|
@@ -96,18 +111,26 @@ export default class LambderSessionManager {
|
|
|
96
111
|
[this.partitionKey]: sessionKeyHash,
|
|
97
112
|
[this.sortKey]: sessionSortKey
|
|
98
113
|
});
|
|
114
|
+
// Use constant error response to prevent timing attacks
|
|
99
115
|
if (!session)
|
|
100
|
-
|
|
101
|
-
if (!session.sessionToken || session.sessionToken
|
|
102
|
-
|
|
116
|
+
return null;
|
|
117
|
+
if (!session.sessionToken || !this.constantTimeCompare(session.sessionToken, sessionToken))
|
|
118
|
+
return null;
|
|
103
119
|
if (!session.csrfToken)
|
|
104
|
-
|
|
120
|
+
return null;
|
|
105
121
|
if (!session.sessionKey)
|
|
106
|
-
|
|
122
|
+
return null;
|
|
107
123
|
if (!session.createdAt)
|
|
108
|
-
|
|
124
|
+
return null;
|
|
109
125
|
if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
|
|
110
|
-
|
|
126
|
+
return null;
|
|
127
|
+
// Update last accessed time if sliding expiration is enabled
|
|
128
|
+
if (this.enableSlidingExpiration) {
|
|
129
|
+
session.lastAccessedAt = Math.floor(Date.now() / 1000);
|
|
130
|
+
session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
|
|
131
|
+
// Fire and forget - don't wait for the update
|
|
132
|
+
this.ddbPutItem(session).catch(() => { });
|
|
133
|
+
}
|
|
111
134
|
return session;
|
|
112
135
|
}
|
|
113
136
|
catch (err) {
|
|
@@ -120,7 +143,7 @@ export default class LambderSessionManager {
|
|
|
120
143
|
return false;
|
|
121
144
|
if (!sessionToken || typeof sessionToken !== "string")
|
|
122
145
|
return false;
|
|
123
|
-
if (session.sessionToken
|
|
146
|
+
if (!this.constantTimeCompare(session.sessionToken, sessionToken))
|
|
124
147
|
return false;
|
|
125
148
|
if (!session.csrfToken)
|
|
126
149
|
return false;
|
|
@@ -133,7 +156,7 @@ export default class LambderSessionManager {
|
|
|
133
156
|
if (!skipCsrfTokenCheck) {
|
|
134
157
|
if (!csrfToken || typeof csrfToken !== "string")
|
|
135
158
|
return false;
|
|
136
|
-
if (session.csrfToken
|
|
159
|
+
if (!this.constantTimeCompare(session.csrfToken, csrfToken))
|
|
137
160
|
return false;
|
|
138
161
|
}
|
|
139
162
|
return true;
|
|
@@ -151,5 +174,13 @@ export default class LambderSessionManager {
|
|
|
151
174
|
return true;
|
|
152
175
|
}
|
|
153
176
|
;
|
|
177
|
+
async regenerateSession(session) {
|
|
178
|
+
if (!session)
|
|
179
|
+
throw new Error("Invalid session");
|
|
180
|
+
// Delete old session
|
|
181
|
+
await this.deleteSession(session);
|
|
182
|
+
// Create new session with same sessionKey and data but new tokens
|
|
183
|
+
return await this.createSession(session.sessionKey, session.data, session.ttlInSeconds);
|
|
184
|
+
}
|
|
154
185
|
}
|
|
155
186
|
;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,4 +4,4 @@ export { default as LambderCaller } from "./LambderCaller.js";
|
|
|
4
4
|
export { default as LambderResponseBuilder } from "./LambderResponseBuilder.js";
|
|
5
5
|
export { default as LambderResolver } from "./LambderResolver.js";
|
|
6
6
|
export { default as LambderSessionManager } from "./LambderSessionManager.js";
|
|
7
|
-
export { type ApiContract, type ApiInput, type ApiOutput, } from "./LambderApiContract.js";
|
|
7
|
+
export { type ApiContractShape, type ApiContract, type ApiInput, type ApiOutput, } from "./LambderApiContract.js";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# DynamoDB Session Table Setup Guide
|
|
2
|
+
|
|
3
|
+
This guide helps you set up a DynamoDB table for Lambder session management with all security features enabled.
|
|
4
|
+
|
|
5
|
+
## Table Creation
|
|
6
|
+
|
|
7
|
+
### Using Terraform
|
|
8
|
+
|
|
9
|
+
```hcl
|
|
10
|
+
resource "aws_dynamodb_table" "lambder_sessions" {
|
|
11
|
+
name = "lambder-sessions"
|
|
12
|
+
billing_mode = "PAY_PER_REQUEST"
|
|
13
|
+
hash_key = "pk"
|
|
14
|
+
range_key = "sk"
|
|
15
|
+
|
|
16
|
+
attribute {
|
|
17
|
+
name = "pk"
|
|
18
|
+
type = "S"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
attribute {
|
|
22
|
+
name = "sk"
|
|
23
|
+
type = "S"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ttl {
|
|
27
|
+
enabled = true
|
|
28
|
+
attribute_name = "expiresAt"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
tags = {
|
|
32
|
+
Purpose = "Session Management"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Enable Time to Live (TTL)
|
|
38
|
+
|
|
39
|
+
TTL automatically removes expired sessions from DynamoDB, saving storage costs.
|
|
40
|
+
|
|
41
|
+
### Using AWS Console
|
|
42
|
+
|
|
43
|
+
1. Go to DynamoDB Console
|
|
44
|
+
2. Select your table (`lambder-sessions`)
|
|
45
|
+
3. Navigate to **Additional settings** tab
|
|
46
|
+
4. Click **Edit** under **Time to Live (TTL)**
|
|
47
|
+
5. Enable TTL
|
|
48
|
+
6. Set **TTL attribute** to: `expiresAt`
|
|
49
|
+
7. Save changes
|
|
50
|
+
|
|
51
|
+
## IAM Permissions
|
|
52
|
+
|
|
53
|
+
Your Lambda function needs these permissions:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"Version": "2012-10-17",
|
|
58
|
+
"Statement": [
|
|
59
|
+
{
|
|
60
|
+
"Effect": "Allow",
|
|
61
|
+
"Action": [
|
|
62
|
+
"dynamodb:GetItem",
|
|
63
|
+
"dynamodb:PutItem",
|
|
64
|
+
"dynamodb:DeleteItem",
|
|
65
|
+
"dynamodb:Query"
|
|
66
|
+
],
|
|
67
|
+
"Resource": [
|
|
68
|
+
"arn:aws:dynamodb:us-east-1:123456789012:table/lambder-sessions"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Session Data Structure
|
|
76
|
+
|
|
77
|
+
Each session is stored as:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"pk": "hash_of_user_id",
|
|
82
|
+
"sk": "random_64_char_hex",
|
|
83
|
+
"sessionToken": "hash_of_user_id:random_64_char_hex",
|
|
84
|
+
"csrfToken": "random_64_char_hex",
|
|
85
|
+
"sessionKey": "user_123",
|
|
86
|
+
"data": {
|
|
87
|
+
"userId": "user_123",
|
|
88
|
+
"username": "john_doe",
|
|
89
|
+
"role": "admin"
|
|
90
|
+
},
|
|
91
|
+
"createdAt": 1697712000,
|
|
92
|
+
"lastAccessedAt": 1697712300,
|
|
93
|
+
"expiresAt": 1700304000,
|
|
94
|
+
"ttlInSeconds": 2592000
|
|
95
|
+
}
|
|
96
|
+
```
|
|
@@ -26,11 +26,13 @@ const lambder = new Lambder<MyApiContract>({
|
|
|
26
26
|
apiPath: '/api'
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
// Now addApi is type-safe!
|
|
29
|
+
// Now addApi is type-safe for both inputs AND outputs!
|
|
30
30
|
lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
31
|
-
// ctx.apiPayload is automatically typed as { userId: string }
|
|
31
|
+
// ✅ ctx.apiPayload is automatically typed as { userId: string }
|
|
32
32
|
const user = await db.getUser(ctx.apiPayload.userId);
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
// ✅ resolver.api() enforces the output type (User)
|
|
35
|
+
return resolver.api(user); // TypeScript checks that user matches User type!
|
|
34
36
|
});
|
|
35
37
|
```
|
|
36
38
|
|
|
@@ -55,7 +57,8 @@ const user = await caller.api('getUserById', { userId: '123' });
|
|
|
55
57
|
- ✅ **No wrapper functions needed**
|
|
56
58
|
- ✅ **Use existing `api()` and `addApi()` methods**
|
|
57
59
|
- ✅ **Full autocomplete in IDE**
|
|
58
|
-
- ✅ **Type-safe inputs
|
|
60
|
+
- ✅ **Type-safe inputs AND outputs**
|
|
61
|
+
- ✅ **Compile-time validation**
|
|
59
62
|
- ✅ **Backward compatible**
|
|
60
63
|
|
|
61
64
|
## Contract Type Format
|
|
@@ -94,6 +97,45 @@ login: {
|
|
|
94
97
|
}
|
|
95
98
|
```
|
|
96
99
|
|
|
100
|
+
## Output Type Enforcement
|
|
101
|
+
|
|
102
|
+
The type system now enforces that `resolver.api()` returns data matching your contract's output type:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
type MyContract = ApiContract<{
|
|
106
|
+
getNumber: { input: void, output: number },
|
|
107
|
+
getUser: { input: { id: string }, output: User }
|
|
108
|
+
}>;
|
|
109
|
+
|
|
110
|
+
const lambder = new Lambder<MyContract>({ ... });
|
|
111
|
+
|
|
112
|
+
// ✅ CORRECT
|
|
113
|
+
lambder.addApi('getNumber', async (ctx, resolver) => {
|
|
114
|
+
return resolver.api(42); // number - matches output type
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ❌ ERROR: Type 'string' is not assignable to type 'number'
|
|
118
|
+
lambder.addApi('getNumber', async (ctx, resolver) => {
|
|
119
|
+
return resolver.api("wrong"); // TypeScript error!
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ✅ CORRECT
|
|
123
|
+
lambder.addApi('getUser', async (ctx, resolver) => {
|
|
124
|
+
return resolver.api({ id: "123", name: "John", ... }); // User object
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ❌ ERROR: Missing required properties
|
|
128
|
+
lambder.addApi('getUser', async (ctx, resolver) => {
|
|
129
|
+
return resolver.api({ id: "123" }); // TypeScript error - incomplete User!
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This works with:
|
|
134
|
+
- `resolver.api()` - typed return value
|
|
135
|
+
- `resolver.die.api()` - typed return value
|
|
136
|
+
- `addApi()` - typed for regular APIs
|
|
137
|
+
- `addSessionApi()` - typed for session APIs
|
|
138
|
+
|
|
97
139
|
## Without Type Safety (Still Works!)
|
|
98
140
|
|
|
99
141
|
Don't want type safety? Just don't pass the generic type:
|
|
@@ -118,6 +160,8 @@ See [simplified-typed-api-example.ts](../examples/simplified-typed-api-example.t
|
|
|
118
160
|
|
|
119
161
|
- **Contract is just a TypeScript type** - No runtime code!
|
|
120
162
|
- **Zero overhead** - All type checking happens at compile time
|
|
163
|
+
- **Input AND output validation** - Both sides of your API are type-safe
|
|
164
|
+
- **Compile-time safety** - Catch type mismatches before deployment
|
|
121
165
|
- **Opt-in** - Use types when you want them
|
|
122
166
|
- **Simple** - Just pass type to constructor
|
|
123
167
|
- **Autocomplete** - IDE shows available APIs as you type
|