@ultimat3/http 24.0.0 → 25.1.0

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/src/stages.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {
8
8
  anonymousActor,
9
+ BUILD_ID_HEADER,
9
10
  inflightCount,
10
11
  isAnonymous,
11
12
  isDraining,
@@ -48,7 +49,7 @@ import type { UltimateRequest } from './request';
48
49
  import { addVary, problem, redirect } from './response';
49
50
  import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
50
51
  import { responseSecurityHeaders } from './security-headers';
51
- import { validate } from './validate';
52
+ import { validateBody } from './validate';
52
53
 
53
54
  export type StageName =
54
55
  | 'request-id'
@@ -173,12 +174,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
173
174
  const answered = preflight(request.raw, config.cors);
174
175
  if (answered !== undefined) return answered;
175
176
 
176
- ctx.clientBuildId = request.header(config.buildIdHeader);
177
+ ctx.clientBuildId = request.header(BUILD_ID_HEADER);
177
178
  // Stamped BEFORE the assertion, so the refusal carries it too. A skew answer that
178
179
  // withholds the server's own id tells a client it is stale without telling it what to
179
180
  // become — and the one caller that can act on that, the service worker holding the
180
181
  // stale id, then has no way to tell this 409 from any other.
181
- if (config.buildId !== null) ctx.headers.set(config.buildIdHeader, config.buildId);
182
+ if (config.buildId !== null) ctx.headers.set(BUILD_ID_HEADER, config.buildId);
182
183
  request.assertBuild();
183
184
 
184
185
  // A `/<locale>/` prefix is stripped BEFORE the match, never by middleware: middleware wraps
@@ -273,7 +274,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
273
274
  body: async (request, ctx) => {
274
275
  const schema = ctx.route?.meta.input;
275
276
  if (schema === undefined) return undefined;
276
- const outcome = await validate(schema, await request.bodyRaw());
277
+ const outcome = await validateBody(schema, await request.bodyRaw());
277
278
  if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
278
279
  ctx.input = outcome.value;
279
280
  return undefined;
@@ -375,7 +376,15 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
375
376
  // never handed a page. Which of the two a browser gets is the ENVIRONMENT — the overlay
376
377
  // prints the cause, the fix and the stack, which is what a visitor may never see.
377
378
  if (acceptsHtml(request.raw)) {
378
- if (config.dev) {
379
+ // A 4xx is not a defect to debug, it is a page a visitor will see, so dev asks for the
380
+ // app's own file first (#492, O-492). A 5xx keeps the overlay, and so does a 4xx the app
381
+ // has no file for: in dev the diagnostic beats the framework's generic page.
382
+ // A throw from the hook is caught by `recoverWith` and degrades to the problem document.
383
+ const override =
384
+ config.dev && facts.status >= 500
385
+ ? undefined
386
+ : await hooks.errorPage?.(facts.status, ctx);
387
+ if (config.dev && override === undefined) {
379
388
  // Asked for inside the branch, never above it: the overlay is the only surface a notice
380
389
  // has, so a production process — or an agent that asked for json — must not pay a
381
390
  // diagnostic's per-request cost to produce findings nothing will render.
@@ -401,10 +410,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
401
410
  ...(seconds === undefined ? {} : { retryAfterSeconds: seconds }),
402
411
  signInPath: config.signInPath,
403
412
  },
404
- // The app's own file, read per request by whoever mounted the hook. A throw here is
405
- // caught by `recoverWith` and degrades to the problem document, which is the answer a
406
- // page whose renderer failed can still give.
407
- { override: await hooks.errorPage?.(facts.status, ctx), headers: retryAfter },
413
+ { override, headers: retryAfter },
408
414
  );
409
415
  }
410
416
  return problem(error, {
package/src/validate.ts CHANGED
@@ -55,12 +55,12 @@ const outcome = <Out>(result: {
55
55
  export const validateSync = <Out>(schema: Schema<Out>, value: unknown): ValidationOutcome<Out> => {
56
56
  const result = schema['~standard'].validate(value);
57
57
  if (result instanceof Promise) {
58
- return { ok: false, issues: ['schema is async; use validate() for request bodies'] };
58
+ return { ok: false, issues: ['schema is async; use validateBody() for request bodies'] };
59
59
  }
60
60
  return outcome<Out>(result);
61
61
  };
62
62
 
63
- export const validate = async <Out>(
63
+ export const validateBody = async <Out>(
64
64
  schema: Schema<Out>,
65
65
  value: unknown,
66
66
  ): Promise<ValidationOutcome<Out>> => outcome<Out>(await schema['~standard'].validate(value));
@@ -25,6 +25,7 @@ import {
25
25
  import {
26
26
  bodyInvalid,
27
27
  httpCountInvalid,
28
+ webhookSecretMissing,
28
29
  webhookSignatureInvalid,
29
30
  webhookSignatureStale,
30
31
  } from './errors';
@@ -78,6 +79,20 @@ const assertFiniteBodyLimit = (maxBytes: number): number => {
78
79
  );
79
80
  };
80
81
 
82
+ /**
83
+ * The secret is screened before the request is touched. `string` in the type is no guarantee: the
84
+ * usual caller passes `process.env.X`, and an unset one is `undefined` — which `webhookMac` turns
85
+ * into an UNKEYED hash, as it does '' into an HMAC under an empty key. Either way the mac is one
86
+ * anyone can compute. Only empty is refused, not short: the sender (`@ultimat3/jobs`) signs with
87
+ * any non-empty secret, and a receiver stricter than its sender refuses deliveries that are real.
88
+ */
89
+ const assertWebhookSecret = (secret: unknown): string => {
90
+ if (typeof secret === 'string' && secret.length > 0) return secret;
91
+ throw webhookSecretMissing(
92
+ secret === '' ? 'an empty string' : `a value of type ${typeof secret}`,
93
+ );
94
+ };
95
+
81
96
  export interface WebhookVerifyOptions {
82
97
  /** The shared secret for THIS sender. Never logged, never rendered into a refusal. */
83
98
  readonly secret: string;
@@ -118,6 +133,7 @@ export async function verifyWebhookSignature(
118
133
  request: Request,
119
134
  options: WebhookVerifyOptions,
120
135
  ): Promise<VerifiedWebhook> {
136
+ const secret = assertWebhookSecret(options.secret);
121
137
  const pathname = new URL(request.url).pathname;
122
138
  const signature = parseWebhookSignatureHeader(request.headers.get(WEBHOOK_SIGNATURE_HEADER));
123
139
  if (signature === undefined) {
@@ -149,7 +165,7 @@ export async function verifyWebhookSignature(
149
165
  // and then the body is identical to hashing one string — and it never round-trips a body that is
150
166
  // not valid UTF-8 through a decoder before the mac is taken over it.
151
167
  const expected = webhookMac({
152
- secret: options.secret,
168
+ secret,
153
169
  timestampText: signature.timestampText,
154
170
  eventId,
155
171
  topic,