lambder 11.1.1 → 12.0.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +18 -13
  3. package/dist/api/LambderApiDeclarations.d.ts +194 -0
  4. package/dist/api/LambderApiDeclarations.js +52 -0
  5. package/dist/api/LambderApiDefinition.d.ts +1 -1
  6. package/dist/api/LambderApiGuards.d.ts +21 -22
  7. package/dist/api/LambderApiGuards.js +1 -1
  8. package/dist/api/LambderApiRateLimits.d.ts +19 -19
  9. package/dist/api/LambderApiRateLimits.js +1 -1
  10. package/dist/api/LambderApiRequest.d.ts +15 -6
  11. package/dist/api/LambderApiRequest.js +8 -9
  12. package/dist/build/generatedTables.d.ts +2 -1
  13. package/dist/build/generatedTables.js +1 -1
  14. package/dist/client/LambderCaller.d.ts +36 -23
  15. package/dist/client/LambderCaller.js +88 -67
  16. package/dist/client/lambderFetchTransport.js +2 -1
  17. package/dist/client.d.ts +3 -1
  18. package/dist/client.js +2 -0
  19. package/dist/core/Lambder.d.ts +203 -87
  20. package/dist/core/Lambder.js +265 -86
  21. package/dist/core/LambderContext.d.ts +1 -1
  22. package/dist/core/LambderContext.js +13 -5
  23. package/dist/core/LambderCreateOptions.d.ts +28 -39
  24. package/dist/core/LambderCreateOptions.js +2 -3
  25. package/dist/index.d.ts +7 -3
  26. package/dist/index.js +2 -0
  27. package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
  28. package/dist/invoke/LambderInvokeCaller.js +110 -94
  29. package/dist/invoke/LambderLambdaEvent.d.ts +0 -1
  30. package/dist/invoke/LambderLambdaEvent.js +0 -1
  31. package/dist/invoke/lambderHandlerTransport.js +2 -1
  32. package/dist/mock/LambderMockApp.d.ts +31 -25
  33. package/dist/mock/LambderMockApp.js +52 -45
  34. package/dist/mock/LambderMockCreateOptions.d.ts +6 -7
  35. package/dist/mock/LambderMockTypes.d.ts +44 -7
  36. package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
  37. package/dist/mock/lambderMockInvokeTransport.js +15 -7
  38. package/dist/mock/lambderMockMswHandler.js +8 -4
  39. package/dist/mock.d.ts +2 -2
  40. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  41. package/dist/shared/transport/LambderApiTransport.js +2 -3
  42. package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
  43. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  44. package/dist/shared/wire/LambderApiContract.d.ts +4 -18
  45. package/dist/shared/wire/LambderApiContract.js +1 -1
  46. package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
  47. package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
  48. package/dist/shared/wire/LambderApiNames.d.ts +55 -0
  49. package/dist/shared/wire/LambderApiNames.js +81 -0
  50. package/dist/shared/wire/LambderUploadSchemas.js +2 -1
  51. package/dist/testing/LambderTestApp.d.ts +5 -1
  52. package/dist/testing/LambderTestVisitor.d.ts +16 -8
  53. package/dist/testing/LambderTestVisitor.js +33 -25
  54. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,143 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [12.0.1] - 2026-09-30
