@ultimat3/http 25.0.0 → 25.2.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/CLAUDE.md CHANGED
@@ -225,8 +225,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
225
225
  `readWithinLimit` and returns the raw text. The mac is checked BEFORE freshness
226
226
  (`X_WEBHOOK_SIGNATURE_STALE` means authentic and old); the window is `Math.abs`; the timestamp is
227
227
  digits-only; `:` is refused in id and topic; the comparison is `timingSafeEqual`
228
- (`bun run secret-compare`). **The FORMAT is core's** (`packages/core/src/webhook-signature.ts`) —
229
- never re-declared here.
228
+ (`bun run secret-compare`). **An empty `secret` is `X_CONFIG_INVALID`.**
229
+ **The FORMAT is core's** (`packages/core/src/webhook-signature.ts`) — never re-declared here.
230
230
  - Tests must not touch the network — the preload seals `fetch`. Socket tests live in `e2e/` (`bun test
231
231
  packages/http/e2e`), sealed; `start()` calls core's `markListening()`. Never unseal.
232
232
 
package/README.md CHANGED
@@ -185,7 +185,7 @@ and one `insert … on conflict` per take (a peek is one `select`), so N replica
185
185
  line over the client the boot already opened; **never `Bun.sql`**, whose `.query` is `undefined`.
186
186
 
187
187
  ```ts
188
- import { db, type SqlFragment } from '@ultimat3/db';
188
+ import { baseClient, dbExecutor } from '@ultimat3/db';
189
189
  import {
190
190
  httpServer,
191
191
  defineHttpConfig,
@@ -196,13 +196,9 @@ import {
196
196
 
197
197
  declare const routes: readonly Route[];
198
198
 
199
- // The client this process already opened, wrapped in one line. `@ultimat3/cli`'s `pgExecutorFor`
200
- // is this exact function, and it is what the boot passes when it installs the store for you.
201
- const client = db();
202
- const executor: PgExecutor = {
203
- query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
204
- client.query<R>({ text, values } satisfies SqlFragment),
205
- };
199
+ // The pool this process already opened, never a request's open transaction — the boot builds
200
+ // the same executor when it installs the store for you.
201
+ const executor: PgExecutor = dbExecutor(baseClient);
206
202
 
207
203
  httpServer({
208
204
  routes,
@@ -368,6 +364,7 @@ export async function POST(request: Request): Promise<Response> {
368
364
  | a replay is detectable | `eventId` is signed and returned, so it cannot be moved in transit; the seen-set is your table, because the framework has nowhere to keep one |
369
365
  | moving the timestamp breaks it | the timestamp is inside the canonical string, so editing `t=` on a captured request invalidates the mac |
370
366
  | the raw bytes are what is verified | the body is read through core's counting reader — the same one `UltimateRequest` uses — so a sender that declares no length cannot make this handler hold an unbounded payload |
367
+ | no secret is refused, never verified against | an empty or non-string `secret` (an unset `process.env.X`) throws `X_CONFIG_INVALID` before the request is read — a mac under an empty key is one anyone can compute. Only empty is refused, not short: the sender signs with any non-empty secret |
371
368
  | the mac is checked BEFORE the window | `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old*, never *unreadable and old*, so an operator reading it goes to a clock or a replay |
372
369
 
373
370
  `X_WEBHOOK_SIGNATURE_INVALID` and `X_WEBHOOK_SIGNATURE_STALE` are both **401**: the request is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "25.0.0",
3
+ "version": "25.2.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,9 +35,9 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/core": "25.0.0",
39
- "@ultimat3/i18n": "25.0.0",
40
- "@ultimat3/schema": "25.0.0",
41
- "@ultimat3/time": "25.0.0"
38
+ "@ultimat3/core": "25.2.0",
39
+ "@ultimat3/i18n": "25.2.0",
40
+ "@ultimat3/schema": "25.2.0",
41
+ "@ultimat3/time": "25.2.0"
42
42
  }
43
43
  }
@@ -70,4 +70,8 @@ export const TIER_1_ERROR_STATUS = {
70
70
  X_STORAGE_READ_FAILED: 500,
71
71
  // @ultimat3/storage — the object is under retention or a legal hold
72
72
  X_STORAGE_OBJECT_LOCKED: 409,
73
+ // @ultimat3/cache — a cached value the cache codec cannot encode (a cycle). The stack absorbs it
74
+ // through `bestEffort`; only a direct `LruCache.set` in a handler reaches a socket, and that is
75
+ // an authoring defect in the server's own code, never the caller's — hence 500.
76
+ X_CACHE_VALUE_UNENCODABLE: 500,
73
77
  } satisfies Readonly<Record<string, number>>;
package/src/errors.ts CHANGED
@@ -129,11 +129,10 @@ export const bodyInvalid = (
129
129
  code: 'X_BODY_INVALID',
130
130
  cause: `${pathname} body rejected: ${issues.join('; ')}`,
131
131
  ...(meta === undefined ? {} : { meta }),
132
- // `x schema show` is not a command — not in the registry and not in `PLANNED_COMMANDS`, so it
133
- // exits `X_CLI_UNKNOWN_COMMAND`. The same axiom-4 inversion `x logs tail` had in `error-map`:
134
- // the one instruction the reader is given fails when they run it. `x routes` ships, and
135
- // `hasInputSchema` plus the route's name is what it prints.
136
- fix: `x routes --json # find ${pathname}, then send a body matching its input schema`,
132
+ // Only a route that declares `meta.input` reaches the body stage, and no action or query does
133
+ // (`invoke` and `runQuery` validate their own input, as `X_INPUT_INVALID`) — so this is a plain
134
+ // `runtime.ts` route, and `x routes --surface api` lists those by name since owner decision 2.
135
+ fix: `x routes --surface api --json # find ${pathname}, a runtime.ts route, then send a body its meta.input schema accepts`,
137
136
  });
138
137
 
139
138
  /** At construction: a hole in a scope, or a prefix shadowing `/api` or `/_x`, never serves. */
@@ -447,3 +446,17 @@ export const webhookSignatureStale = (
447
446
  fix: 'sync the sending host clock with NTP, or widen the window with verifyWebhookSignature(request, { secret, toleranceMs: 600_000 }) if the sender queues deliveries for longer than that',
448
447
  meta: { skewMs, toleranceMs },
449
448
  });
449
+
450
+ /**
451
+ * The receiver holds no secret. Not a weak key — NO key: an HMAC under '' is a mac anyone can
452
+ * compute, so verifying against it accepts every forged delivery. `X_CONFIG_INVALID` because the
453
+ * repair is the route's configuration and nothing a sender does can fix it. `shape` is `typeof`
454
+ * or "an empty string" — never the value, which is a credential when it is one.
455
+ */
456
+ export const webhookSecretMissing = (shape: string): HttpError =>
457
+ new HttpError({
458
+ code: 'X_CONFIG_INVALID',
459
+ cause: `verifyWebhookSignature was given ${shape} as its secret, and a mac under an empty key is one anyone can compute — every forged delivery would verify`,
460
+ fix: "verifyWebhookSignature(request, { secret: process.env.WEBHOOK_SECRET ?? '' }) in the api/ route receiving it, with WEBHOOK_SECRET set in the deployment environment to the signing secret the sender shows for this endpoint — an unset variable is then refused here rather than verified against",
461
+ meta: { option: 'webhook secret' },
462
+ });
@@ -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,