lambder 4.5.1 → 4.6.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
@@ -2,6 +2,11 @@
2
2
 
3
3
  Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
4
4
 
5
+ **New in 4.6:**
6
+
7
+ - **Cookies as a first-class concern**: `res.setCookie(name, value, options)` and `res.clearCookie(name, options)` serialize Set-Cookie headers through the `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form `domain` resolves against the request hostname, the same option the session takes), replacing hand-built header strings; `serializeCookie`/`serializeClearCookie` are exported for code holding a response. `ctx.cookieList` keeps every value a cookie name arrived with beside the first-wins `ctx.cookie`.
8
+ - **Session cookie scope changes heal**: a cookie's identity is (name, domain, path), so changing the session's `cookie.domain` or `path` on a live deployment leaves the old copy in every browser beside the new one, and a whole-header parse silently picks whichever the browser lists first. The controller now tries every copy of the session cookie (record and CSRF pairing checked per copy), logs the ambiguity, and evicts the stale host-only twin from the response, so a migrated browser recovers on its first request instead of answering `sessionExpired` until the old cookie expires.
9
+
5
10
  **New in 4.5:**
6
11
 
7
12
  - **`files` at creation replaces `publicPath`** (and `servePublicFiles({ source })`): one `LambderFileSource` configured once, `files: new LambderLocalFileSource({ root: path.resolve("./public") })` for the folder bundled with the deployment, `new LambderS3FileSource({...})` for S3 or R2, or your own `{ read(relativePath) }`. The instance owns one reader over it (`lambder.files`): path rule, in-memory file cache and compiled-template cache in one place, shared by `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile`, so a build hosted from a bucket serves its index.html and templates from the bucket too, cached the same way as its assets. The cache is tuned or disabled beside the source, `files: { source, memoryCache }`, and `memoryCache` leaves `servePublicFiles`; `res.file` loses its SPA-era `fallback` option (the fallback chain replaced it).
@@ -69,6 +74,24 @@ npm install lambder zod
69
74
  yarn add lambder zod
70
75
  ```
71
76
 