13
+
14
+ A major: endpoints are values. An app declares each endpoint with its
15
+ instance's `defineApi`, gathers them into named groups with `defineApiGroup`,
16
+ and registers every group in one `registerApiGroups()` call. An endpoint's
17
+ guards decide its mode, its name is `group.action`, a call goes to
18
+ `{apiPath}/{group}/{action}`, and every caller reaches it through its group:
19
+ `caller.orders.place(input)`.
20
+
21
+ ### Changed (breaking)
22
+
23
+ - **`addApi` and `addSessionApi` are replaced by `defineApi`,
24
+ `defineApiGroup` and `registerApiGroups`.** `defineApi(options, handler)`
25
+ takes the options and the handler `addApi` took, and registers nothing by
26
+ itself; `defineApiGroup(name, { action: declaration })` gathers a group
27
+ (`defineApiGroup(name, partA, partB)` for one declared across files, an
28
+ action two parts declare being refused); `registerApiGroups(...groups)`
29
+ registers them and returns the instance typed with their contract, one flat
30
+ mapped type. The three builders are arrow properties, so an app hands them
31
+ out with `export const { defineApi, defineApiGroup, lazyApiGroup } =
32
+ lambderApp;`.
33
+ - To move: turn each chain of `addApi`/`addSessionApi` calls into a group of
34
+ `defineApi` declarations and register the groups in one call. Handler
35
+ bodies do not change.
36
+ - **An endpoint's mode is its guards'.** An endpoint declaring a guard that
37
+ needs a session (`session: true`) is a session endpoint: its session is read
38
+ first, a call without one is answered `sessionExpired`, and its handler's
39
+ `ctx.session` is typed present. Every other endpoint is public. A
40
+ per-session rate limit on an endpoint none of whose guards needs a session,
41
+ and a session guard on an instance created without the `session` option,
42
+ are compile errors and registration errors.
43
+ - To move: give an endpoint that was an `addSessionApi` and declares no
44
+ session guard one: a no-op `lambderGuard({ session: true, handler: () => {} })`
45
+ where the session is its whole authorization.
46
+ - **`requireSessionApiGuards` and `requirePublicApiGuards` are replaced by
47
+ `requireApiGuards`**, which makes `guards` a required field of every
48
+ endpoint.
49
+ - **An endpoint is named `group.action`**, both identifiers (a letter, then
50
+ letters, digits or underscores). A group may not take the name of a
51
+ caller's public member or one a runtime asks of any object
52
+ (`LAMBDER_RESERVED_GROUP_NAMES`), nor an action one a function or any
53
+ object answers for (`then`, `call`, `toString`, `valueOf`:
54
+ `LAMBDER_RESERVED_ACTION_NAMES`), so converting a group to a string calls
55
+ no endpoint. The callers keep their state in
56
+ `#private` fields, so a group may take any name their public members leave
57
+ free (`transport`, `client`, `headers`).
58
+ - **A call is `POST {apiPath}/{group}/{action}`**, the envelope in the body
59
+ and no `apiName` in it, so a gateway, a CDN and a log can meter, limit and
60
+ read calls per endpoint. `buildTransportEnvelope` writes no `apiName`, and
61
+ `readApiEnvelope(post, info, apiName)` takes the name from the path. A JSON
62
+ POST to `apiPath` itself naming an endpoint in its body is a page built
63
+ before endpoints had paths, and is answered `versionExpired`, which reloads
64
+ it.
65
+ - To move: an API Gateway route or a CloudFront behavior that matches
66
+ `apiPath` exactly (`POST /api`) now misses every call; widen it to what
67
+ is under it (`/api/*`, `ANY /api/{proxy+}`). A cookie an API handler sets
68
+ with a raw `Set-Cookie` header and no `Path` now defaults to the call's
69
+ directory (`/api/orders`) rather than `/`, in a browser as in the cookie
70
+ jar; `ctx.setCookie` and the session cookies always write `Path=/`.
71
+ - **A request under `apiPath` that nothing matched is the API's**: a GET to a
72
+ call path, or a path of another depth, is answered as an unknown API rather
73
+ than by the public files, the shell or the route fallback. A root `apiPath`
74
+ shares every path with the site, so there only `apiPath` itself and the
75
+ calls are.
76
+ - **The instance takes one type parameter**, `Lambder<TApp, TContract>`,
77
+ where `TApp` is `LambderAppTypes` (the session data, policies, guards,
78
+ idempotency, the guard requirement, sessions and the refusal vocabulary),
79
+ in place of ten positional generics. It is readable as the type-only
80
+ `AppTypes` property beside `ApiContract`.
81
+ - **`use(plugin)` registers routes, hooks and actions**, and hands back the
82
+ same instance: endpoints are registered as groups, so a plugin adds nothing
83
+ to the contract.
84
+ - **A `registerApiGroups()` call's groups take their place in the first-match
85
+ chain where the call stands**, as each `addApi` did: a route registered
86
+ before them sees their calls first, one registered after them never does.
87
+ The beforeRender hooks run before a lazy group loads, and a call to an
88
+ action its group does not have is answered as an unmatched API call, the
89
+ fallback hooks first.
90
+ - **`apiOptionEntries()` is asynchronous**, as `apiSignatures()` and
91
+ `apiSignatureEntries()` are: all three load lazy groups first.
92
+ - **The mock's `publicApi` and `sessionApi` are replaced by `api`, and
93
+ `sessionNotMocked` by `notMocked`.** An entry's mode is the `apiOptions`
94
+ table's when the mock was given it, and otherwise the server's rule applied
95
+ to the guards the entry restates. `LambderMockPublicNames` and
96
+ `LambderMockSessionNames` are removed. Every session endpoint must name at
97
+ least one mock guard declared `session: true`, a compile error at the
98
+ `guards` option otherwise (`LambderMockSessionGuardsCheck`), and a session
99
+ endpoint left unmocked on a mock without the table restates its guards,
100
+ `notMocked(name, { reason, guards })`, so a signed-out call answers
101
+ `sessionExpired` as the server does. `lambderMockInvokeTransport(mockApp,
102
+ { apiPath })` answers what the callee's server reads as a call, a JSON POST
103
+ to a call path under `apiPath` (default `"/api"`), and 404s anything else.
104
+ - **`LambderMergeContract` is removed**, and `LambderAllowedGuardNames`,
105
+ `LambderParamlessGuardNames`, `LambderGuardsOption`,
106
+ `LambderAllowedPolicyNames` and `LambderRateLimitOption` lose their second
107
+ type parameter: every guard and every policy with a `per` may be declared
108
+ on any endpoint, a session guard making it a session one.
109
+
110
+ ### Added
111
+
112
+ - **Every caller reaches an endpoint through its group.**
113
+ `caller.orders.place(input)` is `caller.api("orders.place", input)`, and
114
+ `caller.orders.place.outcome(input)` its `apiOutcome`, on `LambderCaller`,
115
+ `LambderInvokeCaller` and the test visitor alike. The groups are read off
116
+ the contract's type, so a client that imports its contract as a type ships
117
+ no endpoint list. A name that is not `group.action` is refused before it is
118
+ sent.
119
+ - **Lazy groups.** `lazyApiGroup(name, () => import(...).then((m) => m.group))`
120
+ registers a group loaded on the first call to one of its endpoints, so a
121
+ cold start parses none of it until then; `loadApiGroups()` loads every one,
122
+ for a build step or a boot check. A hook that answers a call spares it the
123
+ import; one that throws a refusal, or charges a per-API budget, loads the
124
+ group, so the refusal is checked and the budget counted against the
125
+ endpoint called, as for an eager group. A load that fails is forgotten, and
126
+ the next call tries again.
127
+ - **Compile-time registration checks**: a group typed `any`, a lazy group
128
+ loading one, or a group whose endpoints are (its endpoints would be `any` to
129
+ every client), and a group name given twice, are refused where
130
+ `registerApiGroups()` is written, and a group registration is atomic:
131
+ refused anywhere, it leaves the instance as it was. A lazy group written
132
+ where a list of groups is expected (a `satisfies`, an annotated array)
133
+ keeps its loaded group's type, rather than taking the expected `any`.
134
+ - **`beforeCall` on `LambderInvokeCaller`**: a check run before every call,
135
+ `api()`, `apiOutcome()`, a group's call and `request()` alike, before
136
+ anything is built or sent. What it throws is thrown to the caller, from
137
+ `apiOutcome()` too, and `onFailure` is not told, since nothing was sent. It
138
+ is where a rule the calling code must keep goes, such as never calling out
139
+ while a database transaction is open (`LambderInvokeCallCheck`).
140
+ - **What registering endpoints costs the compiler grows linearly**: each
141
+ declaration is typed on its own, and eight hundred endpoints cost under four
142
+ times what two hundred do, which the type-cost suite holds.
143
+ - Exports: `apiCallPath`, `apiNameOfCallPath`, `splitApiName`, `isGroupName`,
144
+ `isActionName`, `LAMBDER_API_NAME_SEGMENT`, `LAMBDER_CALLER_MEMBER_NAMES`,
145
+ `LAMBDER_RESERVED_GROUP_NAMES`, `LAMBDER_RESERVED_ACTION_NAMES`, and the
146
+ types of declarations, groups and group calls (see
147
+ [Exports](./docs/exports.md#endpoints-declarations-groups-and-names)).
148
+
12
149
  ## [11.1.1] - 2026-09-30
13
150
 
14
151
  A minor for an app whose shell files sit behind a shared cache: a file whose
package/README.md CHANGED
@@ -10,25 +10,30 @@ per-handler boilerplate.
10
10
  import { initLambder, LambderLocalFileSource, LambderDdbSessionStore } from "lambder";
11
11
  import { z } from "zod";
12
12
 
13
- const lambder = initLambder<SessionData>().create({
13
+ const app = initLambder<SessionData>().create({
14
14
  apiPath: "/api",
15
15
  files: new LambderLocalFileSource({ root: "./public" }),
16
16
  session: { store: new LambderDdbSessionStore({ tableName: "app-session", region: "us-east-1" }), sessionSalt: process.env.SESSION_SALT! },
17
- }).addApi("getCompany", {
18
- input: z.object({ slug: z.string() }),
19
- output: z.object({ id: z.string(), name: z.string() }),
20
- }, async ({ apiPayload }) => await loadCompany(apiPayload.slug));
17
+ });
21
18
 
19
+ const companyApis = app.defineApiGroup("companies", {
20
+ get: app.defineApi({
21
+ input: z.object({ slug: z.string() }),
22
+ output: z.object({ id: z.string(), name: z.string() }),
23
+ }, async ({ apiPayload }) => await loadCompany(apiPayload.slug)),
24
+ });
25
+
26
+ export const lambder = app.registerApiGroups(companyApis);
22
27
  export type ApiContractType = typeof lambder.ApiContract;
23
28
  export const handler = lambder.getHandler();
24
29
  ```
25
30
 
26
- A handler returns its output, which is parsed through the output schema before
27
- it is sent, and says no by throwing a refusal: `ctx.refuse()`, typed to the
28
- codes the API declares, or `refuse()` from anywhere. Registration chains onto the
29
- creation call: every `addApi` returns an instance carrying the contract so
30
- far, so the whole backend is one declaration and `lambder.ApiContract` is the
31
- accumulated type.
31
+ An endpoint is a value: declared with `defineApi`, gathered into a named
32
+ group, registered with its groups in one call, and called at
33
+ `/api/companies/get`. A handler returns its output, which is parsed through
34
+ the output schema before it is sent, and says no by throwing a refusal:
35
+ `ctx.refuse()`, typed to the codes the API declares, or `refuse()` from
36
+ anywhere. Its guards say who may call it, and so whether it needs a session.
32
37
 
33
38
  The frontend imports that contract type and gets autocomplete, typed payloads
34
39
  and typed results with no hand-written client:
@@ -38,7 +43,7 @@ import { LambderCaller } from "lambder/client";
38
43
  import type { ApiContractType } from "./backend/handler";
39
44
 
40
45
  const caller = new LambderCaller<ApiContractType>({ apiPath: "/api" });
41
- const company = await caller.api("getCompany", { slug: "acme" });
46
+ const company = await caller.companies.get({ slug: "acme" });
42
47
  ```
43
48
 
44
49
  ## Features
@@ -166,7 +171,7 @@ guide that matches what you are building. The full index lives in
166
171
  | [Getting started](./docs/getting-started.md) | The three-step path from a first API to a typed frontend call |
167
172
  | [Configuration](./docs/configuration.md) | Every `initLambder().create({...})` option, in one reference |
168
173
  | [Routing and actions](./docs/routing.md) | Routes, matchers, hooks, fallbacks, crash reporting, and non-HTTP invocations |
169
- | [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `LambderApiRefusal`, the signature file |
174
+ | [APIs and refusals](./docs/apis.md) | `defineApi`, groups and lazy groups, the mode the guards decide, the inferred contract, `refuse()` and `LambderApiRefusal`, the signature file |
170
175
  | [Responses](./docs/responses.md) | The render context and its response tools (headers, cookies, log entries), the response builder routes and hooks use, compression, ETag and the size cap |
171
176
  | [Sessions](./docs/sessions.md) | Sessions over a store, cookie scope, secrets at rest, `dataRefresh`, the controller API |
172
177
  | [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
@@ -0,0 +1,194 @@
1
+ import type { z } from "zod";
2
+ import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
3
+ import type { LambderApiMode, LambderGuardNamesIn } from "../shared/wire/LambderApiContract.js";
4
+ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue, LambderRefusalsOptionValue } from "../shared/wire/LambderApiOptionValues.js";
5
+ import type { LambderApiRateLimitPolicyConfig, LambderPolicyPerOf } from "./LambderApiRateLimits.js";
6
+ import type { LambderRefusalVocabulary } from "./LambderApiRefusals.js";
7
+ import { type LambderReservedActionName, type LambderReservedGroupName } from "../shared/wire/LambderApiNames.js";
8
+ /**
9
+ * What an instance's declarations are typed against, fixed at create(): the
10
+ * session data, the rate-limit policies, the guards (as LambderGuardMetaMap
11
+ * records them), whether idempotency is configured, whether every endpoint
12
+ * must declare a guard, whether sessions are configured, and the refusal
13
+ * vocabulary with whether every refusal must name a code.
14
+ */
15
+ export type LambderAppTypes = {
16
+ session: unknown;
17
+ policies: Record<string, LambderApiRateLimitPolicyConfig>;
18
+ guards: Record<string, any>;
19
+ idempotency: boolean;
20
+ guardsRequired: boolean;
21
+ sessions: boolean;
22
+ refusals: LambderRefusalVocabulary;
23
+ refusalCodesRequired: boolean;
24
+ };
25
+ /** The types of an instance built without create(): no policies, guards or vocabulary, sessions allowed. */
26
+ export type LambderPlainAppTypes<TSession = any> = {
27
+ session: TSession;
28
+ policies: {};
29
+ guards: {};
30
+ idempotency: false;
31
+ guardsRequired: false;
32
+ sessions: true;
33
+ refusals: {};
34
+ refusalCodesRequired: false;
35
+ };
36
+ /** The guards among the names an endpoint declares that need a session (`session: true`). */
37
+ export type LambderSessionGuardNamesIn<TGuards, TGuardsOpt> = {
38
+ [K in LambderGuardNamesIn<TGuardsOpt> & keyof TGuards]: TGuards[K] extends {
39
+ session: true;
40
+ } ? K : never;
41
+ }[LambderGuardNamesIn<TGuardsOpt> & keyof TGuards];
42
+ /**
43
+ * An endpoint's mode, from the guards it declares: "session" when any of
44
+ * them needs a session, "public" otherwise. The guard is the one place the
45
+ * endpoint says who may call it, and a session is part of that answer, so
46
+ * nothing else states the mode and nothing can disagree with it.
47
+ */
48
+ export type LambderApiModeOf<TGuards, TGuardsOpt> = [
49
+ LambderSessionGuardNamesIn<TGuards, TGuardsOpt>
50
+ ] extends [never] ? "public" : "session";
51
+ /** The per-session policies among the names an endpoint's rateLimit option declares. */
52
+ type LambderSessionPolicyNamesIn<TPolicies, TRateOpt> = {
53
+ [K in LambderGuardNamesIn<TRateOpt> & keyof TPolicies]: [LambderPolicyPerOf<TPolicies[K]>] extends ["session"] ? K : never;
54
+ }[LambderGuardNamesIn<TRateOpt> & keyof TPolicies];
55
+ /**
56
+ * A public endpoint's per-session rate limits, refused on the option: a
57
+ * public call carries no session to count against. The property name is the
58
+ * message and its value the policies at fault.
59
+ */
60
+ export type LambderPublicSessionPolicyCheck<TMode extends LambderApiMode, TPolicies, TRateOpt> = TMode extends "session" ? unknown : [LambderSessionPolicyNamesIn<TPolicies, TRateOpt>] extends [never] ? unknown : {
61
+ rateLimit: {
62
+ readonly "lambder: these rate limits count per session, and none of this API's guards needs a session": LambderSessionPolicyNamesIn<TPolicies, TRateOpt>;
63
+ };
64
+ };
65
+ /** What a session endpoint needs of its instance: sessions configured at create(). */
66
+ type LambderSessionsConfigured = {
67
+ readonly "lambder: a guard of this API needs a session, and sessions are not configured on this instance. Pass the session option to create().": never;
68
+ };
69
+ /** A session endpoint on an instance without sessions, refused where it is declared. */
70
+ export type LambderSessionModeCheck<TMode extends LambderApiMode, TSessions extends boolean> = TMode extends "session" ? (TSessions extends true ? unknown : LambderSessionsConfigured) : unknown;
71
+ /** One endpoint's options as registration reads them. */
72
+ export type LambderApiDeclarationOptions = {
73
+ input: z.ZodType;
74
+ output: z.ZodType;
75
+ guards?: LambderGuardsOptionValue;
76
+ rateLimit?: LambderRateLimitOptionValue;
77
+ idempotency?: LambderApiIdempotencyOption;
78
+ refusals?: LambderRefusalsOptionValue;
79
+ compress?: boolean | "auto";
80
+ };
81
+ /** The key the contract entry of a declaration is typed under. It exists only in types: nothing reads or writes it. */
82
+ declare const LAMBDER_CONTRACT_ENTRY: unique symbol;
83
+ /**
84
+ * One endpoint, declared: its options and its handler, and in its type the
85
+ * contract entry a client calls it by. Built by an instance's defineApi(),
86
+ * registered by being put in a group.
87
+ */
88
+ export type LambderApiDeclaration<TEntry = unknown> = {
89
+ readonly kind: "lambderApi";
90
+ readonly options: LambderApiDeclarationOptions;
91
+ readonly handler: (ctx: never) => MaybePromise<unknown>;
92
+ readonly [LAMBDER_CONTRACT_ENTRY]: TEntry;
93
+ };
94
+ /** A group's endpoints by action name. */
95
+ export type LambderApiDeclarations = Record<string, LambderApiDeclaration<any>>;
96
+ /** A group of endpoints: `name.action` for each of its actions. Built by defineApiGroup(). */
97
+ export type LambderApiGroup<TName extends string = string, TApis extends LambderApiDeclarations = LambderApiDeclarations> = {
98
+ readonly kind: "lambderApiGroup";
99
+ readonly name: TName;
100
+ readonly apis: TApis;
101
+ };
102
+ /**
103
+ * A group loaded on the first call to one of its endpoints: its name, and
104
+ * how to load it. Built by lazyApiGroup(). What a cold start parses then
105
+ * leaves out every group no request has called yet.
106
+ */
107
+ export type LambderLazyApiGroup<TName extends string = string, TGroup extends LambderApiGroup<TName, any> = LambderApiGroup<TName, any>> = {
108
+ readonly kind: "lambderLazyApiGroup";
109
+ readonly name: TName;
110
+ readonly load: () => Promise<TGroup>;
111
+ };
112
+ /** Either kind of group registerApiGroups() takes. */
113
+ export type LambderRegistrableApiGroup = LambderApiGroup<string, any> | LambderLazyApiGroup<string, any>;
114
+ /** A declaration's contract entry. */
115
+ export type LambderEntryOf<TDeclaration> = TDeclaration extends {
116
+ readonly [LAMBDER_CONTRACT_ENTRY]: infer TEntry;
117
+ } ? TEntry : never;
118
+ type LambderLoadedGroupOf<TGroup> = TGroup extends LambderLazyApiGroup<string, infer TLoaded> ? TLoaded : TGroup;
119
+ /**
120
+ * One group's part of the contract: `group.action` for each action, its
121
+ * declaration's entry. Distributed over a union of groups (the `TGroup
122
+ * extends unknown`), so each group is read on its own: read as one union,
123
+ * the actions would be only those every group shares.
124
+ */
125
+ type LambderGroupContract<TGroup> = TGroup extends unknown ? (LambderLoadedGroupOf<TGroup> extends LambderApiGroup<infer TName, infer TApis> ? {
126
+ [TAction in keyof TApis & string as `${TName}.${TAction}`]: LambderEntryOf<TApis[TAction]>;
127
+ } : never) : never;
128
+ type LambderUnionToIntersection<TUnion> = (TUnion extends unknown ? (value: TUnion) => void : never) extends (value: infer TIntersection) => void ? TIntersection : never;
129
+ /** The contract of every group registered together, as one flat object type keyed by endpoint name. */
130
+ export type LambderContractOfGroups<TGroups extends readonly unknown[]> = LambderUnionToIntersection<LambderGroupContract<TGroups[number]>> extends infer TMerged ? {
131
+ [TName in keyof TMerged]: TMerged[TName];
132
+ } : never;
133
+ /**
134
+ * Whether a group, the group a lazy one loads, or that group's endpoints is
135
+ * typed any. A part typed any, merged with the rest, comes out as an index
136
+ * signature rather than as any, and no group declared action by action has
137
+ * one, so that counts too.
138
+ */
139
+ type LambderGroupTypedAny<TGroup> = 0 extends 1 & TGroup ? true : 0 extends 1 & LambderLoadedGroupOf<TGroup> ? true : LambderLoadedGroupOf<TGroup> extends {
140
+ readonly apis: infer TApis;
141
+ } ? (0 extends 1 & TApis ? true : string extends keyof TApis ? true : false) : false;
142
+ /** The group names one registerApiGroups() call names twice. */
143
+ type LambderRepeatedGroupNames<TGroups extends readonly unknown[], TSeen = never> = TGroups extends readonly [infer THead, ...infer TRest] ? (THead extends {
144
+ name: infer TName;
145
+ } ? ([TName] extends [TSeen] ? TName : never) | LambderRepeatedGroupNames<TRest, TSeen | TName> : never) : never;
146
+ /**
147
+ * What registerApiGroups() asks of its receiver: the instance itself, unless
148
+ * a group, what a lazy group loads or a group's endpoints is typed `any` (a
149
+ * module whose types were lost would otherwise make its endpoints `any` to
150
+ * every client) or a group name is given twice (two
151
+ * groups of one name would be one namespace with two owners). Then it asks
152
+ * for an object no instance is, whose property name is the message, so the
153
+ * call is refused where it is written.
154
+ */
155
+ export type LambderRegisteredGroupsCheck<TGroups extends readonly unknown[], TSelf> = true extends {
156
+ [TIndex in keyof TGroups]: LambderGroupTypedAny<TGroups[TIndex]>;
157
+ }[number] ? {
158
+ readonly "lambder: a group given to registerApiGroups, what a lazy group loads, or a group's endpoints is typed any, which would make its part of the contract any. Build it with defineApiGroup() and let its type through.": never;
159
+ } : [LambderRepeatedGroupNames<TGroups>] extends [never] ? TSelf : {
160
+ readonly "lambder: these group names are registered twice": LambderRepeatedGroupNames<TGroups>;
161
+ };
162
+ /** A group's name held to the rule a caller's properties need. */
163
+ export type LambderGroupNameCheck<TName extends string> = TName extends LambderReservedGroupName ? {
164
+ readonly "lambder: a caller already has a member of this name, so no group may take it": TName;
165
+ } : unknown;
166
+ /** The actions more than one of a group's parts declare. */
167
+ type LambderRepeatedActions<TParts extends readonly unknown[], TSeen = never> = TParts extends readonly [infer THead, ...infer TRest] ? (Extract<keyof THead, TSeen> | LambderRepeatedActions<TRest, TSeen | keyof THead>) : never;
168
+ /** A group's parts held to declaring each action once: a part would otherwise replace another's endpoint of the same name. */
169
+ export type LambderGroupPartsCheck<TParts extends readonly unknown[]> = [
170
+ LambderRepeatedActions<TParts>
171
+ ] extends [never] ? unknown : {
172
+ readonly "lambder: these actions are declared by more than one part of the group": LambderRepeatedActions<TParts>;
173
+ };
174
+ /** One group's endpoints, from the parts it was declared in. */
175
+ export type LambderMergedParts<TParts extends readonly unknown[]> = LambderUnionToIntersection<TParts[number]> extends infer TMerged ? {
176
+ [TAction in keyof TMerged]: TMerged[TAction];
177
+ } : never;
178
+ /** A group's action names held to the rule the functions a caller hands out need. */
179
+ export type LambderActionNamesCheck<TApis> = [
180
+ Extract<keyof TApis, LambderReservedActionName>
181
+ ] extends [never] ? unknown : {
182
+ readonly "lambder: a function already has a member of these names, so no action may take them": Extract<keyof TApis, LambderReservedActionName>;
183
+ };
184
+ /**
185
+ * Builds a group from its parts, refusing a name no caller could carry, an
186
+ * action two parts declare, and a value that is not an endpoint. A group is
187
+ * usually one part; one declared across files gives each file's part.
188
+ */
189
+ export declare const buildApiGroup: <TName extends string, TApis extends LambderApiDeclarations>(name: TName, parts: readonly LambderApiDeclarations[]) => LambderApiGroup<TName, TApis>;
190
+ /** Builds a lazy group; the name is checked here, the group it loads when it loads. */
191
+ export declare const buildLazyApiGroup: <TName extends string, TGroup extends LambderApiGroup<TName, any>>(name: TName, load: () => Promise<TGroup>) => LambderLazyApiGroup<TName, TGroup>;
192
+ /** Whether a value is either kind of group registerApiGroups() takes. */
193
+ export declare const isRegistrableApiGroup: (value: unknown) => value is LambderRegistrableApiGroup;
194
+ export {};
@@ -0,0 +1,52 @@
1
+ import { isActionName, isGroupName, LAMBDER_API_NAME_SEGMENT } from "../shared/wire/LambderApiNames.js";
2
+ const describeName = (name) => typeof name === "string" ? `"${name}"` : String(name);
3
+ /**
4
+ * Builds a group from its parts, refusing a name no caller could carry, an
5
+ * action two parts declare, and a value that is not an endpoint. A group is
6
+ * usually one part; one declared across files gives each file's part.
7
+ */
8
+ export const buildApiGroup = (name, parts) => {
9
+ if (typeof name !== "string" || !isGroupName(name)) {
10
+ throw new Error(`Lambder: ${describeName(name)} cannot name a group. A group is a letter then letters, digits or underscores, ` +
11
+ `and not a name a caller already answers for (api, apiOutcome, then, ...).`);
12
+ }
13
+ const apis = {};
14
+ for (const part of parts) {
15
+ if (part === null || typeof part !== "object") {
16
+ throw new Error(`Lambder: a part of group "${name}" is not an object of endpoints by action name.`);
17
+ }
18
+ for (const [action, declaration] of Object.entries(part)) {
19
+ if (Object.prototype.hasOwnProperty.call(apis, action)) {
20
+ throw new Error(`Lambder: "${name}.${action}" is declared by two parts of group "${name}". Each action is declared once.`);
21
+ }
22
+ apis[action] = declaration;
23
+ }
24
+ }
25
+ if (Object.keys(apis).length === 0) {
26
+ throw new Error(`Lambder: group "${name}" declares no endpoints. A group is its endpoints by action name: defineApiGroup("${name}", { action: defineApi(...) }).`);
27
+ }
28
+ for (const [action, declaration] of Object.entries(apis)) {
29
+ if (!isActionName(action)) {
30
+ throw new Error(`Lambder: "${name}.${action}" cannot name an endpoint. An action is a letter then letters, digits or underscores, ` +
31
+ `and not a name a function already answers for (then, outcome, call, ...).`);
32
+ }
33
+ if (declaration?.kind !== "lambderApi") {
34
+ throw new Error(`Lambder: "${name}.${action}" is not an endpoint declaration. Declare it with the instance's defineApi().`);
35
+ }
36
+ }
37
+ return { kind: "lambderApiGroup", name, apis: apis };
38
+ };
39
+ /** Builds a lazy group; the name is checked here, the group it loads when it loads. */
40
+ export const buildLazyApiGroup = (name, load) => {
41
+ if (typeof name !== "string" || !isGroupName(name)) {
42
+ throw new Error(`Lambder: ${describeName(name)} cannot name a group. A group is a letter then letters, digits or underscores, and not a name a caller already answers for.`);
43
+ }
44
+ if (typeof load !== "function")
45
+ throw new Error(`Lambder: lazy group "${name}" has no loader. Pass a function that imports the group: () => import("./orders.js").then((m) => m.orderApis).`);
46
+ return { kind: "lambderLazyApiGroup", name, load };
47
+ };
48
+ /** Whether a value is either kind of group registerApiGroups() takes. */
49
+ export const isRegistrableApiGroup = (value) => {
50
+ const kind = value?.kind;
51
+ return (kind === "lambderApiGroup" || kind === "lambderLazyApiGroup") && typeof value.name === "string" && LAMBDER_API_NAME_SEGMENT.test(value.name);
52
+ };
@@ -4,7 +4,7 @@ import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
4
4
  import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "../shared/wire/LambderApiOptionValues.js";
5
5
  /**
6
6
  * One endpoint's declaration as the pipeline runs it: what the server's
7
- * addApi/addSessionApi options carry, minus the handler, in a shape the mock
7
+ * defineApi options carry, minus the handler, in a shape the mock
8
8
  * runtime can restate from a type-only contract. The schemas are optional
9
9
  * because the mock has none; when input is present, validation runs and the
10
10
  * handler sees the parsed payload. Output is part of the endpoint's
@@ -72,9 +72,11 @@ export type LambderGuardRefusals<TRefusals extends readonly string[] = readonly
72
72
  *
73
73
  * Orthogonally, a guard may also:
74
74
  *
75
- * - declare `session: true`: the guard needs ctx.session, so it is only
76
- * declarable on addSessionApi (compile error and startup assert on public
77
- * APIs) and its handler receives the session-typed context.
75
+ * - declare `session: true`: the guard needs ctx.session, so it makes every
76
+ * endpoint declaring it a session endpoint (the session is read before the
77
+ * guards run, a call without one is answered sessionExpired, and the
78
+ * endpoint's handler receives the session-typed context), and it receives
79
+ * the session-typed context itself.
78
80
  * - take a PARAMETER: annotate a 3rd handler argument
79
81
  * (`(ctx, payload, param: YourType) => ...`) and APIs pass the value in
80
82
  * their declaration: `guards: { yourGuard: paramValue }`. The value is
@@ -272,19 +274,16 @@ export type LambderGuardMetaMap<TGuards> = {
272
274
  [K in keyof TGuards]: LambderGuardMeta<TGuards[K]>;
273
275
  };
274
276
  /**
275
- * Guard names an API may declare: every guard the instance holds, session
276
- * guards only on session APIs. Whether the API's input carries the fields an
277
- * apiInput guard reads is asked of the names it declared, once that input is
278
- * known, by LambderGuardNamesInputLacks.
277
+ * Guard names an API may declare: every guard the instance holds, a session
278
+ * guard included, which makes the API a session one (LambderApiModeOf).
279
+ * Whether the API's input carries the fields an apiInput guard reads is
280
+ * asked of the names it declared, once that input is known, by
281
+ * LambderGuardNamesInputLacks.
279
282
  */
280
- export type LambderAllowedGuardNames<TGuards, TIncludeSession extends boolean = true> = {
281
- [K in keyof TGuards]: TGuards[K] extends {
282
- session: true;
283
- } ? (TIncludeSession extends true ? K : never) : K;
284
- }[keyof TGuards] & string;
283
+ export type LambderAllowedGuardNames<TGuards> = keyof TGuards & string;
285
284
  /**
286
285
  * The apiInput guards among `TNames` whose fields the API's payload does not
287
- * carry. addApi and addSessionApi ask this once the API's input is known, of
286
+ * carry. defineApi asks this once the API's input is known, of
288
287
  * the names its guards option holds; see LambderPayloadSliceCheck.
289
288
  *
290
289
  * The payload is compared whole, not member by member: a bare `TPayload
@@ -298,14 +297,14 @@ export type LambderGuardNamesInputLacks<TGuards, TNames, TPayload> = {
298
297
  } ? ([TPayload] extends [R] ? never : K) : never;
299
298
  }[TNames & keyof TGuards] & string;
300
299
  /** The allowed guard names whose handler takes no param (usable in the string/array forms). */
301
- export type LambderParamlessGuardNames<TGuards, TIncludeSession extends boolean> = {
302
- [K in LambderAllowedGuardNames<TGuards, TIncludeSession> & keyof TGuards]: TGuards[K] extends {
300
+ export type LambderParamlessGuardNames<TGuards> = {
301
+ [K in LambderAllowedGuardNames<TGuards> & keyof TGuards]: TGuards[K] extends {
303
302
  param: undefined;
304
303
  } ? K & string : never;
305
- }[LambderAllowedGuardNames<TGuards, TIncludeSession> & keyof TGuards];
304
+ }[LambderAllowedGuardNames<TGuards> & keyof TGuards];
306
305
  /** The map form's full shape: every declarable guard name, each carrying its own param type. */
307
- type LambderGuardsMap<TGuards, TIncludeSession extends boolean> = {
308
- readonly [K in LambderAllowedGuardNames<TGuards, TIncludeSession> & keyof TGuards]?: TGuards[K] extends {
306
+ type LambderGuardsMap<TGuards> = {
307
+ readonly [K in LambderAllowedGuardNames<TGuards> & keyof TGuards]?: TGuards[K] extends {
309
308
  param: undefined;
310
309
  } ? true : TGuards[K] extends {
311
310
  param: infer P;
@@ -324,10 +323,10 @@ type LambderGuardsMap<TGuards, TIncludeSession extends boolean> = {
324
323
  * compiler builds it once per instance rather than once per API; see
325
324
  * LambderPayloadSliceCheck for why, and for where the input is asked.
326
325
  */
327
- export type LambderGuardsOption<TGuards, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TIncludeSession> | readonly [
328
- LambderParamlessGuardNames<TGuards, TIncludeSession>,
329
- ...LambderParamlessGuardNames<TGuards, TIncludeSession>[]
330
- ] | LambderNonEmptyOptionMap<LambderGuardsMap<TGuards, TIncludeSession>>;
326
+ export type LambderGuardsOption<TGuards> = LambderParamlessGuardNames<TGuards> | readonly [
327
+ LambderParamlessGuardNames<TGuards>,
328
+ ...LambderParamlessGuardNames<TGuards>[]
329
+ ] | LambderNonEmptyOptionMap<LambderGuardsMap<TGuards>>;
331
330
  /**
332
331
  * The typed ctx.guardData an API's handler sees: declared guards that return
333
332
  * a value, keyed by name. Check-only (void) guards never appear.
@@ -101,7 +101,7 @@ export class LambderApiGuardsEngine {
101
101
  throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Declare it in the guards option at creation.`);
102
102
  }
103
103
  if (guardDef.session && mode !== "session") {
104
- throw new Error(`Lambder: API "${apiName}" uses guard "${name}" (session: true), which requires addSessionApi.`);
104
+ throw new Error(`Lambder: API "${apiName}" uses guard "${name}" (session: true) but is registered as public. An endpoint declaring a session guard is a session endpoint.`);
105
105
  }
106
106
  }
107
107
  }
@@ -116,7 +116,7 @@ export type LambderApiRateLimitPolicyConfig<TCtx = any> = LambderRateLimitPolicy
116
116
  export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig<any>>> = {
117
117
  /** Your limiter instance (LambderDdbRateLimiter, LambderMemoryRateLimiter, or your own); its table and keyPrefix apply as configured on it. */
118
118
  limiter: LambderRateLimiter;
119
- /** Named policies referenced (typed) from addApi/addSessionApi. */
119
+ /** Named policies referenced (typed) from defineApi. */
120
120
  policies: TPolicies;
121
121
  /**
122
122
  * Let the request through when the limiter itself fails (the table is
@@ -144,23 +144,23 @@ export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderA
144
144
  * without one, and a union holding undefined for a policy typed as the
145
145
  * general LambderApiRateLimitPolicyConfig, where only registration can tell.
146
146
  */
147
- type LambderPolicyPerOf<TPolicy> = "per" extends keyof TPolicy ? TPolicy["per" & keyof TPolicy] : undefined;
147
+ export type LambderPolicyPerOf<TPolicy> = "per" extends keyof TPolicy ? TPolicy["per" & keyof TPolicy] : undefined;
148
148
  /**
149
- * Policy names an API may reference: session-keyed policies only on session
150
- * APIs, and never a policy without `per`, whose key only the code that
151
- * charges it knows. A policy whose type does not settle its `per` is allowed
152
- * here and checked at registration. Whether the API's input carries the
153
- * fields an apiInput-keyed policy reads is asked of the names it declared,
154
- * once that input is known, by LambderPolicyNamesInputLacks.
149
+ * Policy names an API may reference: never a policy without `per`, whose key
150
+ * only the code that charges it knows. A policy whose type does not settle
151
+ * its `per` is allowed here and checked at registration. A session-keyed
152
+ * policy is allowed here too, and refused of an API none of whose guards
153
+ * needs a session by LambderPublicSessionPolicyCheck, once its guards are
154
+ * known. Whether the API's input carries the fields an apiInput-keyed policy
155
+ * reads is asked of the names it declared, once that input is known, by
156
+ * LambderPolicyNamesInputLacks.
155
157
  */
156
- export type LambderAllowedPolicyNames<TPolicies, TIncludeSession extends boolean> = {
157
- [K in keyof TPolicies]: [
158
- LambderPolicyPerOf<TPolicies[K]>
159
- ] extends [undefined] ? never : [LambderPolicyPerOf<TPolicies[K]>] extends ["session"] ? (TIncludeSession extends true ? K : never) : K;
158
+ export type LambderAllowedPolicyNames<TPolicies> = {
159
+ [K in keyof TPolicies]: [LambderPolicyPerOf<TPolicies[K]>] extends [undefined] ? never : K;
160
160
  }[keyof TPolicies] & string;
161
161
  /**
162
162
  * The apiInput-keyed policies among `TNames` whose key fields the API's
163
- * payload does not carry. addApi and addSessionApi ask this once the API's
163
+ * payload does not carry. defineApi asks this once the API's
164
164
  * input is known, of the names its rateLimit option holds; see
165
165
  * LambderPayloadSliceCheck.
166
166
  *
@@ -235,8 +235,8 @@ type LambderRateLimitOverrideFor<TPolicy> = TPolicy extends {
235
235
  budget: "perPolicy";
236
236
  } ? Pick<LambderRateLimitOverride, "refusal"> : LambderRateLimitOverride;
237
237
  /** The map form's full shape: every referable policy name, each carrying its own override. */
238
- type LambderRateLimitMap<TPolicies, TIncludeSession extends boolean> = {
239
- readonly [K in LambderAllowedPolicyNames<TPolicies, TIncludeSession> & keyof TPolicies]?: true | LambderRateLimitOverrideFor<TPolicies[K]>;
238
+ type LambderRateLimitMap<TPolicies> = {
239
+ readonly [K in LambderAllowedPolicyNames<TPolicies> & keyof TPolicies]?: true | LambderRateLimitOverrideFor<TPolicies[K]>;
240
240
  };
241
241
  /**
242
242
  * The per-API `rateLimit` option: one policy name, a non-empty ordered list
@@ -251,10 +251,10 @@ type LambderRateLimitMap<TPolicies, TIncludeSession extends boolean> = {
251
251
  * It depends on the instance alone, never on the API's input, as
252
252
  * LambderGuardsOption does; see LambderPayloadSliceCheck.
253
253
  */
254
- export type LambderRateLimitOption<TPolicies, TIncludeSession extends boolean> = LambderAllowedPolicyNames<TPolicies, TIncludeSession> | readonly [
255
- LambderAllowedPolicyNames<TPolicies, TIncludeSession>,
256
- ...LambderAllowedPolicyNames<TPolicies, TIncludeSession>[]
257
- ] | LambderNonEmptyOptionMap<LambderRateLimitMap<TPolicies, TIncludeSession>>;
254
+ export type LambderRateLimitOption<TPolicies> = LambderAllowedPolicyNames<TPolicies> | readonly [
255
+ LambderAllowedPolicyNames<TPolicies>,
256
+ ...LambderAllowedPolicyNames<TPolicies>[]
257
+ ] | LambderNonEmptyOptionMap<LambderRateLimitMap<TPolicies>>;
258
258
  /**
259
259
  * When in a call a policy is checked.
260
260
  *
@@ -209,7 +209,7 @@ export class LambderApiRateLimitsEngine {
209
209
  throw new Error(`Lambder: API "${apiName}" references rate-limit policy "${name}", which declares no per: its key is the one a handler passes to ctx.rateLimit("${name}", key), so the request alone cannot be counted against it.`);
210
210
  }
211
211
  if (policy.per === "session" && mode !== "session") {
212
- throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
212
+ throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which counts per session, and none of its guards needs a session.`);
213
213
  }
214
214
  if (override)
215
215
  assertWindowLimits(`API "${apiName}" override of rate-limit policy "${name}"`, override);