@selvajs/server 1.5.0-beta.1 → 1.5.0-beta.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
@@ -26,23 +26,23 @@ pnpm add @selvajs/server
26
26
  **There is no root export.** Import from a subpath — `import … from '@selvajs/server'` fails to
27
27
  resolve, on purpose, so it stays visible which slice a consumer depends on.
28
28
 
29
- | Export | Contents |
30
- | ------------------------------- | ------------------------------------------------------------------------------- |
31
- | `@selvajs/server/compute` | Compute limits, rate limiting, idempotency, SSRF guard, remote-definition fetch |
32
- | `@selvajs/server/definitions` | Definition service, compute-schema extraction, render loader |
33
- | `@selvajs/server/providers` | Env-driven provider selection over a caller-supplied registry |
34
- | `@selvajs/server/tokens` | HMAC codec for capability-URL tokens (share links, invites) |
35
- | `@selvajs/server/errors` | Sentry-backed `IErrorReporter` |
36
- | `@selvajs/server/logging` | pino-backed `ILogger` + request-id correlation |
37
- | `@selvajs/server/http` | Security headers, body-size and redirect guards, route classification |
38
- | `@selvajs/server/access` | Builds the input platform's project access rules consume |
39
- | `@selvajs/server/ops` | Channel-aware semver comparison |
40
- | `@selvajs/server/api` | Transport-free API core: handler contract, injected deps, error envelope |
41
- | `@selvajs/server/handlers` | The API handlers: business logic behind each endpoint, bound via `runHandler` |
42
- | `@selvajs/server/projects` | Project writes whose rules two routes must agree on |
43
- | `@selvajs/server/organizations` | Org-level domain services spanning more than one provider |
44
- | `@selvajs/server/notifications` | `SmtpNotificationProvider`, SMTP config, invite delivery |
45
- | `@selvajs/server/testing` | Test harness for the transport-free API handlers |
29
+ | Export | Contents |
30
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------- |
31
+ | `@selvajs/server/compute` | Compute limits, rate limiting, idempotency, SSRF guard, remote-definition fetch |
32
+ | `@selvajs/server/definitions` | Definition service, compute-schema extraction, render loader |
33
+ | `@selvajs/server/providers` | Env-driven provider selection over a caller-supplied registry |
34
+ | `@selvajs/server/tokens` | HMAC codec for capability-URL tokens (share links, invites) |
35
+ | `@selvajs/server/errors` | Sentry-backed `IErrorReporter` |
36
+ | `@selvajs/server/logging` | pino-backed `ILogger` + request-id correlation |
37
+ | `@selvajs/server/http` | Security headers, body-size and redirect guards, route classification |
38
+ | `@selvajs/server/access` | Builds the input platform's project access rules consume |
39
+ | `@selvajs/server/ops` | Channel-aware semver comparison |
40
+ | `@selvajs/server/api` | Transport-free API core: handler contract, deps, errors, token scope, per-token rate limit, idempotency |
41
+ | `@selvajs/server/handlers` | The API handlers: business logic behind each endpoint, bound via `runHandler` |
42
+ | `@selvajs/server/projects` | Project writes whose rules two routes must agree on |
43
+ | `@selvajs/server/organizations` | Org-level domain services spanning more than one provider |
44
+ | `@selvajs/server/notifications` | `SmtpNotificationProvider`, SMTP config, invite delivery |
45
+ | `@selvajs/server/testing` | Test harness for the transport-free API handlers |
46
46
 
47
47
  Requires a `@selvajs/platform` provider (e.g. `@selvajs/local-provider` or
48
48
  `@selvajs/supabase-provider`) for data/storage/auth.
@@ -15,6 +15,8 @@ import type { DefinitionService } from '../definitions/definition-service.js';
15
15
  import type { OrgAssetService } from '../organizations/org-asset-service.js';
16
16
  import type { TokenCodec } from '../tokens/token-codec.js';
17
17
  import type { ApiTokenCodec } from '../tokens/api-token-codec.js';
18
+ import type { ApiRateLimiter } from './rate-limit.js';
19
+ import type { ApiIdempotencyStore } from './idempotency.js';
18
20
  import type { IAuthProvider, IDataProvider, IEventSink, INotificationProvider, IStorageProvider, SelvaConfig, SelvaFlags } from '@selvajs/platform';