77
+ `zod` and the AWS SDK clients are optional peer dependencies, so installing
78
+ lambder never drags them into your tree. Add whatever the code you actually
79
+ import needs:
80
+
81
+ | What you import | What to install alongside |
82
+ |---|---|
83
+ | `lambder/client` (browser, shared isomorphic code) | `zod` |
84
+ | `lambder` on AWS Lambda (`nodejs18.x` and later) | `zod`. The runtime already provides the AWS SDK v3, so mark the SDK packages as dev dependencies and keep them out of the deployment package |
85
+ | `lambder` anywhere else (a long-running server, a container, local tests) | `zod`, `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
86
+ | `LambderS3FileSource` | `@aws-sdk/client-s3`, loaded on first read |
87
+ | `lambder/testing` | `msw` |
88
+
89
+ The SDK and its `@smithy` tree are roughly 21MB installed, which is why they are
90
+ peers rather than dependencies: a frontend importing only `lambder/client` has
91
+ no use for any of it, and a Lambda deployment package should not ship a second
92
+ copy of what the runtime already loads. The runtime pins its own SDK version,
93
+ so if you need a specific one, install it and bundle it yourself.
94
+
72
95
  ## Package Entry Points
73
96
 
74
97
  The package ships three entry points; pick by where the code runs:
@@ -335,6 +358,12 @@ const lambder = initLambder<SessionData>().create({
335
358
 
336
359
  See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
337
360
 
361
+ #### Cookie scope
362
+
363
+ `session.cookie` sets the scope of the two session cookies: `{ domain: ".example.com" }` shares a login across subdomains, and `domain` may be a `(hostname) => string | undefined` function when one deployment serves several apex domains (return undefined for a host-only cookie); `path`, `sameSite` (default `Lax`) and `secure` (default true) complete it. `LambderCaller` takes the same `sessionCookieDomain` so it can clear the CSRF cookie where the server set it.
364
+
365
+ Changing `domain` or `path` on a live deployment is a migration, because a browser identifies a cookie by (name, domain, path): the old copy stays beside the new one, both arrive on every request, and the browser's order says nothing about which is current. The controller handles the overlap: when the session cookie name arrives more than once it tries every copy (record lookup and CSRF pairing per copy), takes the live one, logs the ambiguity, and evicts the stale host-only twin from the response when a domain is configured. The reverse move, from a domain cookie back to host-only, cannot be evicted (this host cannot name the parent domain), so that copy is tolerated on every request until its own expiry. Renaming the cookies (`tokenCookieKey`, `csrfCookieKey`) alongside the scope change avoids the overlap entirely.
366
+
338
367
  #### How the secrets are stored
339
368
 
340
369
  The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
@@ -504,7 +533,8 @@ The `ctx` object provides access to request data:
504
533
  | `rawBody` | Decoded request body as received (webhook signatures) | `'{"a":1}'` |
505
534
  | `ip` | Client IP (CF-Connecting-IP / X-Forwarded-For / source IP) | `"1.2.3.4"` |
506
535
  | `header(name)` | Case-insensitive request header lookup | `ctx.header("accept-language")` |
507
- | `cookie` | Cookies | `{ rememberMe: "true" }` |
536
+ | `cookie` | Cookies (the first value when a name arrived more than once) | `{ rememberMe: "true" }` |
537
+ | `cookieList` | Every value per cookie name, in header order (a name held at several scopes arrives several times) | `{ rememberMe: ["true"] }` |
508
538
  | `headers` | Request headers | `{ "Content-Type": "..." }` |
509
539
  | `event` | Raw Lambda event (APIGatewayProxyEvent or APIGatewayProxyEventV2) | - |
510
540
  | `lambdaContext` | AWS Lambda Context | - |
@@ -517,6 +547,8 @@ The `ctx` object provides access to request data:
517
547
  **Header Manipulation** (call before returning response):
518
548
  - `res.addHeader(key, value)` - Adds a header value (can be called multiple times for same key)
519
549
  - `res.setHeader(key, value)` - Sets a header (replaces existing values)
550
+ - `res.setCookie(name, value, options?)` - Adds a Set-Cookie header. Options: `domain` (a string, or a `(hostname) => string | undefined` function resolved against the request host), `path` (default `/`), `sameSite` (default `Lax`), `secure` (default true), `httpOnly`, `maxAge` (seconds), `expires` (Date), `encode` (default encodeURIComponent, which `ctx.cookie` reverses)
551
+ - `res.clearCookie(name, options?)` - Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and `path` it was set with: a cookie's identity is (name, domain, path), so a deletion under another scope deletes nothing
520
552
  - `res.logToApiResponse(data)` - Adds data to logList in API responses (debugging)
521
553
 
522
554
  **Response Methods** (all accept an options object: `{ statusCode?, headers?, cacheControl?, compress?, etag? }`):
@@ -101,6 +101,9 @@ export default class LambderCaller {
101
101
  const resolvedDomain = typeof domainOption === "function" ? domainOption(hostname) : domainOption;
102
102
  for (const key of [this.sessionTokenCookieKey, this.sessionCsrfCookieKey]) {
103
103
  // Host-only and domain-scoped cookies are distinct entries; clear both.
104
+ // Only the CSRF cookie is reachable from here: the token cookie is
105
+ // HttpOnly, so its removal is the server's (a Set-Cookie on the
106
+ // session-expired or logout response).
104
107
  Cookies.remove(key);
105
108
  if (resolvedDomain)
106
109
  Cookies.remove(key, { domain: resolvedDomain, path: "/" });
@@ -11,7 +11,16 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
11
11
  method: string;
12
12
  get: Record<string, string | undefined>;
13
13
  post: Record<string, any>;
14
+ /** Cookies by name (the first value when a name arrived more than once; see cookieList). */
14
15
  cookie: Record<string, string>;
16
+ /**
17
+ * Every value the request carried per cookie name, in header order. A
18
+ * name normally maps to one value; several arrive when the browser holds
19
+ * that name at more than one scope (host-only beside Domain=, or two
20
+ * paths), typically after a cookie's Domain or Path was changed. The
21
+ * browser's order says nothing about which copy is current.
22
+ */
23
+ cookieList: Record<string, string[]>;
15
24
  session: null;
16
25
  apiName: string | null;
17
26
  apiPayload: TApiPayload;
@@ -10,7 +10,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
10
10
  let path;
11
11
  let method;
12
12
  let get;
13
- let cookieHeader;
13
+ let cookiePairs;
14
14
  let sourceIp;
15
15
  const headers = event.headers ?? {};
16
16
  if (isV2HttpEvent(event)) {
@@ -26,7 +26,8 @@ export const createContext = (event, lambdaContext, apiPath) => {
26
26
  for (const [key, value] of new URLSearchParams(event.rawQueryString ?? "").entries()) {
27
27
  get[key] = value;
28
28
  }
29
- cookieHeader = (event.cookies ?? []).join("; ");
29
+ // v2 delivers the Cookie header pre-split into name=value pairs.
30
+ cookiePairs = event.cookies ?? [];
30
31
  sourceIp = event.requestContext.http.sourceIp || "";
31
32
  }
32
33
  else {
@@ -34,10 +35,19 @@ export const createContext = (event, lambdaContext, apiPath) => {
34
35
  path = event.path;
35
36
  method = event.httpMethod;
36
37
  get = event.queryStringParameters || {};
37
- cookieHeader = headers.Cookie || headers.cookie || "";
38
+ cookiePairs = (headers.Cookie || headers.cookie || "").split(";");
38
39
  sourceIp = event.requestContext?.identity?.sourceIp || "";
39
40
  }
40
- const cookie = cookieParser.parse(cookieHeader);
41
+ // Parsed pair by pair so a name that arrived more than once keeps every
42
+ // value; a whole-header parse keeps only the first.
43
+ const cookieList = {};
44
+ for (const pair of cookiePairs) {
45
+ for (const [name, value] of Object.entries(cookieParser.parse(pair))) {
46
+ if (value !== undefined)
47
+ (cookieList[name] ??= []).push(value);
48
+ }
49
+ }
50
+ const cookie = Object.fromEntries(Object.entries(cookieList).map(([name, values]) => [name, values[0]]));
41
51
  const lowercasedHeaders = {};
42
52
  for (const [key, value] of Object.entries(headers)) {
43
53
  if (value !== undefined)
@@ -74,7 +84,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
74
84
  const requestVersion = isApiCall ? (post.version ?? null) : null;
75
85
  return {
76
86
  host, path, pathParams: {}, method,
77
- get, post, cookie, event,
87
+ get, post, cookie, cookieList, event,
78
88
  session: null,
79
89
  apiName, apiPayload,
80
90
  guardData: {},
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A cookie's Domain attribute: a fixed value such as ".example.com", or a
3
+ * function of the request hostname for one deployment serving several apex
4
+ * domains. Return undefined (or null) for a host-only cookie.
5
+ */
6
+ export type LambderCookieDomain = string | ((hostname: string) => string | undefined | null);
7
+ /**
8
+ * Attributes of a Set-Cookie header. A cookie's identity in the browser is
9
+ * (name, domain, path): a write under a different domain or path creates a
10
+ * second cookie beside the first instead of replacing it, and a deletion
11
+ * only reaches the cookie whose domain and path it names.
12
+ */
13
+ export type LambderCookieOptions = {
14
+ domain?: LambderCookieDomain;
15
+ /** Default "/". */
16
+ path?: string;
17
+ /** Default "Lax". */
18
+ sameSite?: "Strict" | "Lax" | "None";
19
+ /** Default true. */
20
+ secure?: boolean;
21
+ /** Default false. */
22
+ httpOnly?: boolean;
23
+ /** Lifetime in seconds (Max-Age). A cookie with neither maxAge nor expires lasts the browser session. */
24
+ maxAge?: number;
25
+ /** Absolute expiry (Expires). */
26
+ expires?: Date;
27
+ /** Value encoder. Default encodeURIComponent, which ctx.cookie reverses on the way back in. */
28
+ encode?: (value: string) => string;
29
+ };
30
+ /** Options of a deleting Set-Cookie: only the scope matters. */
31
+ export type LambderClearCookieOptions = Omit<LambderCookieOptions, "maxAge" | "expires" | "encode">;
32
+ /** The Domain attribute for this request, or undefined for a host-only cookie. */
33
+ export declare const resolveCookieDomain: (domain: LambderCookieDomain | undefined, host?: string) => string | undefined;
34
+ /**
35
+ * One Set-Cookie header value. `host` resolves a function-form domain; a
36
+ * string domain needs none.
37
+ */
38
+ export declare const serializeCookie: (name: string, value: string, options?: LambderCookieOptions, host?: string) => string;
39
+ /**
40
+ * A Set-Cookie header value that deletes the cookie. Domain and path must
41
+ * match the cookie being deleted: a mismatch targets a different cookie and
42
+ * deletes nothing.
43
+ */
44
+ export declare const serializeClearCookie: (name: string, options?: LambderClearCookieOptions, host?: string) => string;
@@ -0,0 +1,28 @@
1
+ import cookieParser from "cookie";
2
+ /** The Domain attribute for this request, or undefined for a host-only cookie. */
3
+ export const resolveCookieDomain = (domain, host = "") => {
4
+ // Host header can carry a port; browsers match the Domain attribute on hostname only.
5
+ const hostname = host.split(":")[0] ?? "";
6
+ const resolved = typeof domain === "function" ? domain(hostname) : domain;
7
+ return resolved || undefined;
8
+ };
9
+ /**
10
+ * One Set-Cookie header value. `host` resolves a function-form domain; a
11
+ * string domain needs none.
12
+ */
13
+ export const serializeCookie = (name, value, options = {}, host) => cookieParser.serialize(name, value, {
14
+ domain: resolveCookieDomain(options.domain, host),
15
+ path: options.path ?? "/",
16
+ sameSite: (options.sameSite ?? "Lax").toLowerCase(),
17
+ secure: options.secure ?? true,
18
+ httpOnly: options.httpOnly ?? false,
19
+ maxAge: options.maxAge,
20
+ expires: options.expires,
21
+ encode: options.encode,
22
+ });
23
+ /**
24
+ * A Set-Cookie header value that deletes the cookie. Domain and path must
25
+ * match the cookie being deleted: a mismatch targets a different cookie and
26
+ * deletes nothing.
27
+ */
28
+ export const serializeClearCookie = (name, options = {}, host) => serializeCookie(name, "", { ...options, maxAge: 0, expires: new Date(0) }, host);
@@ -1,4 +1,5 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
+ import { type LambderCookieOptions, type LambderClearCookieOptions } from "./LambderCookie.js";
2
3
  import type { LambderFiles } from "./LambderFiles.js";
3
4
  import { LambderResponse, type HttpStatusCode, type LambderHeadersInput } from "./LambderResponse.js";
4
5
  import { LambderSafeHtml } from "../shared/LambderHtml.js";
@@ -38,6 +39,19 @@ export default class LambderResponseBuilder<TResponse = any> {
38
39
  private requireFiles;
39
40
  addHeader(key: string, value: string): void;
40
41
  setHeader(key: string, value: string | string[]): void;
42
+ /**
43
+ * Adds a Set-Cookie header. A function-form `domain` is resolved against
44
+ * the request hostname. Defaults: Path=/, SameSite=Lax, Secure, not
45
+ * HttpOnly, browser-session lifetime.
46
+ */
47
+ setCookie(name: string, value: string, options?: LambderCookieOptions): void;
48
+ /**
49
+ * Adds a Set-Cookie header that deletes the cookie. Pass the same
50
+ * `domain` and `path` the cookie was set with: a cookie's identity is
51
+ * (name, domain, path), and a deletion under a different scope targets a
52
+ * different cookie and deletes nothing.
53
+ */
54
+ clearCookie(name: string, options?: LambderClearCookieOptions): void;
41
55
  logToApiResponse(input: any): void;
42
56
  raw(init: LambderRawResponseInit): LambderResponse;
43
57
  json(data: Record<string, any>, options?: LambderResponseOptions): LambderResponse;
@@ -1,3 +1,4 @@
1
+ import { serializeCookie, serializeClearCookie } from "./LambderCookie.js";
1
2
  import { LambderResponse } from "./LambderResponse.js";
2
3
  export default class LambderResponseBuilder {
3
4
  files;
@@ -45,6 +46,29 @@ export default class LambderResponseBuilder {
45
46
  this.ctx._otherInternal.setHeaderFnAccumulator.push({ key, value });
46
47
  }
47
48
  ;
49
+ /**
50
+ * Adds a Set-Cookie header. A function-form `domain` is resolved against
51
+ * the request hostname. Defaults: Path=/, SameSite=Lax, Secure, not
52
+ * HttpOnly, browser-session lifetime.
53
+ */
54
+ setCookie(name, value, options) {
55
+ if (!this.ctx)
56
+ throw new Error(".setCookie function is not available within this hook");
57
+ this.addHeader("Set-Cookie", serializeCookie(name, value, options, this.ctx.host));
58
+ }
59
+ ;
60
+ /**
61
+ * Adds a Set-Cookie header that deletes the cookie. Pass the same
62
+ * `domain` and `path` the cookie was set with: a cookie's identity is
63
+ * (name, domain, path), and a deletion under a different scope targets a
64
+ * different cookie and deletes nothing.
65
+ */
66
+ clearCookie(name, options) {
67
+ if (!this.ctx)
68
+ throw new Error(".clearCookie function is not available within this hook");
69
+ this.addHeader("Set-Cookie", serializeClearCookie(name, options, this.ctx.host));
70
+ }
71
+ ;
48
72
  logToApiResponse(input) {
49
73
  if (!this.ctx)
50
74
  throw new Error(".logToApiResponse function is not available within this hook");
package/dist/index.d.ts CHANGED
@@ -41,3 +41,5 @@ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, Lambd
41
41
  export { type ApiContractShape, type LambderApiResponse, type LambderApiResponseConfig, } from "./shared/LambderApiContract.js";
42
42
  export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent } from "./core/LambderContext.js";
43
43
  export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
44
+ export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
45
+ export type { LambderCookieOptions, LambderClearCookieOptions, LambderCookieDomain } from "./core/LambderCookie.js";
package/dist/index.js CHANGED
@@ -32,3 +32,5 @@ export { lambderRateLimitKey } from "./policies/LambderApiRateLimits.js";
32
32
  // Typed translations (standalone, isomorphic)
33
33
  export { createLambderI18n } from "./shared/LambderI18n.js";
34
34
  export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
35
+ // Cookies (res.setCookie / res.clearCookie build on these; exported for code holding a LambderResponse)
36
+ export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
@@ -1,17 +1,18 @@
1
1
  import { LambderRenderContext, LambderSessionRenderContext } from "../core/LambderContext.js";
2
+ import { type LambderCookieOptions } from "../core/LambderCookie.js";
2
3
  import type LambderSessionManager from "./LambderSessionManager.js";
3
4
  import { type LambderSessionContext } from "./LambderSessionManager.js";
4
- export type LambderSessionCookieOptions = {
5
- /**
6
- * e.g. ".example.com" to share sessions across subdomains. Pass a function to
7
- * derive it from the request hostname when one deployment serves several
8
- * apex domains; return undefined for a host-only cookie.
9
- */
10
- domain?: string | ((hostname: string) => string | undefined | null);
11
- path?: string;
12
- sameSite?: "Strict" | "Lax" | "None";
13
- secure?: boolean;
14
- };
5
+ /**
6
+ * Scope of the session cookies. `domain` is e.g. ".example.com" to share
7
+ * sessions across subdomains, or a function of the request hostname when
8
+ * one deployment serves several apex domains (return undefined for a
9
+ * host-only cookie). Changing `domain` or `path` on a live deployment is a
10
+ * migration: browsers keep the cookie under the old scope beside the new
11
+ * one, and both arrive on every request. fetchSession tolerates that by
12
+ * trying every copy and evicting the stale host-only twin; a copy at a
13
+ * parent domain this host cannot name outlives its own Expires.
14
+ */
15
+ export type LambderSessionCookieOptions = Pick<LambderCookieOptions, "domain" | "path" | "sameSite" | "secure">;
15
16
  export default class LambderSessionController<TSessionData = any> {
16
17
  lambderSessionManager: LambderSessionManager;
17
18
  sessionTokenCookieKey: string;
@@ -25,16 +26,24 @@ export default class LambderSessionController<TSessionData = any> {
25
26
  cookieOptions?: LambderSessionCookieOptions;
26
27
  ctx: LambderRenderContext<any> | LambderSessionRenderContext<any, TSessionData>;
27
28
  });
28
- private buildCookie;
29
+ /** The configured scope with the domain resolved for this request, or the host-only scope. */
30
+ private cookieScope;
29
31
  /** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
30
32
  private setSessionCookies;
31
33
  private clearSessionCookies;
34
+ /**
35
+ * Every well-formed value the request carried under the session cookie
36
+ * name. More than one means the browser holds the cookie at several
37
+ * scopes, and the order says nothing about which copy is current.
38
+ */
39
+ private sessionTokenCandidates;
32
40
  private areRequestSessionTokensValid;
33
41
  createSession(sessionKey: string, data?: TSessionData, ttlInSeconds?: number): Promise<LambderSessionContext<TSessionData>>;
34
42
  regenerateSession(): Promise<LambderSessionContext<TSessionData>>;
35
43
  fetchSession(): Promise<LambderSessionContext<TSessionData>>;
36
44
  fetchSessionIfExists(): Promise<LambderSessionContext<TSessionData> | null>;
37
- isSessionValid(session: any): boolean;
45
+ /** Checks the record against a presented token (the request's first session cookie by default) and, on API calls, the posted CSRF token. */
46
+ isSessionValid(session: any, sessionToken?: string | undefined): boolean;
38
47
  updateSessionData(newData: any): Promise<LambderSessionContext>;
39
48
  /**
40
49
  * Force-runs the dataRefresh callback now (see the session option of create) and
@@ -1,4 +1,7 @@
1
+ import { resolveCookieDomain, serializeCookie, serializeClearCookie } from "../core/LambderCookie.js";
1
2
  import { LambderSessionDataRefreshError, LambderSessionReadError } from "./LambderSessionManager.js";
3
+ /** The tokens are hex, so the cookie carries them as they are (the format existing browsers hold). */
4
+ const rawValue = (value) => value;
2
5
  export default class LambderSessionController {
3
6
  lambderSessionManager;
4
7
  sessionTokenCookieKey;
@@ -13,39 +16,37 @@ export default class LambderSessionController {
13
16
  this.ctx = ctx;
14
17
  }
15
18
  ;
16
- buildCookie(key, value, expiresAtMs, httpOnly) {
17
- const { domain, path = "/", sameSite = "Lax", secure = true } = this.cookieOptions;
18
- // Host header can carry a port; browsers match the Domain attribute on hostname only.
19
- const hostname = (this.ctx.host || "").split(":")[0];
20
- const resolvedDomain = typeof domain === "function" ? domain(hostname) : domain;
21
- const parts = [
22
- `${key}=${value}`,
23
- `Expires=${new Date(expiresAtMs).toUTCString()}`,
24
- `Path=${path}`,
25
- ...(resolvedDomain ? [`Domain=${resolvedDomain}`] : []),
26
- ...(httpOnly ? ["HttpOnly"] : []),
27
- `SameSite=${sameSite}`,
28
- ...(secure ? ["Secure"] : []),
29
- ];
30
- return parts.join("; ");
19
+ /** The configured scope with the domain resolved for this request, or the host-only scope. */
20
+ cookieScope(hostOnly = false) {
21
+ const { domain, path, sameSite, secure } = this.cookieOptions;
22
+ return { domain: hostOnly ? undefined : resolveCookieDomain(domain, this.ctx.host), path, sameSite, secure };
31
23
  }
32
24
  ;
33
25
  /** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
34
26
  setSessionCookies(created) {
35
- const expiresAtMs = created.session.expiresAt * 1000;
36
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, created.sessionToken, expiresAtMs, true) });
37
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, created.csrfToken, expiresAtMs, false) });
27
+ const scope = this.cookieScope();
28
+ const expires = new Date(created.session.expiresAt * 1000);
29
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionTokenCookieKey, created.sessionToken, { ...scope, expires, httpOnly: true, encode: rawValue }) });
30
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionCsrfCookieKey, created.csrfToken, { ...scope, expires, encode: rawValue }) });
38
31
  }
39
32
  ;
40
- clearSessionCookies() {
41
- const expired = Date.now() - 100000;
42
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, "0", expired, true) });
43
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, "0", expired, false) });
33
+ clearSessionCookies(hostOnly = false) {
34
+ const scope = this.cookieScope(hostOnly);
35
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionTokenCookieKey, { ...scope, httpOnly: true }) });
36
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionCsrfCookieKey, scope) });
37
+ }
38
+ ;
39
+ /**
40
+ * Every well-formed value the request carried under the session cookie
41
+ * name. More than one means the browser holds the cookie at several
42
+ * scopes, and the order says nothing about which copy is current.
43
+ */
44
+ sessionTokenCandidates() {
45
+ return (this.ctx.cookieList?.[this.sessionTokenCookieKey] ?? []).filter((token) => token.split(":").length === 2);
44
46
  }
45
47
  ;
46
48
  areRequestSessionTokensValid() {
47
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
48
- const isSessionTokenValid = !!sessionToken && sessionToken.split(":").length === 2;
49
+ const isSessionTokenValid = this.sessionTokenCandidates().length > 0;
49
50
  if (this.ctx._otherInternal.isApiCall) {
50
51
  const csrfToken = this.ctx.post?.token;
51
52
  const isCsrfTokenValid = typeof csrfToken === "string" && csrfToken.length > 0;
@@ -76,16 +77,23 @@ export default class LambderSessionController {
76
77
  if (!this.areRequestSessionTokensValid()) {
77
78
  throw new Error("Session tokens are invalid");
78
79
  }
79
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
80
- if (!sessionToken)
81
- throw new Error("Session token not found");
82
- const session = await this.lambderSessionManager.getSession(sessionToken);
83
- if (!session)
84
- throw new Error("Session not found");
85
- if (!this.isSessionValid(session))
86
- throw new Error("Invalid session");
87
- this.ctx.session = session;
88
- return session;
80
+ const candidates = this.sessionTokenCandidates();
81
+ if (candidates.length > 1) {
82
+ console.warn(`Lambder session: ${candidates.length} "${this.sessionTokenCookieKey}" cookies arrived from ${this.ctx.host}; the browser holds the cookie at several scopes and a stale copy may shadow the live one. Trying each.`);
83
+ }
84
+ for (const sessionToken of candidates) {
85
+ const session = await this.lambderSessionManager.getSession(sessionToken);
86
+ if (!session || !this.isSessionValid(session, sessionToken))
87
+ continue;
88
+ // The other copies are stale. This response can evict the
89
+ // host-only twin of a Domain= cookie; a copy at a parent domain
90
+ // this host cannot name is out of reach and expires on its own.
91
+ if (candidates.length > 1 && this.cookieScope().domain)
92
+ this.clearSessionCookies(true);
93
+ this.ctx.session = session;
94
+ return session;
95
+ }
96
+ throw new Error("Session not found");
89
97
  }
90
98
  ;
91
99
  async fetchSessionIfExists() {
@@ -104,14 +112,13 @@ export default class LambderSessionController {
104
112
  }
105
113
  }
106
114
  ;
107
- isSessionValid(session) {
115
+ /** Checks the record against a presented token (the request's first session cookie by default) and, on API calls, the posted CSRF token. */
116
+ isSessionValid(session, sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey]) {
108
117
  if (this.ctx._otherInternal.isApiCall) {
109
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
110
118
  const csrfToken = this.ctx.post?.token;
111
119
  return this.lambderSessionManager.isSessionValid(session, sessionToken, csrfToken);
112
120
  }
113
121
  else {
114
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
115
122
  return this.lambderSessionManager.isSessionValid(session, sessionToken, null, true);
116
123
  }
117
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.5.1",
3
+ "version": "4.6.2",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -51,26 +51,40 @@
51
51
  "url": "https://github.com/nesovera/lambder.git"
52
52
  },
53
53
  "dependencies": {
54
- "@aws-sdk/client-dynamodb": "^3.574.0",
55
- "@aws-sdk/lib-dynamodb": "^3.574.0",
56
54
  "cookie": "^1.0.2",
57
55
  "js-cookie": "^3.0.5",
58
56
  "lru-cache": "^11.5.2",
59
57
  "mime-types": "^2.1.35",
60
- "path-to-regexp": "^6.2.1",
61
- "zod": "^4.1.12"
58
+ "path-to-regexp": "^6.2.1"
62
59
  },
63
60
  "peerDependencies": {
61
+ "@aws-sdk/client-dynamodb": "^3.574.0",
64
62
  "@aws-sdk/client-s3": "^3.574.0",
65
- "msw": "^2.0.0"
63
+ "@aws-sdk/lib-dynamodb": "^3.574.0",
64
+ "msw": "^2.0.0",
65
+ "zod": "^4.1.12"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
+ "@aws-sdk/client-dynamodb": {
69
+ "optional": true
70
+ },
71
+ "@aws-sdk/client-s3": {
72
+ "optional": true
73
+ },
74
+ "@aws-sdk/lib-dynamodb": {
75
+ "optional": true
76
+ },
68
77
  "msw": {
69
78
  "optional": true
79
+ },
80
+ "zod": {
81
+ "optional": true
70
82
  }
71
83
  },
72
84
  "devDependencies": {
85
+ "@aws-sdk/client-dynamodb": "^3.913.0",
73
86
  "@aws-sdk/client-s3": "^3.1127.0",
87
+ "@aws-sdk/lib-dynamodb": "^3.913.0",
74
88
  "@types/aws-lambda": "^8.10.136",
75
89
  "@types/cookie": "^0.6.0",
76
90
  "@types/js-cookie": "^3.0.6",
@@ -82,6 +96,7 @@
82
96
  "aws-sdk-client-mock": "^4.1.0",
83
97
  "eslint": "^8.57.0",
84
98
  "typescript": "^5.9.3",
85
- "vitest": "^3.2.4"
99
+ "vitest": "^3.2.4",
100
+ "zod": "^4.1.12"
86
101
  }
87
102
  }