lambder 3.3.0 → 3.3.3

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/dist/Lambder.d.ts CHANGED
@@ -130,7 +130,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
130
130
  enableSlidingExpiration?: boolean;
131
131
  /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
132
132
  slidingWriteIntervalSeconds?: number;
133
- /** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. */
133
+ /** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. `domain` may be a (hostname) => string function for multi-domain deployments. */
134
134
  cookie?: LambderSessionCookieOptions;
135
135
  partitionKey?: string;
136
136
  sortKey?: string;
@@ -41,7 +41,8 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
41
41
  private fetchEndedHandler?;
42
42
  private sessionTokenCookieKey;
43
43
  private sessionCsrfCookieKey;
44
- constructor({ apiPath, apiVersion, isCorsEnabled, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, }: {
44
+ private sessionCookieDomain?;
45
+ constructor({ apiPath, apiVersion, isCorsEnabled, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }: {
45
46
  apiPath: string;
46
47
  apiVersion?: string;
47
48
  isCorsEnabled: boolean;
@@ -54,8 +55,11 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
54
55
  fetchStartedHandler?: FetchStartEventHandler;
55
56
  fetchEndedHandler?: FetchEndEventHandler;
56
57
  apiInputValidationErrorHandler?: ValidationErrorHandler;
58
+ /** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
59
+ sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
57
60
  });
58
61
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
62
+ private clearSessionCookies;
59
63
  apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: {
60
64
  headers?: Record<string, any>;
61
65
  versionExpiredHandler?: VoidFunction;
@@ -16,10 +16,12 @@ export default class LambderCaller {
16
16
  fetchEndedHandler;
17
17
  sessionTokenCookieKey = "LMDRSESSIONTKID";
18
18
  sessionCsrfCookieKey = "LMDRSESSIONCSTK";
19
- constructor({ apiPath, apiVersion, isCorsEnabled = false, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, }) {
19
+ sessionCookieDomain;
20
+ constructor({ apiPath, apiVersion, isCorsEnabled = false, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }) {
20
21
  this.apiPath = apiPath ?? "/api";
21
22
  this.apiVersion = apiVersion;
22
23
  this.isCorsEnabled = isCorsEnabled;
24
+ this.sessionCookieDomain = sessionCookieDomain;
23
25
  this.versionExpiredHandler = versionExpiredHandler;
24
26
  this.sessionExpiredHandler = sessionExpiredHandler;
25
27
  this.messageHandler = messageHandler;
@@ -35,6 +37,17 @@ export default class LambderCaller {
35
37
  this.sessionTokenCookieKey = sessionTokenCookieKey;
36
38
  this.sessionCsrfCookieKey = sessionCsrfCookieKey;
37
39
  }
40
+ clearSessionCookies() {
41
+ const domainOption = this.sessionCookieDomain;
42
+ const hostname = typeof window !== "undefined" ? window.location.hostname : "";
43
+ const resolvedDomain = typeof domainOption === "function" ? domainOption(hostname) : domainOption;
44
+ for (const key of [this.sessionTokenCookieKey, this.sessionCsrfCookieKey]) {
45
+ // Host-only and domain-scoped cookies are distinct entries; clear both.
46
+ Cookies.remove(key);
47
+ if (resolvedDomain)
48
+ Cookies.remove(key, { domain: resolvedDomain, path: "/" });
49
+ }
50
+ }
38
51
  async apiRaw(apiName, payload, options) {
39
52
  const headers = options?.headers;
40
53
  const fetchTracker = { apiName, done: false, fetchEndCalled: false };
@@ -102,8 +115,7 @@ export default class LambderCaller {
102
115
  return null;
103
116
  }
104
117
  if (data && data.sessionExpired) {
105
- Cookies.set(this.sessionTokenCookieKey, '', { expires: -1 });
106
- Cookies.set(this.sessionCsrfCookieKey, '', { expires: -1 });
118
+ this.clearSessionCookies();
107
119
  if (this.sessionExpiredHandler) {
108
120
  await this.sessionExpiredHandler();
109
121
  }
@@ -0,0 +1,50 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ export interface LambderRateLimitPolicy {
3
+ perMin?: number;
4
+ per10Min?: number;
5
+ perHour?: number;
6
+ perDay?: number;
7
+ perWeek?: number;
8
+ perMonth?: number;
9
+ }
10
+ export type LambderRateLimitExceededMap = Partial<Record<keyof LambderRateLimitPolicy, number>>;
11
+ /** `false` when allowed, otherwise the window(s) whose limit was hit. */
12
+ export type LambderRateLimitResult = false | LambderRateLimitExceededMap;
13
+ export interface LambderDdbRateLimiterOptions {
14
+ tableName: string;
15
+ region?: string;
16
+ /** Partition key prefix, keeps counters separated from other items. */
17
+ keyPrefix?: string;
18
+ /** Multiplier applied to the window length when setting the item TTL. */
19
+ ttlWindowMultiplier?: number;
20
+ /** Allow the request when DynamoDB itself errors. Defaults to false. */
21
+ failOpen?: boolean;
22
+ client?: DynamoDBClient;
23
+ }
24
+ /**
25
+ * Fixed-window rate limiter backed by DynamoDB.
26
+ *
27
+ * Each window is a single item counted with a conditional `ADD`, so the
28
+ * increment and the limit check happen atomically in one request. Windows are
29
+ * evaluated from smallest to largest and evaluation stops at the first
30
+ * exceeded window, which keeps blocked requests cheap and avoids inflating the
31
+ * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
32
+ *
33
+ * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
34
+ */
35
+ export declare class LambderDdbRateLimiter {
36
+ readonly tableName: string;
37
+ readonly keyPrefix: string;
38
+ private readonly client;
39
+ private readonly ttlWindowMultiplier;
40
+ private readonly failOpen;
41
+ constructor(options: LambderDdbRateLimiterOptions);
42
+ /**
43
+ * Increment every configured window for `trackerKey` (IP, session, user id, ...)
44
+ * and report whether any of them is over its limit.
45
+ */
46
+ isRateLimited(trackerKey: string, policy: LambderRateLimitPolicy): Promise<LambderRateLimitResult>;
47
+ /** Increments one window counter. Returns true when the limit was already reached. */
48
+ private incrementWindow;
49
+ }
50
+ export default LambderDdbRateLimiter;
@@ -0,0 +1,87 @@
1
+ import { DynamoDBClient, UpdateItemCommand, } from "@aws-sdk/client-dynamodb";
2
+ const WINDOW_CONFIG = [
3
+ { key: "perMin", seconds: 60 },
4
+ { key: "per10Min", seconds: 10 * 60 },
5
+ { key: "perHour", seconds: 60 * 60 },
6
+ { key: "perDay", seconds: 24 * 60 * 60 },
7
+ { key: "perWeek", seconds: 7 * 24 * 60 * 60 },
8
+ { key: "perMonth", seconds: 30 * 24 * 60 * 60 },
9
+ ];
10
+ /**
11
+ * Fixed-window rate limiter backed by DynamoDB.
12
+ *
13
+ * Each window is a single item counted with a conditional `ADD`, so the
14
+ * increment and the limit check happen atomically in one request. Windows are
15
+ * evaluated from smallest to largest and evaluation stops at the first
16
+ * exceeded window, which keeps blocked requests cheap and avoids inflating the
17
+ * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
18
+ *
19
+ * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
20
+ */
21
+ export class LambderDdbRateLimiter {
22
+ tableName;
23
+ keyPrefix;
24
+ client;
25
+ ttlWindowMultiplier;
26
+ failOpen;
27
+ constructor(options) {
28
+ if (!options.tableName.trim())
29
+ throw new Error("tableName is required");
30
+ this.tableName = options.tableName;
31
+ this.keyPrefix = options.keyPrefix ?? "RL";
32
+ this.ttlWindowMultiplier = options.ttlWindowMultiplier ?? 2;
33
+ if (!Number.isFinite(this.ttlWindowMultiplier) || this.ttlWindowMultiplier < 1) {
34
+ throw new Error("ttlWindowMultiplier must be a number greater than or equal to 1");
35
+ }
36
+ this.failOpen = options.failOpen ?? false;
37
+ this.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
38
+ }
39
+ /**
40
+ * Increment every configured window for `trackerKey` (IP, session, user id, ...)
41
+ * and report whether any of them is over its limit.
42
+ */
43
+ async isRateLimited(trackerKey, policy) {
44
+ for (const { key, seconds } of WINDOW_CONFIG) {
45
+ const limit = policy[key];
46
+ if (!limit)
47
+ continue;
48
+ const exceeded = await this.incrementWindow(trackerKey, key, seconds, limit);
49
+ if (exceeded)
50
+ return { [key]: limit };
51
+ }
52
+ return false;
53
+ }
54
+ /** Increments one window counter. Returns true when the limit was already reached. */
55
+ async incrementWindow(trackerKey, sortKeyPrefix, windowSeconds, limit) {
56
+ const nowSeconds = Math.floor(Date.now() / 1000);
57
+ const windowStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds;
58
+ const expiresAt = nowSeconds + Math.ceil(windowSeconds * this.ttlWindowMultiplier);
59
+ const input = {
60
+ TableName: this.tableName,
61
+ Key: {
62
+ pk: { S: `${this.keyPrefix}#${trackerKey}` },
63
+ sk: { S: `${sortKeyPrefix}#${windowStart}` },
64
+ },
65
+ UpdateExpression: "ADD #count :one SET #expiresAt = if_not_exists(#expiresAt, :expiresAt)",
66
+ ConditionExpression: "attribute_not_exists(#count) OR #count < :limit",
67
+ ExpressionAttributeNames: { "#count": "count", "#expiresAt": "expiresAt" },
68
+ ExpressionAttributeValues: {
69
+ ":one": { N: "1" },
70
+ ":expiresAt": { N: String(expiresAt) },
71
+ ":limit": { N: String(limit) },
72
+ },
73
+ };
74
+ try {
75
+ await this.client.send(new UpdateItemCommand(input));
76
+ return false;
77
+ }
78
+ catch (error) {
79
+ if (error.name === "ConditionalCheckFailedException")
80
+ return true;
81
+ if (this.failOpen)
82
+ return false;
83
+ throw error;
84
+ }
85
+ }
86
+ }
87
+ export default LambderDdbRateLimiter;
@@ -2,8 +2,12 @@ import { LambderRenderContext, LambderSessionRenderContext } from "./LambderCont
2
2
  import type LambderSessionManager from "./LambderSessionManager.js";
3
3
  import type { LambderSessionContext } from "./LambderSessionManager.js";
4
4
  export type LambderSessionCookieOptions = {
5
- /** e.g. ".example.com" to share sessions across subdomains. */
6
- domain?: string;
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);
7
11
  path?: string;
8
12
  sameSite?: "Strict" | "Lax" | "None";
9
13
  secure?: boolean;
@@ -14,11 +14,14 @@ export default class LambderSessionController {
14
14
  ;
15
15
  buildCookie(key, value, expiresAtMs, httpOnly) {
16
16
  const { domain, path = "/", sameSite = "Lax", secure = true } = this.cookieOptions;
17
+ // Host header can carry a port; browsers match the Domain attribute on hostname only.
18
+ const hostname = (this.ctx.host || "").split(":")[0];
19
+ const resolvedDomain = typeof domain === "function" ? domain(hostname) : domain;
17
20
  const parts = [
18
21
  `${key}=${value}`,
19
22
  `Expires=${new Date(expiresAtMs).toUTCString()}`,
20
23
  `Path=${path}`,
21
- ...(domain ? [`Domain=${domain}`] : []),
24
+ ...(resolvedDomain ? [`Domain=${resolvedDomain}`] : []),
22
25
  ...(httpOnly ? ["HttpOnly"] : []),
23
26
  `SameSite=${sameSite}`,
24
27
  ...(secure ? ["Secure"] : []),
package/dist/index.d.ts CHANGED
@@ -19,6 +19,8 @@ export type { LambderSessionCookieOptions } from "./LambderSessionController.js"
19
19
  export type { LambderSessionContext } from "./LambderSessionManager.js";
20
20
  export { LambderDdbCache } from "./LambderDdbCache.js";
21
21
  export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./LambderDdbCache.js";
22
+ export { LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
23
+ export type { LambderDdbRateLimiterOptions, LambderRateLimitPolicy, LambderRateLimitExceededMap, LambderRateLimitResult, } from "./LambderDdbRateLimiter.js";
22
24
  export { createLambderI18n } from "./LambderI18n.js";
23
25
  export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./LambderI18n.js";
24
26
  export { type ApiContractShape, } from "./LambderApiContract.js";
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ export { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
16
16
  export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
17
17
  // DynamoDB-backed compressed cache (standalone, server-only)
18
18
  export { LambderDdbCache } from "./LambderDdbCache.js";
19
+ // DynamoDB-backed fixed-window rate limiter (standalone, server-only)
20
+ export { LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
19
21
  // Typed translations (standalone, isomorphic)
20
22
  export { createLambderI18n } from "./LambderI18n.js";
21
23
  export { createContext, isV2HttpEvent } from "./LambderContext.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "3.3.0",
3
+ "version": "3.3.3",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",