19
21
  export interface SelvaDeps {
20
22
  auth: IAuthProvider;
@@ -90,6 +92,14 @@ export interface SelvaDeps {
90
92
  * handlers report `not-configured` rather than failing the write.
91
93
  */
92
94
  notifications?: INotificationProvider;
95
+ /**
96
+ * Per-token request limit `runHandler` charges. Absent: the process-wide
97
+ * default (`DEFAULT_API_RATE_LIMIT`). `null`: no limit. A host's own limiter
98
+ * must outlive the request, since deps are rebuilt per request.
99
+ */
100
+ apiRateLimiter?: ApiRateLimiter | null;
101
+ /** Replay store for routes mounted with `idempotent: true`. Absent: the process-wide default. */
102
+ idempotency?: ApiIdempotencyStore;
93
103
  /** Instance display name, used where a record has none. */
94
104
  instanceName: string;
95
105
  /** Where a person manages their API tokens, linked from the new-key email. Host-relative. */
@@ -130,12 +140,14 @@ export interface SelvaDeps {
130
140
  * `tokens` arrives in an options object rather than as a third positional, so
131
141
  * the two composed bags cannot be swapped at a call site.
132
142
  */
133
- export declare function depsFromConfig(config: SelvaConfig, services?: SelvaDeps['services'], { tokens, uploadLimits, evictComputeClient, notifications, instanceName, apiTokenSettingsPath }?: {
143
+ export declare function depsFromConfig(config: SelvaConfig, services?: SelvaDeps['services'], { tokens, uploadLimits, evictComputeClient, notifications, instanceName, apiTokenSettingsPath, apiRateLimiter, idempotency }?: {
134
144
  tokens?: SelvaDeps['tokens'];
135
145
  uploadLimits?: Partial<SelvaDeps['uploadLimits']>;
136
146
  evictComputeClient?: SelvaDeps['evictComputeClient'];
137
147
  notifications?: INotificationProvider;
138
148
  instanceName?: string;
139
149
  apiTokenSettingsPath?: string;
150
+ apiRateLimiter?: ApiRateLimiter | null;
151
+ idempotency?: ApiIdempotencyStore;
140
152
  }): SelvaDeps;
141
153
  //# sourceMappingURL=deps.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"deps.d.ts","sourceRoot":"","sources":["../../src/api/deps.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"deps.d.ts","sourceRoot":"","sources":["../../src/api/deps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sCAAsC,CAAC;AAC9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uCAAuC,CAAC;AAC7E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAClE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,KAAK,EACX,aAAa,EACb,aAAa,EACb,UAAU,EACV,qBAAqB,EACrB,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,aAAa,CAAC;IACpB,OAAO,EAAE,gBAAgB,CAAC;IAC1B,IAAI,EAAE,aAAa,CAAC;IACpB,uEAAuE;IACvE,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC5B,uCAAuC;IACvC,QAAQ,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IACpC,0DAA0D;IAC1D,cAAc,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC;IAC7C,aAAa,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC9C,WAAW,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC;IAC1C,UAAU,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IACxC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,CAAC;IAClC,WAAW,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC;IAC1C,qBAAqB,EAAE,aAAa,CAAC,uBAAuB,CAAC,CAAC;IAC9D;;;;;;;OAOG;IACH,MAAM,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,UAAU,KAAK,OAAO,CAAC;IAC1C;;;;;;;;;;;;;OAaG;IACH,MAAM,EAAE;QACP,UAAU,CAAC,EAAE,UAAU,CAAC;QACxB,OAAO,CAAC,EAAE,UAAU,CAAC;QACrB,gEAAgE;QAChE,SAAS,CAAC,EAAE,aAAa,CAAC;KAC1B,CAAC;IACF;;;;;;;;OAQG;IACH,YAAY,EAAE;QACb,qBAAqB,EAAE,MAAM,CAAC;QAC9B,gBAAgB,EAAE,MAAM,CAAC;KACzB,CAAC;IACF;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,qBAAqB,CAAC;IACtC;;;;OAIG;IACH,cAAc,CAAC,EAAE,cAAc,GAAG,IAAI,CAAC;IACvC,iGAAiG;IACjG,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,6FAA6F;IAC7F,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;;;;;;;;OAWG;IACH,kBAAkB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC;;;;;;;;OAQG;IACH,QAAQ,EAAE;QACT,WAAW,CAAC,EAAE,iBAAiB,CAAC;QAChC,SAAS,CAAC,EAAE,eAAe,CAAC;KAC5B,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC5B;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC7B,MAAM,EAAE,WAAW,EACnB,QAAQ,GAAE,SAAS,CAAC,UAAU,CAAM,EACpC,EACC,MAAW,EACX,YAAY,EACZ,kBAA6B,EAC7B,aAAa,EACb,YAAsB,EACtB,oBAAyC,EACzC,cAAc,EACd,WAAW,EACX,GAAE;IACF,MAAM,CAAC,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAC;IAC7B,YAAY,CAAC,EAAE,OAAO,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC;IAClD,kBAAkB,CAAC,EAAE,SAAS,CAAC,oBAAoB,CAAC,CAAC;IACrD,aAAa,CAAC,EAAE,qBAAqB,CAAC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,cAAc,CAAC,EAAE,cAAc,GAAG,IAAI,CAAC;IACvC,WAAW,CAAC,EAAE,mBAAmB,CAAC;CAC7B,GACJ,SAAS,CAiCX"}
package/dist/api/deps.js CHANGED
@@ -21,7 +21,7 @@ import { isFlagEnabled, NoopEventSink } from '@selvajs/platform';
21
21
  * `tokens` arrives in an options object rather than as a third positional, so
22
22
  * the two composed bags cannot be swapped at a call site.
23
23
  */
24
- export function depsFromConfig(config, services = {}, { tokens = {}, uploadLimits, evictComputeClient = () => { }, notifications, instanceName = 'Selva', apiTokenSettingsPath = '/settings/tokens' } = {}) {
24
+ export function depsFromConfig(config, services = {}, { tokens = {}, uploadLimits, evictComputeClient = () => { }, notifications, instanceName = 'Selva', apiTokenSettingsPath = '/settings/tokens', apiRateLimiter, idempotency } = {}) {
25
25
  const { data } = config;
26
26
  return {
27
27
  auth: config.auth,
@@ -50,6 +50,8 @@ export function depsFromConfig(config, services = {}, { tokens = {}, uploadLimit
50
50
  notifications,
51
51
  instanceName,
52
52
  apiTokenSettingsPath,
53
+ apiRateLimiter,
54
+ idempotency,
53
55
  services
54
56
  };
55
57
  }
@@ -1 +1 @@
1
- {"version":3,"file":"deps.js","sourceRoot":"","sources":["../../src/api/deps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAyHjE;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAC7B,MAAmB,EACnB,WAAkC,EAAE,EACpC,EACC,MAAM,GAAG,EAAE,EACX,YAAY,EACZ,kBAAkB,GAAG,GAAG,EAAE,GAAE,CAAC,EAC7B,aAAa,EACb,YAAY,GAAG,OAAO,EACtB,oBAAoB,GAAG,kBAAkB,KAQtC,EAAE;IAEN,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC;IACxB,OAAO;QACN,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,IAAI;QACJ,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,cAAc,EAAE,IAAI,CAAC,WAAW;QAChC,aAAa,EAAE,IAAI,CAAC,aAAa;QACjC,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,qBAAqB,EAAE,IAAI,CAAC,qBAAqB;QACjD,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,IAAI,aAAa,EAAE;QAC5C,IAAI,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,IAAI,CAAC;QAC3C,MAAM;QACN,qEAAqE;QACrE,sEAAsE;QACtE,sEAAsE;QACtE,YAAY,EAAE;YACb,qBAAqB,EAAE,YAAY,EAAE,qBAAqB,IAAI,EAAE,GAAG,IAAI,GAAG,IAAI;YAC9E,gBAAgB,EAAE,YAAY,EAAE,gBAAgB,IAAI,EAAE,GAAG,IAAI,GAAG,IAAI;SACpE;QACD,kBAAkB;QAClB,aAAa;QACb,YAAY;QACZ,oBAAoB;QACpB,QAAQ;KACR,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"deps.js","sourceRoot":"","sources":["../../src/api/deps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAmIjE;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAC7B,MAAmB,EACnB,WAAkC,EAAE,EACpC,EACC,MAAM,GAAG,EAAE,EACX,YAAY,EACZ,kBAAkB,GAAG,GAAG,EAAE,GAAE,CAAC,EAC7B,aAAa,EACb,YAAY,GAAG,OAAO,EACtB,oBAAoB,GAAG,kBAAkB,EACzC,cAAc,EACd,WAAW,KAUR,EAAE;IAEN,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC;IACxB,OAAO;QACN,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,IAAI;QACJ,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,cAAc,EAAE,IAAI,CAAC,WAAW;QAChC,aAAa,EAAE,IAAI,CAAC,aAAa;QACjC,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,qBAAqB,EAAE,IAAI,CAAC,qBAAqB;QACjD,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,IAAI,aAAa,EAAE;QAC5C,IAAI,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,IAAI,CAAC;QAC3C,MAAM;QACN,qEAAqE;QACrE,sEAAsE;QACtE,sEAAsE;QACtE,YAAY,EAAE;YACb,qBAAqB,EAAE,YAAY,EAAE,qBAAqB,IAAI,EAAE,GAAG,IAAI,GAAG,IAAI;YAC9E,gBAAgB,EAAE,YAAY,EAAE,gBAAgB,IAAI,EAAE,GAAG,IAAI,GAAG,IAAI;SACpE;QACD,kBAAkB;QAClB,aAAa;QACb,YAAY;QACZ,oBAAoB;QACpB,cAAc;QACd,WAAW;QACX,QAAQ;KACR,CAAC;AACH,CAAC"}
@@ -20,6 +20,8 @@ export declare const ApiErrorCode: {
20
20
  readonly INTERNAL: "INTERNAL";
21
21
  /** API tokens are switched off or misconfigured here; browser sessions still work. */
22
22
  readonly API_TOKENS_UNAVAILABLE: "API_TOKENS_UNAVAILABLE";
23
+ /** Too many requests, or a share link's solve cap is spent. `Retry-After`, when sent, says when to retry. */
24
+ readonly RATE_LIMITED: "RATE_LIMITED";
23
25
  };
24
26
  export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode];
25
27
  export declare class ApiError extends Error {
@@ -31,6 +33,13 @@ export declare class ApiError extends Error {
31
33
  readonly details?: Record<string, string>;
32
34
  constructor(status: number, code: ApiErrorCode, message: string, fields?: Record<string, string>, details?: Record<string, string>);
33
35
  }
36
+ export interface ApiErrorBody {
37
+ message: string;
38
+ code: ApiErrorCode;
39
+ fields?: Record<string, string>;
40
+ details?: Record<string, string>;
41
+ }
42
+ export declare function toErrorBody(err: ApiError): ApiErrorBody;
34
43
  export declare function isApiError(err: unknown): err is ApiError;
35
44
  /** Raise a structured API error. The only way handlers should fail. */
36
45
  export declare function apiError(status: number, code: ApiErrorCode, message: string, fields?: Record<string, string>, details?: Record<string, string>): never;
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/api/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iFAAiF;AACjF,eAAO,MAAM,YAAY;;;;;;;;;;IAUxB,sFAAsF;;CAE7E,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,OAAO,YAAY,CAAC,CAAC;AAE5E,qBAAa,QAAS,SAAQ,KAAK;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;gBAGzC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;CASjC;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAExD;AAED,uEAAuE;AACvE,wBAAgB,QAAQ,CACvB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,KAAK,CAEP;AAED,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAmB1D"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/api/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iFAAiF;AACjF,eAAO,MAAM,YAAY;;;;;;;;;;IAUxB,sFAAsF;;IAEtF,6GAA6G;;CAEpG,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,OAAO,YAAY,CAAC,CAAC;AAE5E,qBAAa,QAAS,SAAQ,KAAK;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;gBAGzC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;CASjC;AAED,MAAM,WAAW,YAAY;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,YAAY,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,YAAY,CAKvD;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAExD;AAED,uEAAuE;AACvE,wBAAgB,QAAQ,CACvB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,KAAK,CAEP;AAED,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAqB1D"}
@@ -19,7 +19,9 @@ export const ApiErrorCode = {
19
19
  SETUP_REQUIRED: 'SETUP_REQUIRED',
20
20
  INTERNAL: 'INTERNAL',
21
21
  /** API tokens are switched off or misconfigured here; browser sessions still work. */
22
- API_TOKENS_UNAVAILABLE: 'API_TOKENS_UNAVAILABLE'
22
+ API_TOKENS_UNAVAILABLE: 'API_TOKENS_UNAVAILABLE',
23
+ /** Too many requests, or a share link's solve cap is spent. `Retry-After`, when sent, says when to retry. */
24
+ RATE_LIMITED: 'RATE_LIMITED'
23
25
  };
24
26
  export class ApiError extends Error {
25
27
  status;
@@ -37,6 +39,14 @@ export class ApiError extends Error {
37
39
  this.details = details;
38
40
  }
39
41
  }
42
+ export function toErrorBody(err) {
43
+ const body = { message: err.message, code: err.code };
44
+ if (err.fields)
45
+ body.fields = err.fields;
46
+ if (err.details)
47
+ body.details = err.details;
48
+ return body;
49
+ }
40
50
  export function isApiError(err) {
41
51
  return err instanceof ApiError;
42
52
  }
@@ -59,6 +69,8 @@ export function codeForStatus(status) {
59
69
  return ApiErrorCode.CONFLICT;
60
70
  case 422:
61
71
  return ApiErrorCode.UNPROCESSABLE;
72
+ case 429:
73
+ return ApiErrorCode.RATE_LIMITED;
62
74
  case 503:
63
75
  return ApiErrorCode.COMPUTE_UNAVAILABLE;
64
76
  default:
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/api/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iFAAiF;AACjF,MAAM,CAAC,MAAM,YAAY,GAAG;IAC3B,iBAAiB,EAAE,mBAAmB;IACtC,YAAY,EAAE,cAAc;IAC5B,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;IACtB,QAAQ,EAAE,UAAU;IACpB,aAAa,EAAE,eAAe;IAC9B,mBAAmB,EAAE,qBAAqB;IAC1C,cAAc,EAAE,gBAAgB;IAChC,QAAQ,EAAE,UAAU;IACpB,sFAAsF;IACtF,sBAAsB,EAAE,wBAAwB;CACvC,CAAC;AAIX,MAAM,OAAO,QAAS,SAAQ,KAAK;IACzB,MAAM,CAAS;IACf,IAAI,CAAe;IAC5B,sFAAsF;IAC7E,MAAM,CAA0B;IACzC,yEAAyE;IAChE,OAAO,CAA0B;IAE1C,YACC,MAAc,EACd,IAAkB,EAClB,OAAe,EACf,MAA+B,EAC/B,OAAgC;QAEhC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACxB,CAAC;CACD;AAED,MAAM,UAAU,UAAU,CAAC,GAAY;IACtC,OAAO,GAAG,YAAY,QAAQ,CAAC;AAChC,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,QAAQ,CACvB,MAAc,EACd,IAAkB,EAClB,OAAe,EACf,MAA+B,EAC/B,OAAgC;IAEhC,MAAM,IAAI,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AAC5D,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,aAAa,CAAC,MAAc;IAC3C,QAAQ,MAAM,EAAE,CAAC;QAChB,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,iBAAiB,CAAC;QACvC,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,YAAY,CAAC;QAClC,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,SAAS,CAAC;QAC/B,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,SAAS,CAAC;QAC/B,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,QAAQ,CAAC;QAC9B,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,aAAa,CAAC;QACnC,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,mBAAmB,CAAC;QACzC;YACC,OAAO,YAAY,CAAC,QAAQ,CAAC;IAC/B,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/api/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iFAAiF;AACjF,MAAM,CAAC,MAAM,YAAY,GAAG;IAC3B,iBAAiB,EAAE,mBAAmB;IACtC,YAAY,EAAE,cAAc;IAC5B,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;IACtB,QAAQ,EAAE,UAAU;IACpB,aAAa,EAAE,eAAe;IAC9B,mBAAmB,EAAE,qBAAqB;IAC1C,cAAc,EAAE,gBAAgB;IAChC,QAAQ,EAAE,UAAU;IACpB,sFAAsF;IACtF,sBAAsB,EAAE,wBAAwB;IAChD,6GAA6G;IAC7G,YAAY,EAAE,cAAc;CACnB,CAAC;AAIX,MAAM,OAAO,QAAS,SAAQ,KAAK;IACzB,MAAM,CAAS;IACf,IAAI,CAAe;IAC5B,sFAAsF;IAC7E,MAAM,CAA0B;IACzC,yEAAyE;IAChE,OAAO,CAA0B;IAE1C,YACC,MAAc,EACd,IAAkB,EAClB,OAAe,EACf,MAA+B,EAC/B,OAAgC;QAEhC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACxB,CAAC;CACD;AASD,MAAM,UAAU,WAAW,CAAC,GAAa;IACxC,MAAM,IAAI,GAAiB,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC;IACpE,IAAI,GAAG,CAAC,MAAM;QAAE,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;IACzC,IAAI,GAAG,CAAC,OAAO;QAAE,IAAI,CAAC,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;IAC5C,OAAO,IAAI,CAAC;AACb,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,GAAY;IACtC,OAAO,GAAG,YAAY,QAAQ,CAAC;AAChC,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,QAAQ,CACvB,MAAc,EACd,IAAkB,EAClB,OAAe,EACf,MAA+B,EAC/B,OAAgC;IAEhC,MAAM,IAAI,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AAC5D,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,aAAa,CAAC,MAAc;IAC3C,QAAQ,MAAM,EAAE,CAAC;QAChB,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,iBAAiB,CAAC;QACvC,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,YAAY,CAAC;QAClC,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,SAAS,CAAC;QAC/B,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,SAAS,CAAC;QAC/B,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,QAAQ,CAAC;QAC9B,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,aAAa,CAAC;QACnC,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,YAAY,CAAC;QAClC,KAAK,GAAG;YACP,OAAO,YAAY,CAAC,mBAAmB,CAAC;QACzC;YACC,OAAO,YAAY,CAAC,QAAQ,CAAC;IAC/B,CAAC;AACF,CAAC"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `Idempotency-Key` for mounted routes that opt in with `idempotent: true`.
3
+ *
4
+ * Built on the solve route's store and wire contract (`../compute/idempotency*`):
5
+ * the store absorbs in-flight and near-term retries in this process; it is not
6
+ * a durable record. A create that must never duplicate also needs the key on
7
+ * the created row under a unique constraint, written in the same transaction.
8
+ *
9
+ * Each entry carries a fingerprint of method, path and body, so a client that
10
+ * reuses a key for a different request gets 422 instead of someone else's
11
+ * replay. Only responses below 400 are kept: a failed attempt frees the key so
12
+ * a corrected retry can run.
13
+ */
14
+ import type { RequestContext } from '@selvajs/platform';
15
+ import { type IdempotencyStore } from '../compute/idempotency.js';
16
+ import { type StoredResponse } from '../compute/idempotency-http.js';
17
+ export declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
18
+ export declare const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
19
+ /** Covers a client's retry-on-timeout window without turning into a result cache. */
20
+ export declare const DEFAULT_API_IDEMPOTENCY_TTL_MS: number;
21
+ export interface IdempotentEntry {
22
+ fingerprint: string;
23
+ response: StoredResponse;
24
+ }
25
+ export type ApiIdempotencyStore = IdempotencyStore<IdempotentEntry>;
26
+ export declare function createApiIdempotencyStore(ttlMs?: number): ApiIdempotencyStore;
27
+ /** The store `depsFromConfig` wires when the host passes none. One per process. */
28
+ export declare function defaultApiIdempotencyStore(): ApiIdempotencyStore;
29
+ /**
30
+ * The narrowest identity a key is namespaced by: the token for a token
31
+ * request, so two keys of one user never share replays, else the user.
32
+ */
33
+ export declare function idempotencyCallerId(ctx: RequestContext): string;
34
+ /** Read and validate the client's key. `null` when the header is absent. */
35
+ export declare function readIdempotencyKey(request: Request): string | null;
36
+ /** SHA-256 over method, path and body, hex. */
37
+ export declare function requestFingerprint(method: string, path: string, body: ArrayBuffer | string): Promise<string>;
38
+ /**
39
+ * The body `requestFingerprint` should hash, read from a clone so the handler
40
+ * can still consume the original.
41
+ *
42
+ * Multipart is canonicalized to its fields and file contents: clients pick a
43
+ * fresh random boundary per request, so the raw bytes of an identical retry
44
+ * never match.
45
+ */
46
+ export declare function fingerprintBody(request: Request): Promise<ArrayBuffer | string>;
47
+ /**
48
+ * Run `fn` once per `(callerId, clientKey)`, replaying its response to repeats
49
+ * with `Idempotency-Replayed: true`. Throws 422 when the key was used for a
50
+ * different request. Errors `fn` throws propagate and free the key.
51
+ */
52
+ export declare function runIdempotent({ store, callerId, clientKey, fingerprint }: {
53
+ store: ApiIdempotencyStore;
54
+ callerId: string;
55
+ clientKey: string;
56
+ fingerprint: string;
57
+ }, fn: () => Promise<Response>): Promise<Response>;
58
+ //# sourceMappingURL=idempotency.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../../src/api/idempotency.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC1F,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,gCAAgC,CAAC;AAGxC,eAAO,MAAM,sBAAsB,oBAAoB,CAAC;AACxD,eAAO,MAAM,0BAA0B,MAAM,CAAC;AAC9C,qFAAqF;AACrF,eAAO,MAAM,8BAA8B,QAAa,CAAC;AAEzD,MAAM,WAAW,eAAe;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,cAAc,CAAC;CACzB;AAED,MAAM,MAAM,mBAAmB,GAAG,gBAAgB,CAAC,eAAe,CAAC,CAAC;AAEpE,wBAAgB,yBAAyB,CACxC,KAAK,SAAiC,GACpC,mBAAmB,CAErB;AAID,mFAAmF;AACnF,wBAAgB,0BAA0B,IAAI,mBAAmB,CAEhE;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,CAE/D;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAWlE;AAOD,+CAA+C;AAC/C,wBAAsB,kBAAkB,CACvC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,WAAW,GAAG,MAAM,GACxB,OAAO,CAAC,MAAM,CAAC,CAOjB;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,GAAG,MAAM,CAAC,CAsBrF;AAmBD;;;;GAIG;AACH,wBAAsB,aAAa,CAClC,EACC,KAAK,EACL,QAAQ,EACR,SAAS,EACT,WAAW,EACX,EAAE;IAAE,KAAK,EAAE,mBAAmB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,EAC3F,EAAE,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GACzB,OAAO,CAAC,QAAQ,CAAC,CAiBnB"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `Idempotency-Key` for mounted routes that opt in with `idempotent: true`.
3
+ *
4
+ * Built on the solve route's store and wire contract (`../compute/idempotency*`):
5
+ * the store absorbs in-flight and near-term retries in this process; it is not
6
+ * a durable record. A create that must never duplicate also needs the key on
7
+ * the created row under a unique constraint, written in the same transaction.
8
+ *
9
+ * Each entry carries a fingerprint of method, path and body, so a client that
10
+ * reuses a key for a different request gets 422 instead of someone else's
11
+ * replay. Only responses below 400 are kept: a failed attempt frees the key so
12
+ * a corrected retry can run.
13
+ */
14
+ import { createIdempotencyStore } from '../compute/idempotency.js';
15
+ import { fromStoredResponse, idempotencyKey, toStoredResponse } from '../compute/idempotency-http.js';
16
+ import { apiError, ApiErrorCode } from './errors.js';
17
+ export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
18
+ export const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
19
+ /** Covers a client's retry-on-timeout window without turning into a result cache. */
20
+ export const DEFAULT_API_IDEMPOTENCY_TTL_MS = 5 * 60_000;
21
+ export function createApiIdempotencyStore(ttlMs = DEFAULT_API_IDEMPOTENCY_TTL_MS) {
22
+ return createIdempotencyStore({ ttlMs });
23
+ }
24
+ let sharedDefault;
25
+ /** The store `depsFromConfig` wires when the host passes none. One per process. */
26
+ export function defaultApiIdempotencyStore() {
27
+ return (sharedDefault ??= createApiIdempotencyStore());
28
+ }
29
+ /**
30
+ * The narrowest identity a key is namespaced by: the token for a token
31
+ * request, so two keys of one user never share replays, else the user.
32
+ */
33
+ export function idempotencyCallerId(ctx) {
34
+ return ctx.apiScope ? `token:${ctx.apiScope.tokenId}` : `user:${ctx.userId}`;
35
+ }
36
+ /** Read and validate the client's key. `null` when the header is absent. */
37
+ export function readIdempotencyKey(request) {
38
+ const key = request.headers.get(IDEMPOTENCY_KEY_HEADER);
39
+ if (key === null)
40
+ return null;
41
+ if (key.length === 0 || key.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
42
+ apiError(400, ApiErrorCode.VALIDATION_FAILED, `${IDEMPOTENCY_KEY_HEADER} must be 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters.`);
43
+ }
44
+ return key;
45
+ }
46
+ async function sha256Hex(bytes) {
47
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
48
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
49
+ }
50
+ /** SHA-256 over method, path and body, hex. */
51
+ export async function requestFingerprint(method, path, body) {
52
+ const head = new TextEncoder().encode(`${method.toUpperCase()} ${path}\n`);
53
+ const tail = typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body);
54
+ const bytes = new Uint8Array(head.length + tail.length);
55
+ bytes.set(head);
56
+ bytes.set(tail, head.length);
57
+ return sha256Hex(bytes);
58
+ }
59
+ /**
60
+ * The body `requestFingerprint` should hash, read from a clone so the handler
61
+ * can still consume the original.
62
+ *
63
+ * Multipart is canonicalized to its fields and file contents: clients pick a
64
+ * fresh random boundary per request, so the raw bytes of an identical retry
65
+ * never match.
66
+ */
67
+ export async function fingerprintBody(request) {
68
+ const type = request.headers.get('content-type') ?? '';
69
+ if (!type.toLowerCase().startsWith('multipart/form-data')) {
70
+ return request.clone().arrayBuffer();
71
+ }
72
+ let form;
73
+ try {
74
+ form = await request.clone().formData();
75
+ }
76
+ catch {
77
+ // Malformed: hash the raw bytes and let the handler refuse it.
78
+ return request.clone().arrayBuffer();
79
+ }
80
+ const parts = [];
81
+ for (const [name, value] of form) {
82
+ parts.push([
83
+ name,
84
+ typeof value === 'string'
85
+ ? value
86
+ : { file: value.name, type: value.type, sha256: await sha256Hex(await value.arrayBuffer()) }
87
+ ]);
88
+ }
89
+ return JSON.stringify(parts);
90
+ }
91
+ class NotReplayable {
92
+ fingerprint;
93
+ response;
94
+ constructor(fingerprint, response) {
95
+ this.fingerprint = fingerprint;
96
+ this.response = response;
97
+ }
98
+ }
99
+ function assertSameRequest(stored, fingerprint) {
100
+ if (stored !== fingerprint) {
101
+ apiError(422, ApiErrorCode.UNPROCESSABLE, `This ${IDEMPOTENCY_KEY_HEADER} was already used for a different request.`);
102
+ }
103
+ }
104
+ /**
105
+ * Run `fn` once per `(callerId, clientKey)`, replaying its response to repeats
106
+ * with `Idempotency-Replayed: true`. Throws 422 when the key was used for a
107
+ * different request. Errors `fn` throws propagate and free the key.
108
+ */
109
+ export async function runIdempotent({ store, callerId, clientKey, fingerprint }, fn) {
110
+ let outcome;
111
+ try {
112
+ outcome = await store.run(idempotencyKey(callerId, clientKey), async () => {
113
+ const res = await fn();
114
+ const response = await toStoredResponse(res);
115
+ if (res.status >= 400)
116
+ throw new NotReplayable(fingerprint, response);
117
+ return { fingerprint, response };
118
+ });
119
+ }
120
+ catch (err) {
121
+ if (!(err instanceof NotReplayable))
122
+ throw err;
123
+ // A concurrent retry joining the failed attempt receives the same failure.
124
+ assertSameRequest(err.fingerprint, fingerprint);
125
+ return fromStoredResponse(err.response, false);
126
+ }
127
+ assertSameRequest(outcome.value.fingerprint, fingerprint);
128
+ return fromStoredResponse(outcome.value.response, outcome.replayed);
129
+ }
130
+ //# sourceMappingURL=idempotency.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../../src/api/idempotency.ts"],"names":[],"mappings":"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"}
@@ -5,6 +5,8 @@ export { collection, created, noContent } from './responses.js';
5
5
  export { shaped, shapedCollection } from './shaped.js';
6
6
  export { formText, parseBody, parseParam, requireCaller, requireParams, requireUpload, throwZodError } from './request.js';
7
7
  export { runHandler, toErrorBody, type ApiErrorBody, type RunHandlerOptions } from './respond.js';
8
+ export { createApiRateLimiter, defaultApiRateLimiter, resolveApiRateLimitConfig, chargeApiRateLimit, rateLimitHeaders, rateLimitedResponse, withRateLimitHeaders, DEFAULT_API_RATE_LIMIT, type ApiRateLimiter, type ApiRateLimitConfig, type ApiRateLimitVerdict } from './rate-limit.js';
9
+ export { createApiIdempotencyStore, defaultApiIdempotencyStore, idempotencyCallerId, readIdempotencyKey, requestFingerprint, runIdempotent, IDEMPOTENCY_KEY_HEADER, MAX_IDEMPOTENCY_KEY_LENGTH, DEFAULT_API_IDEMPOTENCY_TTL_MS, type ApiIdempotencyStore, type IdempotentEntry } from './idempotency.js';
8
10
  export { assertScope, actionForMethod, scopeRefusalsToday, projectScopeTarget, definitionScopeTarget } from './scope.js';
9
11
  export { mapCoreError } from './map-core-error.js';
10
12
  export { parseListOptions, parseDefinitionListOptions } from './pagination.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC1F,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EACN,QAAQ,EACR,SAAS,EACT,UAAU,EACV,aAAa,EACb,aAAa,EACb,aAAa,EACb,aAAa,EACb,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAClG,OAAO,EACN,WAAW,EACX,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EACN,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,0BAA0B,EAC1B,6BAA6B,EAC7B,sBAAsB,EACtB,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,uBAAuB,EACvB,8BAA8B,EAC9B,oBAAoB,EACpB,2BAA2B,EAC3B,8BAA8B,EAC9B,yBAAyB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,6BAA6B,EAC7B,KAAK,iBAAiB,EACtB,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC1F,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EACN,QAAQ,EACR,SAAS,EACT,UAAU,EACV,aAAa,EACb,aAAa,EACb,aAAa,EACb,aAAa,EACb,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAClG,OAAO,EACN,oBAAoB,EACpB,qBAAqB,EACrB,yBAAyB,EACzB,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,EACnB,oBAAoB,EACpB,sBAAsB,EACtB,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACN,yBAAyB,EACzB,0BAA0B,EAC1B,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,sBAAsB,EACtB,0BAA0B,EAC1B,8BAA8B,EAC9B,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACN,WAAW,EACX,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EACN,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,0BAA0B,EAC1B,6BAA6B,EAC7B,sBAAsB,EACtB,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,uBAAuB,EACvB,8BAA8B,EAC9B,oBAAoB,EACpB,2BAA2B,EAC3B,8BAA8B,EAC9B,yBAAyB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,6BAA6B,EAC7B,KAAK,iBAAiB,EACtB,MAAM,uBAAuB,CAAC"}
package/dist/api/index.js CHANGED
@@ -8,6 +8,8 @@ export { collection, created, noContent } from './responses.js';
8
8
  export { shaped, shapedCollection } from './shaped.js';
9
9
  export { formText, parseBody, parseParam, requireCaller, requireParams, requireUpload, throwZodError } from './request.js';
10
10
  export { runHandler, toErrorBody } from './respond.js';
11
+ export { createApiRateLimiter, defaultApiRateLimiter, resolveApiRateLimitConfig, chargeApiRateLimit, rateLimitHeaders, rateLimitedResponse, withRateLimitHeaders, DEFAULT_API_RATE_LIMIT } from './rate-limit.js';
12
+ export { createApiIdempotencyStore, defaultApiIdempotencyStore, idempotencyCallerId, readIdempotencyKey, requestFingerprint, runIdempotent, IDEMPOTENCY_KEY_HEADER, MAX_IDEMPOTENCY_KEY_LENGTH, DEFAULT_API_IDEMPOTENCY_TTL_MS } from './idempotency.js';
11
13
  export { assertScope, actionForMethod, scopeRefusalsToday, projectScopeTarget, definitionScopeTarget } from './scope.js';
12
14
  export { mapCoreError } from './map-core-error.js';
13
15
  export { parseListOptions, parseDefinitionListOptions } from './pagination.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,EAAE;AACF,8EAA8E;AAC9E,kEAAkE;AAElE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE1F,OAAO,EAAE,cAAc,EAAkB,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EACN,QAAQ,EACR,SAAS,EACT,UAAU,EACV,aAAa,EACb,aAAa,EACb,aAAa,EACb,aAAa,EACb,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,UAAU,EAAE,WAAW,EAA6C,MAAM,cAAc,CAAC;AAClG,OAAO,EACN,WAAW,EACX,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EACN,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,0BAA0B,EAC1B,6BAA6B,EAC7B,sBAAsB,EACtB,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,uBAAuB,EACvB,8BAA8B,EAC9B,oBAAoB,EACpB,2BAA2B,EAC3B,8BAA8B,EAC9B,yBAAyB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,6BAA6B,EAE7B,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,EAAE;AACF,8EAA8E;AAC9E,kEAAkE;AAElE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE1F,OAAO,EAAE,cAAc,EAAkB,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EACN,QAAQ,EACR,SAAS,EACT,UAAU,EACV,aAAa,EACb,aAAa,EACb,aAAa,EACb,aAAa,EACb,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,UAAU,EAAE,WAAW,EAA6C,MAAM,cAAc,CAAC;AAClG,OAAO,EACN,oBAAoB,EACpB,qBAAqB,EACrB,yBAAyB,EACzB,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,EACnB,oBAAoB,EACpB,sBAAsB,EAItB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACN,yBAAyB,EACzB,0BAA0B,EAC1B,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,sBAAsB,EACtB,0BAA0B,EAC1B,8BAA8B,EAG9B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACN,WAAW,EACX,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EACN,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,0BAA0B,EAC1B,6BAA6B,EAC7B,sBAAsB,EACtB,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,uBAAuB,EACvB,8BAA8B,EAC9B,oBAAoB,EACpB,2BAA2B,EAC3B,8BAA8B,EAC9B,yBAAyB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,6BAA6B,EAE7B,MAAM,uBAAuB,CAAC"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The general per-token request limit `runHandler` applies to every API-token
3
+ * request. Browser sessions are not charged: their abuse surface is the
4
+ * per-user compute bucket and the login limiter, not request volume.
5
+ *
6
+ * Solves charge this bucket and the owner's `user:{id}` compute bucket, so ten
7
+ * keys of one user never buy ten times the compute.
8
+ *
9
+ * The default limiter is process-local (fixed window over
10
+ * `createComputeRateLimiter`), so N instances admit N times the rate. A host
11
+ * that needs one shared budget implements `ApiRateLimiter` over its own store.
12
+ */
13
+ import type { ILogger, RequestContext } from '@selvajs/platform';
14
+ import { type EnvRecord } from '../compute/limits.js';
15
+ export interface ApiRateLimitVerdict {
16
+ allowed: boolean;
17
+ /** Requests admitted per window. */
18
+ limit: number;
19
+ /** Requests left in the current window after this one. */
20
+ remaining: number;
21
+ /** Seconds until the window resets, when refused. */
22
+ retryAfter?: number;
23
+ }
24
+ /** Records one request against `key`. Async so a shared-store implementation fits. */
25
+ export interface ApiRateLimiter {
26
+ hit(key: string): ApiRateLimitVerdict | Promise<ApiRateLimitVerdict>;
27
+ }
28
+ export interface ApiRateLimitConfig {
29
+ windowMs: number;
30
+ /** `0` disables the limit. */
31
+ maxPerWindow: number;
32
+ }
33
+ export declare const DEFAULT_API_RATE_LIMIT: Readonly<ApiRateLimitConfig>;
34
+ /**
35
+ * Read `API_TOKEN_RATE_LIMIT_MAX` (requests per window, `0` = off) and
36
+ * `API_TOKEN_RATE_LIMIT_WINDOW_MS` from an injected env map.
37
+ */
38
+ export declare function resolveApiRateLimitConfig(env: EnvRecord, logger?: ILogger): ApiRateLimitConfig;
39
+ /** Process-local fixed-window limiter. Returns `null` when `maxPerWindow` is 0. */
40
+ export declare function createApiRateLimiter(config?: ApiRateLimitConfig): ApiRateLimiter | null;
41
+ /**
42
+ * The limiter `depsFromConfig` wires when the host passes none. One per
43
+ * process: deps are rebuilt per request, and a limiter per request counts
44
+ * nothing.
45
+ */
46
+ export declare function defaultApiRateLimiter(): ApiRateLimiter | null;
47
+ /**
48
+ * Charge one request to the caller's token. `undefined` for a session caller
49
+ * or when no limiter is wired.
50
+ */
51
+ export declare function chargeApiRateLimit(ctx: RequestContext | undefined, limiter: ApiRateLimiter | null | undefined): Promise<ApiRateLimitVerdict | undefined>;
52
+ export declare function rateLimitHeaders(verdict: ApiRateLimitVerdict): Record<string, string>;
53
+ /** The 429 envelope, with `Retry-After` and the `RateLimit-*` headers. */
54
+ export declare function rateLimitedResponse(verdict: ApiRateLimitVerdict, message?: string): Response;
55
+ /** Stamp the `RateLimit-*` headers onto a response. No-op without a verdict. */
56
+ export declare function withRateLimitHeaders(res: Response, verdict: ApiRateLimitVerdict | undefined): Response;
57
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/api/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEjE,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAG3F,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,sFAAsF;AACtF,MAAM,WAAW,cAAc;IAC9B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACrE;AAED,MAAM,WAAW,kBAAkB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAAC,kBAAkB,CAG9D,CAAC;AAEH;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,kBAAkB,CAe9F;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CACnC,MAAM,GAAE,kBAA2C,GACjD,cAAc,GAAG,IAAI,CAevB;AAID;;;;GAIG;AACH,wBAAgB,qBAAqB,IAAI,cAAc,GAAG,IAAI,CAG7D;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACvC,GAAG,EAAE,cAAc,GAAG,SAAS,EAC/B,OAAO,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,GACxC,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC,CAG1C;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAOrF;AAED,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAClC,OAAO,EAAE,mBAAmB,EAC5B,OAAO,SAAgF,GACrF,QAAQ,CAOV;AAED,gFAAgF;AAChF,wBAAgB,oBAAoB,CACnC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,mBAAmB,GAAG,SAAS,GACtC,QAAQ,CAgBV"}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The general per-token request limit `runHandler` applies to every API-token
3
+ * request. Browser sessions are not charged: their abuse surface is the
4
+ * per-user compute bucket and the login limiter, not request volume.
5
+ *
6
+ * Solves charge this bucket and the owner's `user:{id}` compute bucket, so ten
7
+ * keys of one user never buy ten times the compute.
8
+ *
9
+ * The default limiter is process-local (fixed window over
10
+ * `createComputeRateLimiter`), so N instances admit N times the rate. A host
11
+ * that needs one shared budget implements `ApiRateLimiter` over its own store.
12
+ */
13
+ import { createComputeRateLimiter } from '../compute/rate-limit.js';
14
+ import { readNonNegativeInt, readPositiveInt } from '../compute/limits.js';
15
+ import { ApiError, ApiErrorCode, toErrorBody } from './errors.js';
16
+ export const DEFAULT_API_RATE_LIMIT = Object.freeze({
17
+ windowMs: 60_000,
18
+ maxPerWindow: 600
19
+ });
20
+ /**
21
+ * Read `API_TOKEN_RATE_LIMIT_MAX` (requests per window, `0` = off) and
22
+ * `API_TOKEN_RATE_LIMIT_WINDOW_MS` from an injected env map.
23
+ */
24
+ export function resolveApiRateLimitConfig(env, logger) {
25
+ return {
26
+ windowMs: readPositiveInt(env, 'API_TOKEN_RATE_LIMIT_WINDOW_MS', DEFAULT_API_RATE_LIMIT.windowMs, logger),
27
+ maxPerWindow: readNonNegativeInt(env, 'API_TOKEN_RATE_LIMIT_MAX', DEFAULT_API_RATE_LIMIT.maxPerWindow, logger)
28
+ };
29
+ }
30
+ /** Process-local fixed-window limiter. Returns `null` when `maxPerWindow` is 0. */
31
+ export function createApiRateLimiter(config = DEFAULT_API_RATE_LIMIT) {
32
+ if (config.maxPerWindow <= 0)
33
+ return null;
34
+ const inner = createComputeRateLimiter(config);
35
+ const limit = config.maxPerWindow;
36
+ return {
37
+ hit(key) {
38
+ const result = inner.check(key);
39
+ return {
40
+ allowed: result.allowed,
41
+ limit,
42
+ remaining: Math.max(0, limit - inner.count(key)),
43
+ retryAfter: result.retryAfter
44
+ };
45
+ }
46
+ };
47
+ }
48
+ let sharedDefault;
49
+ /**
50
+ * The limiter `depsFromConfig` wires when the host passes none. One per
51
+ * process: deps are rebuilt per request, and a limiter per request counts
52
+ * nothing.
53
+ */
54
+ export function defaultApiRateLimiter() {
55
+ if (sharedDefault === undefined)
56
+ sharedDefault = createApiRateLimiter();
57
+ return sharedDefault;
58
+ }
59
+ /**
60
+ * Charge one request to the caller's token. `undefined` for a session caller
61
+ * or when no limiter is wired.
62
+ */
63
+ export async function chargeApiRateLimit(ctx, limiter) {
64
+ if (!ctx?.apiScope || !limiter)
65
+ return undefined;
66
+ return limiter.hit(`token:${ctx.apiScope.tokenId}`);
67
+ }
68
+ export function rateLimitHeaders(verdict) {
69
+ const headers = {
70
+ 'RateLimit-Limit': String(verdict.limit),
71
+ 'RateLimit-Remaining': String(verdict.remaining)
72
+ };
73
+ if (!verdict.allowed)
74
+ headers['Retry-After'] = String(verdict.retryAfter ?? 1);
75
+ return headers;
76
+ }
77
+ /** The 429 envelope, with `Retry-After` and the `RateLimit-*` headers. */
78
+ export function rateLimitedResponse(verdict, message = `Too many requests for this API token. Retry in ${verdict.retryAfter ?? 1}s.`) {
79
+ const retryAfter = String(verdict.retryAfter ?? 1);
80
+ const err = new ApiError(429, ApiErrorCode.RATE_LIMITED, message, undefined, { retryAfter });
81
+ return new Response(JSON.stringify(toErrorBody(err)), {
82
+ status: 429,
83
+ headers: { 'content-type': 'application/json', ...rateLimitHeaders(verdict) }
84
+ });
85
+ }
86
+ /** Stamp the `RateLimit-*` headers onto a response. No-op without a verdict. */
87
+ export function withRateLimitHeaders(res, verdict) {
88
+ if (!verdict)
89
+ return res;
90
+ const headers = rateLimitHeaders(verdict);
91
+ try {
92
+ for (const [k, v] of Object.entries(headers))
93
+ res.headers.set(k, v);
94
+ return res;
95
+ }
96
+ catch {
97
+ // A fetched or otherwise immutable response: rewrap rather than drop the headers.
98
+ const copy = new Headers(res.headers);
99
+ for (const [k, v] of Object.entries(headers))
100
+ copy.set(k, v);
101
+ return new Response(res.body, {
102
+ status: res.status,
103
+ statusText: res.statusText,
104
+ headers: copy
105
+ });
106
+ }
107
+ }
108
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../src/api/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAkB,MAAM,sBAAsB,CAAC;AAC3F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuBlE,MAAM,CAAC,MAAM,sBAAsB,GAAiC,MAAM,CAAC,MAAM,CAAC;IACjF,QAAQ,EAAE,MAAM;IAChB,YAAY,EAAE,GAAG;CACjB,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CAAC,GAAc,EAAE,MAAgB;IACzE,OAAO;QACN,QAAQ,EAAE,eAAe,CACxB,GAAG,EACH,gCAAgC,EAChC,sBAAsB,CAAC,QAAQ,EAC/B,MAAM,CACN;QACD,YAAY,EAAE,kBAAkB,CAC/B,GAAG,EACH,0BAA0B,EAC1B,sBAAsB,CAAC,YAAY,EACnC,MAAM,CACN;KACD,CAAC;AACH,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,oBAAoB,CACnC,SAA6B,sBAAsB;IAEnD,IAAI,MAAM,CAAC,YAAY,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1C,MAAM,KAAK,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IAC/C,MAAM,KAAK,GAAG,MAAM,CAAC,YAAY,CAAC;IAClC,OAAO;QACN,GAAG,CAAC,GAAG;YACN,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YAChC,OAAO;gBACN,OAAO,EAAE,MAAM,CAAC,OAAO;gBACvB,KAAK;gBACL,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;gBAChD,UAAU,EAAE,MAAM,CAAC,UAAU;aAC7B,CAAC;QACH,CAAC;KACD,CAAC;AACH,CAAC;AAED,IAAI,aAAgD,CAAC;AAErD;;;;GAIG;AACH,MAAM,UAAU,qBAAqB;IACpC,IAAI,aAAa,KAAK,SAAS;QAAE,aAAa,GAAG,oBAAoB,EAAE,CAAC;IACxE,OAAO,aAAa,CAAC;AACtB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACvC,GAA+B,EAC/B,OAA0C;IAE1C,IAAI,CAAC,GAAG,EAAE,QAAQ,IAAI,CAAC,OAAO;QAAE,OAAO,SAAS,CAAC;IACjD,OAAO,OAAO,CAAC,GAAG,CAAC,SAAS,GAAG,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;AACrD,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,OAA4B;IAC5D,MAAM,OAAO,GAA2B;QACvC,iBAAiB,EAAE,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;QACxC,qBAAqB,EAAE,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC;KAChD,CAAC;IACF,IAAI,CAAC,OAAO,CAAC,OAAO;QAAE,OAAO,CAAC,aAAa,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC;IAC/E,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,mBAAmB,CAClC,OAA4B,EAC5B,OAAO,GAAG,kDAAkD,OAAO,CAAC,UAAU,IAAI,CAAC,IAAI;IAEvF,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC;IACnD,MAAM,GAAG,GAAG,IAAI,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;IAC7F,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,EAAE;QACrD,MAAM,EAAE,GAAG;QACX,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,gBAAgB,CAAC,OAAO,CAAC,EAAE;KAC7E,CAAC,CAAC;AACJ,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,oBAAoB,CACnC,GAAa,EACb,OAAwC;IAExC,IAAI,CAAC,OAAO;QAAE,OAAO,GAAG,CAAC;IACzB,MAAM,OAAO,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;IAC1C,IAAI,CAAC;QACJ,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;YAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACpE,OAAO,GAAG,CAAC;IACZ,CAAC;IAAC,MAAM,CAAC;QACR,kFAAkF;QAClF,MAAM,IAAI,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACtC,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC7D,OAAO,IAAI,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE;YAC7B,MAAM,EAAE,GAAG,CAAC,MAAM;YAClB,UAAU,EAAE,GAAG,CAAC,UAAU;YAC1B,OAAO,EAAE,IAAI;SACb,CAAC,CAAC;IACJ,CAAC;AACF,CAAC"}
@@ -7,16 +7,10 @@
7
7
  * to build an `ApiRequest` and call `runHandler`; one that does not can reuse
8
8
  * `toErrorBody` and serialize itself.
9
9
  */
10
- import { ApiError, ApiErrorCode } from './errors.js';
10
+ import { ApiError } from './errors.js';
11
11
  import type { ApiScopeAction, ScopeTarget } from '@selvajs/platform';
12
12
  import type { ApiHandler, ApiRequest } from './types.js';
13
- export interface ApiErrorBody {
14
- message: string;
15
- code: ApiErrorCode;
16
- fields?: Record<string, string>;
17
- details?: Record<string, string>;
18
- }
19
- export declare function toErrorBody(err: ApiError): ApiErrorBody;
13
+ export { toErrorBody, type ApiErrorBody } from './errors.js';
20
14
  export interface RunHandlerOptions {
21
15
  /** The 500 message when the handler throws something unmapped. */
22
16
  fallback: string;
@@ -36,7 +30,13 @@ export interface RunHandlerOptions {
36
30
  * org-level, which project- and definition-scoped keys never pass.
37
31
  */
38
32
  scopeTarget?: (req: ApiRequest) => ScopeTarget | Promise<ScopeTarget>;
33
+ /**
34
+ * Honour `Idempotency-Key`: a repeat from the same caller replays the first
35
+ * response, and a key reused with a different body gets 422. Opt in per
36
+ * route; what is safe to replay differs.
37
+ */
38
+ idempotent?: boolean;
39
39
  }
40
40
  /** Run a handler and serialize whatever comes out, including failures. */
41
- export declare function runHandler(handler: ApiHandler, req: ApiRequest, { fallback, mapError, action, scopeTarget }: RunHandlerOptions): Promise<Response>;
41
+ export declare function runHandler(handler: ApiHandler, req: ApiRequest, { fallback, mapError, action, scopeTarget, idempotent }: RunHandlerOptions): Promise<Response>;
42
42
  //# sourceMappingURL=respond.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"respond.d.ts","sourceRoot":"","sources":["../../src/api/respond.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAc,MAAM,aAAa,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAe,MAAM,YAAY,CAAC;AAGtE,MAAM,WAAW,YAAY;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,YAAY,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,YAAY,CAKvD;AAcD,MAAM,WAAW,iBAAiB;IACjC,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,QAAQ,GAAG,SAAS,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CACtE;AAED,0EAA0E;AAC1E,wBAAsB,UAAU,CAC/B,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE,UAAU,EACf,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,iBAAiB,GAC5D,OAAO,CAAC,QAAQ,CAAC,CAiCnB"}
1
+ {"version":3,"file":"respond.d.ts","sourceRoot":"","sources":["../../src/api/respond.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,QAAQ,EAAyC,MAAM,aAAa,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAe,MAAM,YAAY,CAAC;AAiBtE,OAAO,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAc7D,MAAM,WAAW,iBAAiB;IACjC,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,QAAQ,GAAG,SAAS,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACtE;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,0EAA0E;AAC1E,wBAAsB,UAAU,CAC/B,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE,UAAU,EACf,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,EAAE,iBAAiB,GACxE,OAAO,CAAC,QAAQ,CAAC,CA4EnB"}
@@ -7,16 +7,11 @@
7
7
  * to build an `ApiRequest` and call `runHandler`; one that does not can reuse
8
8
  * `toErrorBody` and serialize itself.
9
9
  */
10
- import { ApiErrorCode, isApiError } from './errors.js';
10
+ import { ApiErrorCode, isApiError, toErrorBody } from './errors.js';
11
11
  import { actionForMethod, assertScope } from './scope.js';
12
- export function toErrorBody(err) {
13
- const body = { message: err.message, code: err.code };
14
- if (err.fields)
15
- body.fields = err.fields;
16
- if (err.details)
17
- body.details = err.details;
18
- return body;
19
- }
12
+ import { chargeApiRateLimit, defaultApiRateLimiter, rateLimitedResponse, withRateLimitHeaders } from './rate-limit.js';
13
+ import { defaultApiIdempotencyStore, fingerprintBody, idempotencyCallerId, readIdempotencyKey, requestFingerprint, runIdempotent } from './idempotency.js';
14
+ export { toErrorBody } from './errors.js';
20
15
  function toResponse(result) {
21
16
  if (result instanceof Response)
22
17
  return result;
@@ -29,14 +24,8 @@ function toResponse(result) {
29
24
  });
30
25
  }
31
26
  /** Run a handler and serialize whatever comes out, including failures. */
32
- export async function runHandler(handler, req, { fallback, mapError, action, scopeTarget }) {
33
- try {
34
- if (req.ctx?.apiScope) {
35
- assertScope(req.ctx, action ?? actionForMethod(req.request.method), scopeTarget ? await scopeTarget(req) : undefined);
36
- }
37
- return toResponse(await handler(req));
38
- }
39
- catch (err) {
27
+ export async function runHandler(handler, req, { fallback, mapError, action, scopeTarget, idempotent }) {
28
+ const fail = (err) => {
40
29
  if (isApiError(err)) {
41
30
  return toResponse({ status: err.status, body: toErrorBody(err) });
42
31
  }
@@ -55,6 +44,49 @@ export async function runHandler(handler, req, { fallback, mapError, action, sco
55
44
  status: 500,
56
45
  body: { message: fallback, code: ApiErrorCode.INTERNAL }
57
46
  });
47
+ };
48
+ let verdict;
49
+ try {
50
+ // Charged before the scope check: `scopeTarget` may read the database,
51
+ // and a throttled key must not reach it.
52
+ const limiter = req.deps.apiRateLimiter === undefined ? defaultApiRateLimiter() : req.deps.apiRateLimiter;
53
+ verdict = await chargeApiRateLimit(req.ctx, limiter);
54
+ if (verdict && !verdict.allowed)
55
+ return rateLimitedResponse(verdict);
56
+ }
57
+ catch (err) {
58
+ return fail(err);
59
+ }
60
+ let res;
61
+ try {
62
+ if (req.ctx?.apiScope) {
63
+ assertScope(req.ctx, action ?? actionForMethod(req.request.method), scopeTarget ? await scopeTarget(req) : undefined);
64
+ }
65
+ const run = async () => {
66
+ try {
67
+ return toResponse(await handler(req));
68
+ }
69
+ catch (err) {
70
+ return fail(err);
71
+ }
72
+ };
73
+ const clientKey = idempotent && req.ctx ? readIdempotencyKey(req.request) : null;
74
+ if (clientKey && req.ctx) {
75
+ const fingerprint = await requestFingerprint(req.request.method, req.url.pathname + req.url.search, await fingerprintBody(req.request));
76
+ res = await runIdempotent({
77
+ store: req.deps.idempotency ?? defaultApiIdempotencyStore(),
78
+ callerId: idempotencyCallerId(req.ctx),
79
+ clientKey,
80
+ fingerprint
81
+ }, run);
82
+ }
83
+ else {
84
+ res = await run();
85
+ }
86
+ }
87
+ catch (err) {
88
+ res = fail(err);
58
89
  }
90
+ return withRateLimitHeaders(res, verdict);
59
91
  }
60
92
  //# sourceMappingURL=respond.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"respond.js","sourceRoot":"","sources":["../../src/api/respond.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"respond.js","sourceRoot":"","sources":["../../src/api/respond.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/server",
3
- "version": "1.5.0-beta.1",
3
+ "version": "1.5.0-beta.2",
4
4
  "description": "Transport-agnostic server building blocks for apps built on the Selva engine (limits, rate limiting, SSRF guard, remote-definition fetch, definition service + render loader); the solve core lives in @selvajs/solve/server",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -112,8 +112,8 @@
112
112
  "zod": "^4.6.5",
113
113
  "@selvajs/compute": "^4.2.1",
114
114
  "@selvajs/notifications": "^0.3.0-beta.1",
115
- "@selvajs/schemas": "^5.2.0",
116
- "@selvajs/platform": "^0.21.0-beta.1"
115
+ "@selvajs/platform": "^0.21.0-beta.1",
116
+ "@selvajs/schemas": "^5.2.0"
117
117
  },
118
118
  "peerDependencies": {
119
119
  "@sentry/node": ">=8",