@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 +2 -2
- package/README.md +5 -8
- package/package.json +5 -5
- package/src/error-map-tier-1.ts +4 -0
- package/src/errors.ts +18 -5
- package/src/webhook-verify.ts +17 -1
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`). **
|
|
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 {
|
|
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
|
|
200
|
-
//
|
|
201
|
-
const
|
|
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.
|
|
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.
|
|
39
|
-
"@ultimat3/i18n": "25.
|
|
40
|
-
"@ultimat3/schema": "25.
|
|
41
|
-
"@ultimat3/time": "25.
|
|
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
|
}
|
package/src/error-map-tier-1.ts
CHANGED
|
@@ -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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
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
|
+
});
|
package/src/webhook-verify.ts
CHANGED
|
@@ -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
|
|
168
|
+
secret,
|
|
153
169
|
timestampText: signature.timestampText,
|
|
154
170
|
eventId,
|
|
155
171
|
topic,
|