@zmdb/web 1.0.0-beta.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 (179) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +49 -0
  3. package/dist/app/bridge.d.ts +12 -0
  4. package/dist/app/bridge.d.ts.map +1 -0
  5. package/dist/app/bridge.js +16 -0
  6. package/dist/app/bridge.js.map +1 -0
  7. package/dist/app/index.d.ts +23 -0
  8. package/dist/app/index.d.ts.map +1 -0
  9. package/dist/app/index.js +47 -0
  10. package/dist/app/index.js.map +1 -0
  11. package/dist/bench/index.d.ts +68 -0
  12. package/dist/bench/index.d.ts.map +1 -0
  13. package/dist/bench/index.js +250 -0
  14. package/dist/bench/index.js.map +1 -0
  15. package/dist/compression/index.d.ts +13 -0
  16. package/dist/compression/index.d.ts.map +1 -0
  17. package/dist/compression/index.js +211 -0
  18. package/dist/compression/index.js.map +1 -0
  19. package/dist/context/index.d.ts +74 -0
  20. package/dist/context/index.d.ts.map +1 -0
  21. package/dist/context/index.js +118 -0
  22. package/dist/context/index.js.map +1 -0
  23. package/dist/contract/__fixtures__/http-client.generated.d.ts +70 -0
  24. package/dist/contract/__fixtures__/http-client.generated.d.ts.map +1 -0
  25. package/dist/contract/__fixtures__/http-client.generated.js +86 -0
  26. package/dist/contract/__fixtures__/http-client.generated.js.map +1 -0
  27. package/dist/contract/__fixtures__/http-convergence.d.ts +396 -0
  28. package/dist/contract/__fixtures__/http-convergence.d.ts.map +1 -0
  29. package/dist/contract/__fixtures__/http-convergence.js +425 -0
  30. package/dist/contract/__fixtures__/http-convergence.js.map +1 -0
  31. package/dist/contract/compiler/client.d.ts +12 -0
  32. package/dist/contract/compiler/client.d.ts.map +1 -0
  33. package/dist/contract/compiler/client.js +1293 -0
  34. package/dist/contract/compiler/client.js.map +1 -0
  35. package/dist/contract/compiler/index.d.ts +18 -0
  36. package/dist/contract/compiler/index.d.ts.map +1 -0
  37. package/dist/contract/compiler/index.js +1058 -0
  38. package/dist/contract/compiler/index.js.map +1 -0
  39. package/dist/contract/index.d.ts +237 -0
  40. package/dist/contract/index.d.ts.map +1 -0
  41. package/dist/contract/index.js +22 -0
  42. package/dist/contract/index.js.map +1 -0
  43. package/dist/csrf/index.d.ts +15 -0
  44. package/dist/csrf/index.d.ts.map +1 -0
  45. package/dist/csrf/index.js +218 -0
  46. package/dist/csrf/index.js.map +1 -0
  47. package/dist/data/index.d.ts +35 -0
  48. package/dist/data/index.d.ts.map +1 -0
  49. package/dist/data/index.js +58 -0
  50. package/dist/data/index.js.map +1 -0
  51. package/dist/devtools/index.d.ts +74 -0
  52. package/dist/devtools/index.d.ts.map +1 -0
  53. package/dist/devtools/index.js +552 -0
  54. package/dist/devtools/index.js.map +1 -0
  55. package/dist/dto-pipes/index.d.ts +45 -0
  56. package/dist/dto-pipes/index.d.ts.map +1 -0
  57. package/dist/dto-pipes/index.js +78 -0
  58. package/dist/dto-pipes/index.js.map +1 -0
  59. package/dist/gateways/index.d.ts +31 -0
  60. package/dist/gateways/index.d.ts.map +1 -0
  61. package/dist/gateways/index.js +138 -0
  62. package/dist/gateways/index.js.map +1 -0
  63. package/dist/health/index.d.ts +20 -0
  64. package/dist/health/index.d.ts.map +1 -0
  65. package/dist/health/index.js +148 -0
  66. package/dist/health/index.js.map +1 -0
  67. package/dist/http-token.d.ts +2 -0
  68. package/dist/http-token.d.ts.map +1 -0
  69. package/dist/http-token.js +21 -0
  70. package/dist/http-token.js.map +1 -0
  71. package/dist/index.d.ts +18 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +40 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/integrations/index.d.ts +11 -0
  76. package/dist/integrations/index.d.ts.map +1 -0
  77. package/dist/integrations/index.js +39 -0
  78. package/dist/integrations/index.js.map +1 -0
  79. package/dist/middleware/errors.d.ts +14 -0
  80. package/dist/middleware/errors.d.ts.map +1 -0
  81. package/dist/middleware/errors.js +18 -0
  82. package/dist/middleware/errors.js.map +1 -0
  83. package/dist/middleware/index.d.ts +46 -0
  84. package/dist/middleware/index.d.ts.map +1 -0
  85. package/dist/middleware/index.js +64 -0
  86. package/dist/middleware/index.js.map +1 -0
  87. package/dist/modules/__fixtures__/large-graph.d.ts +239 -0
  88. package/dist/modules/__fixtures__/large-graph.d.ts.map +1 -0
  89. package/dist/modules/__fixtures__/large-graph.js +483 -0
  90. package/dist/modules/__fixtures__/large-graph.js.map +1 -0
  91. package/dist/openapi/__fixtures__/entities.d.ts +8 -0
  92. package/dist/openapi/__fixtures__/entities.d.ts.map +1 -0
  93. package/dist/openapi/__fixtures__/entities.js +4 -0
  94. package/dist/openapi/__fixtures__/entities.js.map +1 -0
  95. package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts +38 -0
  96. package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts.map +1 -0
  97. package/dist/openapi/__fixtures__/openapi-tools.fixture.js +172 -0
  98. package/dist/openapi/__fixtures__/openapi-tools.fixture.js.map +1 -0
  99. package/dist/openapi/__fixtures__/route-schemas.d.ts +2 -0
  100. package/dist/openapi/__fixtures__/route-schemas.d.ts.map +1 -0
  101. package/dist/openapi/__fixtures__/route-schemas.js +17 -0
  102. package/dist/openapi/__fixtures__/route-schemas.js.map +1 -0
  103. package/dist/openapi/index.d.ts +61 -0
  104. package/dist/openapi/index.d.ts.map +1 -0
  105. package/dist/openapi/index.js +314 -0
  106. package/dist/openapi/index.js.map +1 -0
  107. package/dist/pipeline/guards.d.ts +9 -0
  108. package/dist/pipeline/guards.d.ts.map +1 -0
  109. package/dist/pipeline/guards.js +11 -0
  110. package/dist/pipeline/guards.js.map +1 -0
  111. package/dist/pipeline/index.d.ts +141 -0
  112. package/dist/pipeline/index.d.ts.map +1 -0
  113. package/dist/pipeline/index.js +1624 -0
  114. package/dist/pipeline/index.js.map +1 -0
  115. package/dist/routing/index.d.ts +40 -0
  116. package/dist/routing/index.d.ts.map +1 -0
  117. package/dist/routing/index.js +162 -0
  118. package/dist/routing/index.js.map +1 -0
  119. package/dist/static/index.d.ts +14 -0
  120. package/dist/static/index.d.ts.map +1 -0
  121. package/dist/static/index.js +348 -0
  122. package/dist/static/index.js.map +1 -0
  123. package/dist/testing/index.d.ts +20 -0
  124. package/dist/testing/index.d.ts.map +1 -0
  125. package/dist/testing/index.js +32 -0
  126. package/dist/testing/index.js.map +1 -0
  127. package/dist/traced-router.d.ts +5 -0
  128. package/dist/traced-router.d.ts.map +1 -0
  129. package/dist/traced-router.js +6 -0
  130. package/dist/traced-router.js.map +1 -0
  131. package/dist/upload/index.d.ts +28 -0
  132. package/dist/upload/index.d.ts.map +1 -0
  133. package/dist/upload/index.js +410 -0
  134. package/dist/upload/index.js.map +1 -0
  135. package/dist/versioning/index.d.ts +35 -0
  136. package/dist/versioning/index.d.ts.map +1 -0
  137. package/dist/versioning/index.js +66 -0
  138. package/dist/versioning/index.js.map +1 -0
  139. package/dist/versioning/runtime.d.ts +5 -0
  140. package/dist/versioning/runtime.d.ts.map +1 -0
  141. package/dist/versioning/runtime.js +34 -0
  142. package/dist/versioning/runtime.js.map +1 -0
  143. package/package.json +143 -0
  144. package/src/app/bridge.ts +39 -0
  145. package/src/app/index.ts +76 -0
  146. package/src/bench/index.ts +329 -0
  147. package/src/compression/index.ts +255 -0
  148. package/src/context/index.ts +181 -0
  149. package/src/contract/__fixtures__/http-client.generated.ts +73 -0
  150. package/src/contract/__fixtures__/http-client.generated.ts.map +1 -0
  151. package/src/contract/__fixtures__/http-convergence.ts +564 -0
  152. package/src/contract/compiler/client.ts +1518 -0
  153. package/src/contract/compiler/index.ts +1457 -0
  154. package/src/contract/index.ts +266 -0
  155. package/src/csrf/index.ts +264 -0
  156. package/src/data/index.ts +65 -0
  157. package/src/devtools/index.ts +727 -0
  158. package/src/dto-pipes/index.ts +93 -0
  159. package/src/gateways/index.ts +180 -0
  160. package/src/health/index.ts +198 -0
  161. package/src/http-token.ts +22 -0
  162. package/src/index.ts +167 -0
  163. package/src/integrations/index.ts +50 -0
  164. package/src/middleware/errors.ts +17 -0
  165. package/src/middleware/index.ts +108 -0
  166. package/src/modules/__fixtures__/large-graph.ts +540 -0
  167. package/src/openapi/__fixtures__/entities.ts +19 -0
  168. package/src/openapi/__fixtures__/openapi-tools.fixture.ts +212 -0
  169. package/src/openapi/__fixtures__/route-schemas.ts +27 -0
  170. package/src/openapi/index.ts +483 -0
  171. package/src/pipeline/guards.ts +23 -0
  172. package/src/pipeline/index.ts +2041 -0
  173. package/src/routing/index.ts +201 -0
  174. package/src/static/index.ts +443 -0
  175. package/src/testing/index.ts +58 -0
  176. package/src/traced-router.ts +8 -0
  177. package/src/upload/index.ts +489 -0
  178. package/src/versioning/index.ts +106 -0
  179. package/src/versioning/runtime.ts +38 -0
