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.
@@ -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, 'raw'>;
7
- json: MethodType<LambderResponseBuilder, 'json'>;
8
- xml: MethodType<LambderResponseBuilder, 'xml'>;
9
- html: MethodType<LambderResponseBuilder, 'html'>;
10
- status301: MethodType<LambderResponseBuilder, 'status301'>;
11
- status404: MethodType<LambderResponseBuilder, 'status404'>;
12
- cors: MethodType<LambderResponseBuilder, 'cors'>;
13
- fileBase64: MethodType<LambderResponseBuilder, 'fileBase64'>;
14
- file: MethodType<LambderResponseBuilder, 'file'>;
15
- ejsFile: MethodType<LambderResponseBuilder, 'ejsFile'>;
16
- ejsTemplate: MethodType<LambderResponseBuilder, 'ejsTemplate'>;
17
- api: MethodType<LambderResponseBuilder, '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
  }
@@ -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
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, }: {
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
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, }) {
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(8).toString("hex");
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
- throw new Error("Session not found");
101
- if (!session.sessionToken || session.sessionToken !== sessionToken)
102
- throw new Error("Not found: session.sessionToken");
116
+ return null;
117
+ if (!session.sessionToken || !this.constantTimeCompare(session.sessionToken, sessionToken))
118
+ return null;
103
119
  if (!session.csrfToken)
104
- throw new Error("Not found: session.csrfToken");
120
+ return null;
105
121
  if (!session.sessionKey)
106
- throw new Error("Not found: session.sessionKey");
122
+ return null;
107
123
  if (!session.createdAt)
108
- throw new Error("Not found: session.createdAt");
124
+ return null;
109
125
  if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
110
- throw new Error("Not found: session.expiresAt");
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 !== 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 !== 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
- return resolver.api(user);
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 and outputs**
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