@@ -0,0 +1,266 @@
1
+ // @zmdb/web/contract — inert HTTP declarations and the serialisable contract IR.
2
+ //
3
+ // This runtime entry deliberately imports only types. The compiler-backed collector
4
+ // lives at ./compiler so an application that only registers an already-compiled
5
+ // contract never reaches TypeScript, the filesystem, or a reflection session.
6
+
7
+ import { type JsonValue as SchemaJsonValue, type TypeIR } from '@zmdb/schema/ir';
8
+
9
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
10
+ export type JsonValue = SchemaJsonValue;
11
+
12
+ /** A controller class recorded by a declaration and instantiated by the application. */
13
+ export type HttpController = abstract new (...args: never[]) => object;
14
+
15
+ export interface HttpOperationTypes {
16
+ readonly path?: Readonly<Record<string, unknown>>;
17
+ readonly query?: Readonly<Record<string, unknown>>;
18
+ readonly headers?: Readonly<Record<string, unknown>>;
19
+ readonly cookies?: Readonly<Record<string, unknown>>;
20
+ readonly body?: unknown;
21
+ readonly responses: Readonly<
22
+ Record<number, { readonly body: unknown; readonly headers?: Readonly<Record<string, unknown>> }>
23
+ >;
24
+ }
25
+
26
+ type PropertiesAt<
27
+ Types extends HttpOperationTypes,
28
+ Location extends 'path' | 'query' | 'headers' | 'cookies',
29
+ > = keyof NonNullable<Types[Location]> & string;
30
+
31
+ export type HttpParameterDeclaration<Types extends HttpOperationTypes = HttpOperationTypes> =
32
+ | {
33
+ readonly in: 'path';
34
+ readonly property: PropertiesAt<Types, 'path'>;
35
+ readonly name: string;
36
+ }
37
+ | {
38
+ readonly in: 'query';
39
+ readonly property: PropertiesAt<Types, 'query'>;
40
+ readonly name: string;
41
+ }
42
+ | {
43
+ readonly in: 'header';
44
+ readonly property: PropertiesAt<Types, 'headers'>;
45
+ readonly name: string;
46
+ }
47
+ | {
48
+ readonly in: 'cookie';
49
+ readonly property: PropertiesAt<Types, 'cookies'>;
50
+ readonly name: string;
51
+ };
52
+
53
+ export type HttpBodyDeclaration =
54
+ | { readonly kind: 'json'; readonly mediaType: string }
55
+ | { readonly kind: 'text' | 'bytes' | 'stream'; readonly mediaType: string }
56
+ | { readonly kind: 'empty' };
57
+
58
+ export type HttpRequestBodyDeclaration = Exclude<HttpBodyDeclaration, { readonly kind: 'empty' }> & {
59
+ readonly required: boolean;
60
+ };
61
+
62
+ export interface HttpResponseHeaderDeclaration {
63
+ readonly property: string;
64
+ readonly name: string;
65
+ readonly description?: string;
66
+ }
67
+
68
+ export interface HttpResponseDeclaration {
69
+ readonly description: string;
70
+ readonly headers?: readonly HttpResponseHeaderDeclaration[];
71
+ readonly body: HttpBodyDeclaration;
72
+ /** Per-version response bodies for media-type versioning. */
73
+ readonly versions?: Readonly<Record<string, HttpBodyDeclaration>>;
74
+ }
75
+
76
+ export type HttpVersionDeclaration =
77
+ | { readonly kind: 'none' }
78
+ | { readonly kind: 'neutral' }
79
+ | { readonly kind: 'path'; readonly value: string }
80
+ | {
81
+ readonly kind: 'header';
82
+ readonly name: string;
83
+ readonly values: readonly string[];
84
+ readonly default: string;
85
+ }
86
+ | {
87
+ readonly kind: 'media-type';
88
+ readonly key: string;
89
+ readonly values: readonly string[];
90
+ readonly default: string;
91
+ };
92
+
93
+ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
94
+
95
+ export interface OAuthFlow {
96
+ readonly refreshUrl?: string;
97
+ readonly scopes: Readonly<Record<string, string>>;
98
+ }
99
+
100
+ export interface ImplicitFlow extends OAuthFlow {
101
+ readonly authorizationUrl: string;
102
+ }
103
+
104
+ export interface PasswordFlow extends OAuthFlow {
105
+ readonly tokenUrl: string;
106
+ }
107
+
108
+ export interface ClientCredentialsFlow extends OAuthFlow {
109
+ readonly tokenUrl: string;
110
+ }
111
+
112
+ export interface AuthorizationCodeFlow extends OAuthFlow {
113
+ readonly authorizationUrl: string;
114
+ readonly tokenUrl: string;
115
+ }
116
+
117
+ interface AllOAuthFlows {
118
+ readonly implicit?: ImplicitFlow;
119
+ readonly password?: PasswordFlow;
120
+ readonly clientCredentials?: ClientCredentialsFlow;
121
+ readonly authorizationCode?: AuthorizationCodeFlow;
122
+ }
123
+
124
+ export type OAuthFlows =
125
+ | (AllOAuthFlows & { readonly implicit: ImplicitFlow })
126
+ | (AllOAuthFlows & { readonly password: PasswordFlow })
127
+ | (AllOAuthFlows & { readonly clientCredentials: ClientCredentialsFlow })
128
+ | (AllOAuthFlows & { readonly authorizationCode: AuthorizationCodeFlow });
129
+
130
+ export type SecurityScheme =
131
+ | { readonly type: 'http'; readonly scheme: 'bearer'; readonly bearerFormat?: string; readonly description?: string }
132
+ | { readonly type: 'http'; readonly scheme: 'basic'; readonly description?: string }
133
+ | {
134
+ readonly type: 'apiKey';
135
+ readonly in: 'header' | 'query' | 'cookie';
136
+ readonly name: string;
137
+ readonly description?: string;
138
+ }
139
+ | { readonly type: 'mutualTLS'; readonly description?: string }
140
+ | { readonly type: 'oauth2'; readonly flows: OAuthFlows; readonly description?: string }
141
+ | { readonly type: 'openIdConnect'; readonly openIdConnectUrl: string; readonly description?: string };
142
+
143
+ export interface HttpOperationDeclaration<
144
+ Types extends HttpOperationTypes = HttpOperationTypes,
145
+ Controller extends HttpController = HttpController,
146
+ Handler extends string = string,
147
+ > {
148
+ readonly controller: Controller;
149
+ readonly handler: Handler;
150
+ readonly method: HttpMethod;
151
+ readonly path: string;
152
+ readonly parameters: readonly HttpParameterDeclaration<Types>[];
153
+ readonly requestBody?: HttpRequestBodyDeclaration;
154
+ readonly responses: Readonly<Record<keyof Types['responses'] & number, HttpResponseDeclaration>>;
155
+ readonly security: readonly SecurityRequirement[];
156
+ readonly version: HttpVersionDeclaration;
157
+ readonly deprecated: boolean;
158
+ }
159
+
160
+ export interface HttpContractDeclaration<
161
+ Operations extends Readonly<Record<string, HttpOperationDeclaration>> = Readonly<
162
+ Record<string, HttpOperationDeclaration>
163
+ >,
164
+ > {
165
+ readonly operations: Operations;
166
+ readonly securitySchemes: Readonly<Record<string, SecurityScheme>>;
167
+ }
168
+
169
+ /**
170
+ * Record one operation without registering a route or retaining its generic type.
171
+ *
172
+ * The generic argument is recovered only by the build-time compiler subpath.
173
+ */
174
+ export function httpOperation<Types extends HttpOperationTypes>(
175
+ declaration: HttpOperationDeclaration<Types>,
176
+ ): HttpOperationDeclaration<Types> {
177
+ return Object.freeze(declaration);
178
+ }
179
+
180
+ /** Define one inert contract module. The object keys are the public operation IDs. */
181
+ export function defineHttpContract<const Operations extends Readonly<Record<string, HttpOperationDeclaration>>>(
182
+ declaration: HttpContractDeclaration<Operations>,
183
+ ): HttpContractDeclaration<Operations> {
184
+ return Object.freeze({
185
+ operations: Object.freeze({ ...declaration.operations }),
186
+ securitySchemes: Object.freeze({ ...declaration.securitySchemes }),
187
+ });
188
+ }
189
+
190
+ export interface HttpTypeIR {
191
+ readonly type: TypeIR;
192
+ readonly openApi: Readonly<Record<string, JsonValue>>;
193
+ }
194
+
195
+ export interface HttpContractIR {
196
+ readonly format: 1;
197
+ readonly types: Readonly<Record<string, HttpTypeIR>>;
198
+ readonly operations: readonly HttpOperationIR[];
199
+ readonly securitySchemes: Readonly<Record<string, SecurityScheme>>;
200
+ }
201
+
202
+ export interface HttpOperationIR {
203
+ readonly operationId: string;
204
+ readonly controller: string;
205
+ readonly handler: string;
206
+ readonly method: HttpMethod;
207
+ readonly path: string;
208
+ readonly parameters: readonly HttpParameterIR[];
209
+ readonly requestBody?: HttpRequestBodyIR;
210
+ readonly responses: readonly HttpResponseIR[];
211
+ readonly security: readonly SecurityRequirement[];
212
+ readonly version: HttpVersionIR;
213
+ readonly deprecated: boolean;
214
+ }
215
+
216
+ export interface HttpParameterIR {
217
+ readonly property: string;
218
+ readonly name: string;
219
+ readonly in: 'path' | 'query' | 'header' | 'cookie';
220
+ readonly required: boolean;
221
+ readonly typeId: string;
222
+ }
223
+
224
+ export type HttpBodyKind = 'json' | 'text' | 'bytes' | 'stream' | 'empty';
225
+
226
+ export type HttpBodyIR =
227
+ | { readonly kind: 'json'; readonly mediaType: string; readonly typeId: string }
228
+ | { readonly kind: 'text' | 'bytes' | 'stream'; readonly mediaType: string }
229
+ | { readonly kind: 'empty' };
230
+
231
+ export type HttpRequestBodyIR = Exclude<HttpBodyIR, { readonly kind: 'empty' }> & {
232
+ readonly required: boolean;
233
+ };
234
+
235
+ export interface HttpResponseIR {
236
+ readonly status: number;
237
+ readonly description: string;
238
+ readonly headers: readonly HttpResponseHeaderIR[];
239
+ readonly body: HttpBodyIR;
240
+ readonly versions?: Readonly<Record<string, HttpBodyIR>>;
241
+ }
242
+
243
+ export interface HttpResponseHeaderIR {
244
+ readonly property: string;
245
+ readonly name: string;
246
+ readonly description?: string;
247
+ readonly required: boolean;
248
+ readonly typeId: string;
249
+ }
250
+
251
+ export type HttpVersionIR = HttpVersionDeclaration;
252
+
253
+ /** Runtime binding kept separate from the serialisable IR. */
254
+ export interface CompiledHttpOperation {
255
+ readonly operation: HttpOperationIR;
256
+ readonly controller: HttpController;
257
+ readonly handler: string;
258
+ }
259
+
260
+ /** One compilation result shared by runtime routing and later artifact emitters. */
261
+ export interface CompiledHttpContract {
262
+ readonly ir: HttpContractIR;
263
+ readonly operations: readonly CompiledHttpOperation[];
264
+ /** Project-source inputs whose changes can alter this compilation. */
265
+ readonly dependencies: readonly string[];
266
+ }
@@ -0,0 +1,264 @@
1
+ // @zmdb/web — session-bound CSRF tokens (epic #564, spec ./SPEC.md).
2
+ //
3
+ // The application supplies the session lookup, allowed browser origins and
4
+ // signing secret. Tokens are stateless: this module keeps no session or token
5
+ // store, and two instances using the same secret can verify each other's tokens.
6
+
7
+ import { ChainError, type AnyCtx, type Guard } from '../middleware/index.js';
8
+
9
+ export interface CsrfOptions {
10
+ readonly secret: Uint8Array<ArrayBuffer>;
11
+ readonly sessionOf: (ctx: AnyCtx) => string | undefined;
12
+ readonly allowedOrigins: readonly string[];
13
+ readonly headerName?: string;
14
+ }
15
+
16
+ export interface Csrf {
17
+ issue(ctx: AnyCtx): Promise<string>;
18
+ verify(ctx: AnyCtx): Promise<void>;
19
+ guard(): Guard;
20
+ }
21
+
22
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
23
+ const DEFAULT_HEADER = 'x-csrf-token';
24
+ const FORBIDDEN = 'forbidden';
25
+ const HMAC = { name: 'HMAC', hash: 'SHA-256' } as const;
26
+ const NONCE_BYTES = 16;
27
+ const MAC_BYTES = 32;
28
+ const MASK_BYTES = 16;
29
+ const NONCE_TEXT_LENGTH = 22;
30
+ const MAC_TEXT_LENGTH = 43;
31
+ const TOKEN_TEXT_LENGTH = NONCE_TEXT_LENGTH + 1 + MAC_TEXT_LENGTH;
32
+ const MASK_TEXT_LENGTH = 22;
33
+ const MASKED_TEXT_LENGTH = 88;
34
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
35
+ const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
36
+ const encoder = new TextEncoder();
37
+ const decoder = new TextDecoder('utf-8', { fatal: true });
38
+
39
+ function randomBytes(length: number): Uint8Array<ArrayBuffer> {
40
+ return globalThis.crypto.getRandomValues(new Uint8Array(length));
41
+ }
42
+
43
+ async function importHmacKey(secret: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
44
+ return globalThis.crypto.subtle.importKey('raw', secret, HMAC, false, ['sign']);
45
+ }
46
+
47
+ async function sign(key: CryptoKey, value: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> {
48
+ return new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, value));
49
+ }
50
+
51
+ function encodeBase64Url(value: Uint8Array<ArrayBuffer>): string {
52
+ return value.toBase64({ alphabet: 'base64url', omitPadding: true });
53
+ }
54
+
55
+ function decodeBase64Url(value: string): Uint8Array<ArrayBuffer> | undefined {
56
+ if (value.length === 0 || !BASE64URL.test(value)) {
57
+ return undefined;
58
+ }
59
+ try {
60
+ const decoded = Uint8Array.fromBase64(value, { alphabet: 'base64url' });
61
+ return encodeBase64Url(decoded) === value ? decoded : undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ function xorMask(value: Uint8Array<ArrayBuffer>, mask: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
68
+ const result = new Uint8Array(value.length);
69
+ for (let index = 0; index < value.length; index += 1) {
70
+ result[index] = (value[index] ?? 0) ^ (mask[index % mask.length] ?? 0);
71
+ }
72
+ return result;
73
+ }
74
+
75
+ function normaliseOrigins(origins: readonly string[]): ReadonlySet<string> {
76
+ if (!Array.isArray(origins) || origins.length === 0) {
77
+ throw new TypeError('allowedOrigins must contain at least one explicit HTTP(S) origin');
78
+ }
79
+ const normalised = new Set<string>();
80
+ for (const candidate of origins) {
81
+ if (typeof candidate !== 'string' || candidate.includes('*')) {
82
+ throw new TypeError('allowedOrigins must contain explicit HTTP(S) origins');
83
+ }
84
+ let url: URL;
85
+ try {
86
+ url = new URL(candidate);
87
+ } catch {
88
+ throw new TypeError('allowedOrigins must contain valid absolute URLs');
89
+ }
90
+ if (
91
+ (url.protocol !== 'http:' && url.protocol !== 'https:') ||
92
+ url.origin === 'null' ||
93
+ url.username !== '' ||
94
+ url.password !== '' ||
95
+ (url.pathname !== '' && url.pathname !== '/') ||
96
+ url.search !== '' ||
97
+ url.hash !== ''
98
+ ) {
99
+ throw new TypeError('allowedOrigins entries must be origins, not URL patterns or paths');
100
+ }
101
+ normalised.add(url.origin);
102
+ }
103
+ return normalised;
104
+ }
105
+
106
+ function normaliseHeaderName(name: string | undefined): string {
107
+ const normalised = name ?? DEFAULT_HEADER;
108
+ if (normalised.length === 0 || !HEADER_NAME.test(normalised)) {
109
+ throw new TypeError('headerName must be a valid HTTP field name');
110
+ }
111
+ return normalised.toLowerCase();
112
+ }
113
+
114
+ function header(headers: Readonly<Record<string, string>>, wanted: string): string | undefined {
115
+ const direct = headers[wanted];
116
+ if (direct !== undefined) {
117
+ return direct;
118
+ }
119
+ for (const name of Object.keys(headers)) {
120
+ if (name.toLowerCase() === wanted) {
121
+ return headers[name];
122
+ }
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ function requestOrigin(ctx: AnyCtx): string | undefined {
128
+ const candidate = header(ctx.headers, 'origin') ?? header(ctx.headers, 'referer');
129
+ if (candidate === undefined) {
130
+ return undefined;
131
+ }
132
+ try {
133
+ const parsed = new URL(candidate);
134
+ return parsed.origin === 'null' ? undefined : parsed.origin;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ function sessionFor(ctx: AnyCtx, sessionOf: CsrfOptions['sessionOf']): string {
141
+ const session = sessionOf(ctx);
142
+ if (session === undefined) {
143
+ throw new Error('CSRF protection requires a cookie-backed session');
144
+ }
145
+ return session;
146
+ }
147
+
148
+ function rejectRequest(): never {
149
+ throw new ChainError(403, FORBIDDEN);
150
+ }
151
+
152
+ async function equalAfterMac(
153
+ key: CryptoKey,
154
+ expected: Uint8Array<ArrayBuffer>,
155
+ provided: Uint8Array<ArrayBuffer>,
156
+ ): Promise<boolean> {
157
+ const [expectedDigest, providedDigest] = await Promise.all([sign(key, expected), sign(key, provided)]);
158
+ return encodeBase64Url(expectedDigest) === encodeBase64Url(providedDigest);
159
+ }
160
+
161
+ interface ParsedToken {
162
+ readonly nonce: string;
163
+ readonly mac: Uint8Array<ArrayBuffer>;
164
+ }
165
+
166
+ function parseToken(token: string): ParsedToken | undefined {
167
+ const segments = token.split('.');
168
+ if (segments.length !== 2 || segments[0]?.length !== MASK_TEXT_LENGTH || segments[1]?.length !== MASKED_TEXT_LENGTH) {
169
+ return undefined;
170
+ }
171
+ const mask = decodeBase64Url(segments[0]);
172
+ const masked = decodeBase64Url(segments[1]);
173
+ if (mask?.byteLength !== MASK_BYTES || masked?.byteLength !== TOKEN_TEXT_LENGTH) {
174
+ return undefined;
175
+ }
176
+
177
+ let raw: string;
178
+ try {
179
+ raw = decoder.decode(xorMask(masked, mask));
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ const tokenParts = raw.split('.');
184
+ if (
185
+ tokenParts.length !== 2 ||
186
+ tokenParts[0]?.length !== NONCE_TEXT_LENGTH ||
187
+ tokenParts[1]?.length !== MAC_TEXT_LENGTH
188
+ ) {
189
+ return undefined;
190
+ }
191
+ const nonceBytes = decodeBase64Url(tokenParts[0]);
192
+ const mac = decodeBase64Url(tokenParts[1]);
193
+ if (nonceBytes?.byteLength !== NONCE_BYTES || mac?.byteLength !== MAC_BYTES) {
194
+ return undefined;
195
+ }
196
+ return { nonce: tokenParts[0], mac };
197
+ }
198
+
199
+ /** Create stateless CSRF protection for a cookie-authenticated application. */
200
+ export async function createCsrf(options: CsrfOptions): Promise<Csrf> {
201
+ if (typeof options !== 'object' || options === null) {
202
+ throw new TypeError('CSRF options are required');
203
+ }
204
+ if (!(options.secret instanceof Uint8Array) || options.secret.byteLength === 0) {
205
+ throw new TypeError('secret must be a non-empty Uint8Array');
206
+ }
207
+ if (typeof options.sessionOf !== 'function') {
208
+ throw new TypeError('sessionOf is required');
209
+ }
210
+
211
+ const allowedOrigins = normaliseOrigins(options.allowedOrigins);
212
+ const headerName = normaliseHeaderName(options.headerName);
213
+ const sessionOf = options.sessionOf;
214
+ const signingKey = await importHmacKey(options.secret.slice());
215
+ const comparisonKey = await importHmacKey(randomBytes(MAC_BYTES));
216
+
217
+ const issue = async (ctx: AnyCtx): Promise<string> => {
218
+ const session = sessionFor(ctx, sessionOf);
219
+ const nonce = encodeBase64Url(randomBytes(NONCE_BYTES));
220
+ const mac = await sign(signingKey, encoder.encode(`${session}.${nonce}`));
221
+ const raw = encoder.encode(`${nonce}.${encodeBase64Url(mac)}`);
222
+ const mask = randomBytes(MASK_BYTES);
223
+ return `${encodeBase64Url(mask)}.${encodeBase64Url(xorMask(raw, mask))}`;
224
+ };
225
+
226
+ const verify = async (ctx: AnyCtx): Promise<void> => {
227
+ const session = sessionFor(ctx, sessionOf);
228
+ if (SAFE_METHODS.has(ctx.method)) {
229
+ return;
230
+ }
231
+
232
+ const origin = requestOrigin(ctx);
233
+ if (origin === undefined || !allowedOrigins.has(origin)) {
234
+ rejectRequest();
235
+ }
236
+
237
+ const supplied = header(ctx.headers, headerName);
238
+ const parsed = supplied === undefined ? undefined : parseToken(supplied);
239
+ if (parsed === undefined) {
240
+ rejectRequest();
241
+ }
242
+
243
+ const expected = await sign(signingKey, encoder.encode(`${session}.${parsed.nonce}`));
244
+ if (!(await equalAfterMac(comparisonKey, expected, parsed.mac))) {
245
+ rejectRequest();
246
+ }
247
+ };
248
+
249
+ const guard = (): Guard => ({
250
+ async canActivate(ctx: AnyCtx): Promise<boolean> {
251
+ try {
252
+ await verify(ctx);
253
+ return true;
254
+ } catch (error) {
255
+ if (error instanceof ChainError && error.status === 403) {
256
+ return false;
257
+ }
258
+ throw error;
259
+ }
260
+ },
261
+ });
262
+
263
+ return Object.freeze({ issue, verify, guard });
264
+ }
@@ -0,0 +1,65 @@
1
+ // @zmdb/web — HTTP data-boundary adapters (epic #277, spec ./SPEC.md).
2
+ // Repository injection is app-owned; this module converts wire values and
3
+ // adapts validators for the request pipeline.
4
+ //
5
+ // "No runtime parser" still holds with `wireDecoder` here: it converts the two column
6
+ // types JSON cannot carry (a `timestamp`, a `bigint`) into the values the app layer holds,
7
+ // and it accepts and rejects nothing. Validation remains the consumer's AOT `assert`.
8
+
9
+ import { type CoreSchema } from '@zmdb/schema';
10
+ import { decodeWire, encodeWire, type CodecRegistry, type Variant } from '@zmdb/schema/ir';
11
+
12
+ /**
13
+ * Adapt a validator into a pipeline `validateBody` hook. Pass any function that
14
+ * returns the validated value or throws — e.g. `@zmdb/validator`'s
15
+ * `assert<CreateDTO<T>>`. The framework embeds no parser of its own.
16
+ */
17
+ export function validateWith<T>(validator: (raw: unknown) => T): (raw: unknown) => T {
18
+ return validator;
19
+ }
20
+
21
+ /** A JSON object body — not an array, which is a valid JSON document and not a payload. */
22
+ function isBody(value: unknown): value is Record<string, unknown> {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ /**
27
+ * The wire→app decode, at the boundary and nowhere else (plan D3).
28
+ *
29
+ * A column has three types, and two of them meet here: JSON carries a `timestamp` as an
30
+ * ISO-8601 string and a `bigint` as a decimal one, while a handler, a `CreateDTO<T>` and
31
+ * the repository all hold a `Date` and a `bigint`. Something has to convert, and if it is
32
+ * not one function at the edge then it is every handler, differently — which is the state
33
+ * in which nobody noticed that the repository accepted both and the DDL was wrong.
34
+ *
35
+ * Put it *before* validation in the chain: it decodes, the validator then checks the app
36
+ * type. It converts and never rejects — a string that is not a date survives as a string,
37
+ * so the validator reports it rather than the handler receiving `Invalid Date`.
38
+ *
39
+ * ```ts
40
+ * dtoChain({ decode: wireDecoder(OrderSchema, 'create'), validate: assert<CreateDTO<Order>> })
41
+ * ```
42
+ */
43
+ export function wireDecoder(
44
+ schema: CoreSchema<string>,
45
+ variant: Variant = 'create',
46
+ codecs: CodecRegistry = {},
47
+ ): (raw: unknown) => unknown {
48
+ const ir = schema.ir;
49
+ return (raw: unknown) => (isBody(raw) ? decodeWire(ir, variant, raw, codecs) : raw);
50
+ }
51
+
52
+ /**
53
+ * The app→wire encode, for a response: the same crossing in the other direction.
54
+ *
55
+ * A `Date` does not survive `JSON.stringify` as anything the published document describes
56
+ * — it becomes an ISO string, which happens to be right — and a `bigint` does not survive
57
+ * it at all (`TypeError`). Both are the wire type of a column, so both come from here.
58
+ */
59
+ export function wireEncoder(schema: CoreSchema<string>, codecs: CodecRegistry = {}): (result: unknown) => unknown {
60
+ const ir = schema.ir;
61
+ return (result: unknown) => {
62
+ if (Array.isArray(result)) return result.map(row => (isBody(row) ? encodeWire(ir, row, codecs) : row));
63
+ return isBody(result) ? encodeWire(ir, result, codecs) : result;
64
+ };
65
+ }