@crouton-kit/crouter 0.3.39 → 0.3.41
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/dist/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +706 -733
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
- package/dist/core/hearth/providers/blaxel-home.js +0 -386
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export interface RateLimiterConfig {
|
|
2
|
-
/** Per-key burst size (max tokens for one routing key). */
|
|
3
|
-
capacity: number;
|
|
4
|
-
/** Per-key steady-state refill rate (tokens/second). */
|
|
5
|
-
refillPerSec: number;
|
|
6
|
-
/** Global burst ceiling across all keys. */
|
|
7
|
-
globalCapacity: number;
|
|
8
|
-
/** Global steady-state refill rate (tokens/second). */
|
|
9
|
-
globalRefillPerSec: number;
|
|
10
|
-
/** Injectable clock for tests. Defaults to `Date.now`. */
|
|
11
|
-
now?: () => number;
|
|
12
|
-
}
|
|
13
|
-
export interface RateCheckResult {
|
|
14
|
-
ok: boolean;
|
|
15
|
-
retryAfterMs?: number;
|
|
16
|
-
}
|
|
17
|
-
export declare function loadRateLimiterConfig(env?: NodeJS.ProcessEnv): RateLimiterConfig;
|
|
18
|
-
export interface RateLimiter {
|
|
19
|
-
checkRate(key: string): RateCheckResult;
|
|
20
|
-
}
|
|
21
|
-
/** Build a token-bucket rate limiter. Each distinct `key` gets its own bucket
|
|
22
|
-
* plus a single shared global bucket; a request must draw one token from BOTH
|
|
23
|
-
* to pass. */
|
|
24
|
-
export declare function createRateLimiter(config: RateLimiterConfig): RateLimiter;
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// control-plane/ingress/rate-limit.ts — the R-TRIG-4 pre-resolution ingress
|
|
2
|
-
// rate limiter (master plan P5.2). Burst protection on inbound ingress ONLY,
|
|
3
|
-
// evaluated BEFORE any route lookup / secret resolution / DB write, so a flood
|
|
4
|
-
// against an unknown or unverified routing key is shed cheaply and can never
|
|
5
|
-
// amplify into DB work.
|
|
6
|
-
//
|
|
7
|
-
// An in-memory token bucket keyed by the caller-supplied key (the ingress
|
|
8
|
-
// `routing_key`) plus a coarse GLOBAL ceiling. In-memory is acceptable this
|
|
9
|
-
// increment because the CP is a single always-on instance (design D1 / R-CP-1,
|
|
10
|
-
// HA out of scope) — there is no distributed limiter to coordinate. Detailed
|
|
11
|
-
// backoff tuning is out of scope (part-plan gap note 3): sane defaults only.
|
|
12
|
-
const ENV = {
|
|
13
|
-
capacity: 'CRTR_HEARTH_CP_WEBHOOK_RATE_CAPACITY',
|
|
14
|
-
refillPerSec: 'CRTR_HEARTH_CP_WEBHOOK_RATE_REFILL_PER_SEC',
|
|
15
|
-
globalCapacity: 'CRTR_HEARTH_CP_WEBHOOK_RATE_GLOBAL_CAPACITY',
|
|
16
|
-
globalRefillPerSec: 'CRTR_HEARTH_CP_WEBHOOK_RATE_GLOBAL_REFILL_PER_SEC',
|
|
17
|
-
};
|
|
18
|
-
const DEFAULTS = {
|
|
19
|
-
capacity: 20,
|
|
20
|
-
refillPerSec: 5,
|
|
21
|
-
globalCapacity: 200,
|
|
22
|
-
globalRefillPerSec: 50,
|
|
23
|
-
};
|
|
24
|
-
function parsePositive(value, fallback) {
|
|
25
|
-
if (value === undefined || value.trim() === '')
|
|
26
|
-
return fallback;
|
|
27
|
-
const parsed = Number(value);
|
|
28
|
-
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
29
|
-
return fallback;
|
|
30
|
-
return parsed;
|
|
31
|
-
}
|
|
32
|
-
export function loadRateLimiterConfig(env = process.env) {
|
|
33
|
-
return {
|
|
34
|
-
capacity: parsePositive(env[ENV.capacity], DEFAULTS.capacity),
|
|
35
|
-
refillPerSec: parsePositive(env[ENV.refillPerSec], DEFAULTS.refillPerSec),
|
|
36
|
-
globalCapacity: parsePositive(env[ENV.globalCapacity], DEFAULTS.globalCapacity),
|
|
37
|
-
globalRefillPerSec: parsePositive(env[ENV.globalRefillPerSec], DEFAULTS.globalRefillPerSec),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
/** Bound on distinct live per-key buckets before an idle sweep runs. A sweep
|
|
41
|
-
* drops any bucket that has refilled back to full capacity (i.e. been idle
|
|
42
|
-
* long enough to be indistinguishable from a fresh one), so an attacker
|
|
43
|
-
* cycling through unbounded distinct routing keys cannot grow the map without
|
|
44
|
-
* limit — they are already shed by the global ceiling. */
|
|
45
|
-
const MAX_KEY_BUCKETS = 10_000;
|
|
46
|
-
/** Build a token-bucket rate limiter. Each distinct `key` gets its own bucket
|
|
47
|
-
* plus a single shared global bucket; a request must draw one token from BOTH
|
|
48
|
-
* to pass. */
|
|
49
|
-
export function createRateLimiter(config) {
|
|
50
|
-
const now = config.now ?? (() => Date.now());
|
|
51
|
-
const keyBuckets = new Map();
|
|
52
|
-
const globalBucket = { tokens: config.globalCapacity, lastRefillMs: now() };
|
|
53
|
-
function refill(bucket, capacity, refillPerSec, atMs) {
|
|
54
|
-
const elapsedSec = Math.max(0, (atMs - bucket.lastRefillMs) / 1000);
|
|
55
|
-
bucket.tokens = Math.min(capacity, bucket.tokens + elapsedSec * refillPerSec);
|
|
56
|
-
bucket.lastRefillMs = atMs;
|
|
57
|
-
}
|
|
58
|
-
function retryAfterMs(deficit, refillPerSec) {
|
|
59
|
-
return Math.max(1, Math.ceil((deficit / refillPerSec) * 1000));
|
|
60
|
-
}
|
|
61
|
-
/** Drop every key bucket that has refilled to full capacity as of `atMs` —
|
|
62
|
-
* such a bucket is behaviourally identical to a fresh one, so removing it
|
|
63
|
-
* loses no rate state while reclaiming memory. Active (partially-drained)
|
|
64
|
-
* buckets are kept so their limits still bite. */
|
|
65
|
-
function sweepIdle(atMs) {
|
|
66
|
-
for (const [key, bucket] of keyBuckets) {
|
|
67
|
-
refill(bucket, config.capacity, config.refillPerSec, atMs);
|
|
68
|
-
if (bucket.tokens >= config.capacity)
|
|
69
|
-
keyBuckets.delete(key);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
checkRate(key) {
|
|
74
|
-
const atMs = now();
|
|
75
|
-
// Global ceiling first — a flood across many keys is shed even if each
|
|
76
|
-
// individual key is under its own limit.
|
|
77
|
-
refill(globalBucket, config.globalCapacity, config.globalRefillPerSec, atMs);
|
|
78
|
-
if (globalBucket.tokens < 1) {
|
|
79
|
-
return { ok: false, retryAfterMs: retryAfterMs(1 - globalBucket.tokens, config.globalRefillPerSec) };
|
|
80
|
-
}
|
|
81
|
-
let bucket = keyBuckets.get(key);
|
|
82
|
-
if (bucket === undefined) {
|
|
83
|
-
if (keyBuckets.size >= MAX_KEY_BUCKETS)
|
|
84
|
-
sweepIdle(atMs);
|
|
85
|
-
bucket = { tokens: config.capacity, lastRefillMs: atMs };
|
|
86
|
-
keyBuckets.set(key, bucket);
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
refill(bucket, config.capacity, config.refillPerSec, atMs);
|
|
90
|
-
}
|
|
91
|
-
if (bucket.tokens < 1) {
|
|
92
|
-
return { ok: false, retryAfterMs: retryAfterMs(1 - bucket.tokens, config.refillPerSec) };
|
|
93
|
-
}
|
|
94
|
-
// Draw from both buckets only when both admit the request.
|
|
95
|
-
bucket.tokens -= 1;
|
|
96
|
-
globalBucket.tokens -= 1;
|
|
97
|
-
return { ok: true };
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
export type IngressChannel = 'webhook' | 'sms' | 'email' | `chat-${string}` | 'discord-gateway' | 'imap-idle';
|
|
2
|
-
export interface IngressRouteRow {
|
|
3
|
-
route_id: string;
|
|
4
|
-
tenant_id: string;
|
|
5
|
-
user_id: string;
|
|
6
|
-
home_id: string;
|
|
7
|
-
channel: IngressChannel;
|
|
8
|
-
routing_key: string;
|
|
9
|
-
routing_key_kind: string;
|
|
10
|
-
channel_secret_ref: string | null;
|
|
11
|
-
delivery_template: unknown;
|
|
12
|
-
enabled: boolean;
|
|
13
|
-
created_at: string;
|
|
14
|
-
updated_at: string;
|
|
15
|
-
}
|
|
16
|
-
export interface RegisterRouteInput {
|
|
17
|
-
route_id?: string;
|
|
18
|
-
home_id: string;
|
|
19
|
-
channel: IngressChannel;
|
|
20
|
-
routing_key: string;
|
|
21
|
-
routing_key_kind: string;
|
|
22
|
-
channel_secret_ref?: string | null;
|
|
23
|
-
delivery_template: unknown;
|
|
24
|
-
enabled?: boolean;
|
|
25
|
-
now?: string;
|
|
26
|
-
}
|
|
27
|
-
export declare function registerRoute(input: RegisterRouteInput): IngressRouteRow;
|
|
28
|
-
export declare function getRouteById(routeId: string): IngressRouteRow | null;
|
|
29
|
-
export declare function resolveRoute(channel: IngressChannel, routingKey: string): IngressRouteRow | null;
|
|
30
|
-
export declare function listRoutesForUser(tenantId: string, userId: string): IngressRouteRow[];
|
|
31
|
-
export declare function setRouteEnabled(routeId: string, enabled: boolean, now?: string): void;
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { getControlPlaneDb } from '../db.js';
|
|
3
|
-
function nowIso() {
|
|
4
|
-
return new Date().toISOString();
|
|
5
|
-
}
|
|
6
|
-
function homeIdentity(homeId) {
|
|
7
|
-
const row = getControlPlaneDb().prepare('SELECT tenant_id, user_id FROM home WHERE home_id = ?').get(homeId);
|
|
8
|
-
if (row === undefined)
|
|
9
|
-
throw new Error(`home not found: ${homeId}`);
|
|
10
|
-
return row;
|
|
11
|
-
}
|
|
12
|
-
function routeFrom(row) {
|
|
13
|
-
return {
|
|
14
|
-
route_id: row['route_id'],
|
|
15
|
-
tenant_id: row['tenant_id'],
|
|
16
|
-
user_id: row['user_id'],
|
|
17
|
-
home_id: row['home_id'],
|
|
18
|
-
channel: row['channel'],
|
|
19
|
-
routing_key: row['routing_key'],
|
|
20
|
-
routing_key_kind: row['routing_key_kind'],
|
|
21
|
-
channel_secret_ref: row['channel_secret_ref'] ?? null,
|
|
22
|
-
delivery_template: JSON.parse(row['delivery_template']),
|
|
23
|
-
enabled: Number(row['enabled']) !== 0,
|
|
24
|
-
created_at: row['created_at'],
|
|
25
|
-
updated_at: row['updated_at'],
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
export function registerRoute(input) {
|
|
29
|
-
const routeId = input.route_id ?? `route-${randomUUID()}`;
|
|
30
|
-
const stamped = input.now ?? nowIso();
|
|
31
|
-
const identity = homeIdentity(input.home_id);
|
|
32
|
-
getControlPlaneDb()
|
|
33
|
-
.prepare(`INSERT INTO ingress_route (
|
|
34
|
-
route_id, tenant_id, user_id, home_id, channel, routing_key,
|
|
35
|
-
routing_key_kind, channel_secret_ref, delivery_template, enabled,
|
|
36
|
-
created_at, updated_at
|
|
37
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
38
|
-
.run(routeId, identity.tenant_id, identity.user_id, input.home_id, input.channel, input.routing_key, input.routing_key_kind, input.channel_secret_ref ?? null, JSON.stringify(input.delivery_template), input.enabled === false ? 0 : 1, stamped, stamped);
|
|
39
|
-
return getRouteById(routeId);
|
|
40
|
-
}
|
|
41
|
-
export function getRouteById(routeId) {
|
|
42
|
-
const row = getControlPlaneDb().prepare('SELECT * FROM ingress_route WHERE route_id = ?').get(routeId);
|
|
43
|
-
return row === undefined ? null : routeFrom(row);
|
|
44
|
-
}
|
|
45
|
-
export function resolveRoute(channel, routingKey) {
|
|
46
|
-
const row = getControlPlaneDb()
|
|
47
|
-
.prepare('SELECT * FROM ingress_route WHERE channel = ? AND routing_key = ? AND enabled = 1')
|
|
48
|
-
.get(channel, routingKey);
|
|
49
|
-
return row === undefined ? null : routeFrom(row);
|
|
50
|
-
}
|
|
51
|
-
export function listRoutesForUser(tenantId, userId) {
|
|
52
|
-
const rows = getControlPlaneDb()
|
|
53
|
-
.prepare('SELECT * FROM ingress_route WHERE tenant_id = ? AND user_id = ? ORDER BY channel, routing_key')
|
|
54
|
-
.all(tenantId, userId);
|
|
55
|
-
return rows.map(routeFrom);
|
|
56
|
-
}
|
|
57
|
-
export function setRouteEnabled(routeId, enabled, now = nowIso()) {
|
|
58
|
-
getControlPlaneDb()
|
|
59
|
-
.prepare('UPDATE ingress_route SET enabled = ?, updated_at = ? WHERE route_id = ?')
|
|
60
|
-
.run(enabled ? 1 : 0, now, routeId);
|
|
61
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
export type WebhookDeliveryState = 'received' | 'delivered' | 'rejected' | 'failed';
|
|
2
|
-
export interface WebhookDeliveryRow {
|
|
3
|
-
delivery_id: string;
|
|
4
|
-
route_id: string;
|
|
5
|
-
tenant_id: string;
|
|
6
|
-
user_id: string;
|
|
7
|
-
home_id: string;
|
|
8
|
-
replay_key: string;
|
|
9
|
-
replay_window_start: string;
|
|
10
|
-
payload_sha256: string | null;
|
|
11
|
-
signature_sha256: string | null;
|
|
12
|
-
state: WebhookDeliveryState;
|
|
13
|
-
received_at: string;
|
|
14
|
-
delivered_at: string | null;
|
|
15
|
-
last_error: string | null;
|
|
16
|
-
created_at: string;
|
|
17
|
-
updated_at: string;
|
|
18
|
-
}
|
|
19
|
-
export interface ClaimWebhookDeliveryInput {
|
|
20
|
-
delivery_id?: string;
|
|
21
|
-
route_id: string;
|
|
22
|
-
replay_key: string;
|
|
23
|
-
replay_window_start: string;
|
|
24
|
-
payload_sha256?: string | null;
|
|
25
|
-
signature_sha256?: string | null;
|
|
26
|
-
received_at?: string;
|
|
27
|
-
now?: string;
|
|
28
|
-
}
|
|
29
|
-
export interface ClaimWebhookDeliveryResult {
|
|
30
|
-
delivery: WebhookDeliveryRow;
|
|
31
|
-
claimed: boolean;
|
|
32
|
-
}
|
|
33
|
-
export interface SetWebhookDeliveryStateOptions {
|
|
34
|
-
delivered_at?: string | null;
|
|
35
|
-
last_error?: string | null;
|
|
36
|
-
now?: string;
|
|
37
|
-
}
|
|
38
|
-
export declare function claimWebhookDelivery(input: ClaimWebhookDeliveryInput): ClaimWebhookDeliveryResult;
|
|
39
|
-
export declare function getWebhookDeliveryById(deliveryId: string): WebhookDeliveryRow | null;
|
|
40
|
-
export declare function getWebhookDeliveryByReplayKey(routeId: string, replayKey: string, replayWindowStart: string): WebhookDeliveryRow | null;
|
|
41
|
-
export declare function setWebhookDeliveryState(deliveryId: string, state: WebhookDeliveryState, options?: SetWebhookDeliveryStateOptions): void;
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { getControlPlaneDb } from '../db.js';
|
|
3
|
-
function nowIso() {
|
|
4
|
-
return new Date().toISOString();
|
|
5
|
-
}
|
|
6
|
-
function routeIdentity(routeId) {
|
|
7
|
-
const row = getControlPlaneDb()
|
|
8
|
-
.prepare('SELECT home_id, tenant_id, user_id FROM ingress_route WHERE route_id = ?')
|
|
9
|
-
.get(routeId);
|
|
10
|
-
if (row === undefined)
|
|
11
|
-
throw new Error(`ingress route not found: ${routeId}`);
|
|
12
|
-
return row;
|
|
13
|
-
}
|
|
14
|
-
function deliveryFrom(row) {
|
|
15
|
-
return {
|
|
16
|
-
delivery_id: row['delivery_id'],
|
|
17
|
-
route_id: row['route_id'],
|
|
18
|
-
tenant_id: row['tenant_id'],
|
|
19
|
-
user_id: row['user_id'],
|
|
20
|
-
home_id: row['home_id'],
|
|
21
|
-
replay_key: row['replay_key'],
|
|
22
|
-
replay_window_start: row['replay_window_start'],
|
|
23
|
-
payload_sha256: row['payload_sha256'] ?? null,
|
|
24
|
-
signature_sha256: row['signature_sha256'] ?? null,
|
|
25
|
-
state: row['state'],
|
|
26
|
-
received_at: row['received_at'],
|
|
27
|
-
delivered_at: row['delivered_at'] ?? null,
|
|
28
|
-
last_error: row['last_error'] ?? null,
|
|
29
|
-
created_at: row['created_at'],
|
|
30
|
-
updated_at: row['updated_at'],
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
export function claimWebhookDelivery(input) {
|
|
34
|
-
const deliveryId = input.delivery_id ?? `whdel-${randomUUID()}`;
|
|
35
|
-
const stamped = input.now ?? nowIso();
|
|
36
|
-
const receivedAt = input.received_at ?? stamped;
|
|
37
|
-
const identity = routeIdentity(input.route_id);
|
|
38
|
-
const result = getControlPlaneDb()
|
|
39
|
-
.prepare(`INSERT OR IGNORE INTO webhook_delivery (
|
|
40
|
-
delivery_id, route_id, tenant_id, user_id, home_id, replay_key,
|
|
41
|
-
replay_window_start, payload_sha256, signature_sha256, state,
|
|
42
|
-
received_at, delivered_at, last_error, created_at, updated_at
|
|
43
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'received', ?, NULL, NULL, ?, ?)`)
|
|
44
|
-
.run(deliveryId, input.route_id, identity.tenant_id, identity.user_id, identity.home_id, input.replay_key, input.replay_window_start, input.payload_sha256 ?? null, input.signature_sha256 ?? null, receivedAt, stamped, stamped);
|
|
45
|
-
const delivery = getWebhookDeliveryByReplayKey(input.route_id, input.replay_key, input.replay_window_start);
|
|
46
|
-
if (delivery === null) {
|
|
47
|
-
throw new Error(`failed to claim webhook delivery for route ${input.route_id} replay key ${input.replay_key}`);
|
|
48
|
-
}
|
|
49
|
-
return { delivery, claimed: result.changes === 1 };
|
|
50
|
-
}
|
|
51
|
-
export function getWebhookDeliveryById(deliveryId) {
|
|
52
|
-
const row = getControlPlaneDb().prepare('SELECT * FROM webhook_delivery WHERE delivery_id = ?').get(deliveryId);
|
|
53
|
-
return row === undefined ? null : deliveryFrom(row);
|
|
54
|
-
}
|
|
55
|
-
export function getWebhookDeliveryByReplayKey(routeId, replayKey, replayWindowStart) {
|
|
56
|
-
const row = getControlPlaneDb()
|
|
57
|
-
.prepare('SELECT * FROM webhook_delivery WHERE route_id = ? AND replay_key = ? AND replay_window_start = ?')
|
|
58
|
-
.get(routeId, replayKey, replayWindowStart);
|
|
59
|
-
return row === undefined ? null : deliveryFrom(row);
|
|
60
|
-
}
|
|
61
|
-
export function setWebhookDeliveryState(deliveryId, state, options = {}) {
|
|
62
|
-
const stamped = options.now ?? nowIso();
|
|
63
|
-
const deliveredAt = options.delivered_at === undefined ? (state === 'delivered' ? stamped : null) : options.delivered_at;
|
|
64
|
-
getControlPlaneDb()
|
|
65
|
-
.prepare(`UPDATE webhook_delivery
|
|
66
|
-
SET state = ?, delivered_at = COALESCE(?, delivered_at), last_error = ?, updated_at = ?
|
|
67
|
-
WHERE delivery_id = ?`)
|
|
68
|
-
.run(state, deliveredAt, options.last_error ?? null, stamped, deliveryId);
|
|
69
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { buildTriggerEnvelope } from '../triggers/envelope.js';
|
|
3
|
-
import { type DeliverTriggerResult } from '../triggers/deliver.js';
|
|
4
|
-
import { type IngressRouteRow } from './route-store.js';
|
|
5
|
-
import { claimWebhookDelivery, setWebhookDeliveryState } from './webhook-delivery-store.js';
|
|
6
|
-
import { type RateCheckResult } from './rate-limit.js';
|
|
7
|
-
export interface WebhookProcessDeps {
|
|
8
|
-
checkRate: (key: string) => RateCheckResult;
|
|
9
|
-
resolveRoute: (channel: 'webhook', routingKey: string) => IngressRouteRow | null;
|
|
10
|
-
resolveSecret: (ref: string) => string;
|
|
11
|
-
claimWebhookDelivery: typeof claimWebhookDelivery;
|
|
12
|
-
setWebhookDeliveryState: typeof setWebhookDeliveryState;
|
|
13
|
-
deliverTrigger: (input: {
|
|
14
|
-
home_id: string;
|
|
15
|
-
source_ref: string;
|
|
16
|
-
envelope: ReturnType<typeof buildTriggerEnvelope>;
|
|
17
|
-
tier: 'urgent';
|
|
18
|
-
payload_sha256?: string;
|
|
19
|
-
metadata?: Record<string, unknown>;
|
|
20
|
-
}) => Promise<DeliverTriggerResult>;
|
|
21
|
-
now: () => Date;
|
|
22
|
-
}
|
|
23
|
-
export interface WebhookRequest {
|
|
24
|
-
routingKey: string;
|
|
25
|
-
rawBody: Buffer;
|
|
26
|
-
signatureHeader: string | null;
|
|
27
|
-
deliveryIdHeader: string | null;
|
|
28
|
-
contentType: string | null;
|
|
29
|
-
}
|
|
30
|
-
export interface WebhookResult {
|
|
31
|
-
status: number;
|
|
32
|
-
message: string;
|
|
33
|
-
retryAfterMs?: number;
|
|
34
|
-
}
|
|
35
|
-
/** Production defaults — real rate limiter, stores, secret resolver, and
|
|
36
|
-
* delivery chokepoint. Tests inject fakes. */
|
|
37
|
-
export declare function defaultWebhookProcessDeps(): WebhookProcessDeps;
|
|
38
|
-
/**
|
|
39
|
-
* The verification + delivery core, independent of HTTP plumbing so the
|
|
40
|
-
* security invariants (signature, replay, no-routing-key) are directly
|
|
41
|
-
* testable. Returns the status + message the HTTP layer writes back.
|
|
42
|
-
*/
|
|
43
|
-
export declare function processWebhookDelivery(req: WebhookRequest, deps: WebhookProcessDeps): Promise<WebhookResult>;
|
|
44
|
-
/** Extract the single `routing_key` path segment from `/webhook/<key>`.
|
|
45
|
-
* Rejects an empty key, extra segments, embedded slash/backslash, or
|
|
46
|
-
* malformed percent-encoding. */
|
|
47
|
-
export declare function extractRoutingKey(pathname: string): string | null;
|
|
48
|
-
/**
|
|
49
|
-
* HTTP handler for `POST /webhook/:routing_key`. Reads the raw body (bounded),
|
|
50
|
-
* extracts the routing key + signature/delivery headers, runs
|
|
51
|
-
* `processWebhookDelivery`, and writes the resulting status. This is a PUBLIC
|
|
52
|
-
* endpoint — its only authentication is the per-route HMAC signature, so it
|
|
53
|
-
* must be mounted OUTSIDE the session/registry gate.
|
|
54
|
-
*/
|
|
55
|
-
export declare function handleWebhook(req: IncomingMessage, res: ServerResponse, deps?: WebhookProcessDeps): Promise<void>;
|
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
// control-plane/ingress/webhook-route.ts — the generic webhook ingress
|
|
2
|
-
// endpoint (master plan P5.5 / part-plan T-C3): `POST /webhook/:routing_key`.
|
|
3
|
-
// The ONLY ingress channel mounted this increment (SMS/email/chat/persistent-
|
|
4
|
-
// socket connectors are out of scope).
|
|
5
|
-
//
|
|
6
|
-
// Order of operations (security-load-bearing):
|
|
7
|
-
// 1. PRE-RESOLUTION rate limit on the routing key — a flood is shed before
|
|
8
|
-
// any DB lookup / secret resolution / write.
|
|
9
|
-
// 2. Route lookup (`resolveRoute('webhook', key)`) — unknown key → 404, no
|
|
10
|
-
// delivery.
|
|
11
|
-
// 3. HMAC-SHA256 over the RAW request body using the route's resolved
|
|
12
|
-
// signing secret (`channel_secret_ref` → `resolveSecret`), constant-time
|
|
13
|
-
// compared. The secret is DISTINCT from the routing key (the key is
|
|
14
|
-
// public/demux-only). Missing/invalid signature → reject, no delivery.
|
|
15
|
-
// A bad signature never writes a `webhook_delivery` row (no amplification).
|
|
16
|
-
// 4. Replay/idempotency via `webhook_delivery` (unique per route + replay
|
|
17
|
-
// key + window). A duplicate is acknowledged idempotently and NOT
|
|
18
|
-
// re-delivered.
|
|
19
|
-
// 5. Build the trigger envelope — `trigger_id` is route-derived + per-
|
|
20
|
-
// delivery (`<route_id>:<replay_key>`) for stable home-side dedupe. The
|
|
21
|
-
// ROUTING KEY IS NEVER PLACED IN THE ENVELOPE (R-ING-3).
|
|
22
|
-
// 6. Deliver through the shared `deliverTrigger` chokepoint at tier
|
|
23
|
-
// `urgent` (R-TRIG-3) — which authorizes, wakes, exec's in-guest, audits.
|
|
24
|
-
//
|
|
25
|
-
// The raw public body is never persisted: `webhook_delivery`/`trigger_audit`
|
|
26
|
-
// carry only content hashes + bounded metadata.
|
|
27
|
-
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
28
|
-
import { buildTriggerEnvelope } from '../triggers/envelope.js';
|
|
29
|
-
import { deliverTrigger } from '../triggers/deliver.js';
|
|
30
|
-
import { resolveSecret } from '../secrets.js';
|
|
31
|
-
import { resolveRoute } from './route-store.js';
|
|
32
|
-
import { claimWebhookDelivery, setWebhookDeliveryState, } from './webhook-delivery-store.js';
|
|
33
|
-
import { createRateLimiter, loadRateLimiterConfig } from './rate-limit.js';
|
|
34
|
-
const ENV = {
|
|
35
|
-
maxBodyBytes: 'CRTR_HEARTH_CP_WEBHOOK_MAX_BODY_BYTES',
|
|
36
|
-
};
|
|
37
|
-
const DEFAULT_MAX_BODY_BYTES = 1_048_576; // 1 MiB
|
|
38
|
-
/** Canonical signing header (hex HMAC-SHA256 of the raw body). The GitHub-
|
|
39
|
-
* style `x-hub-signature-256: sha256=<hex>` header is also accepted for
|
|
40
|
-
* compatibility; both are verified identically against the raw body. */
|
|
41
|
-
const SIGNATURE_HEADERS = ['x-hearth-signature-256', 'x-hub-signature-256', 'x-signature-256'];
|
|
42
|
-
const DELIVERY_ID_HEADERS = ['x-hearth-delivery-id', 'x-idempotency-key', 'x-github-delivery'];
|
|
43
|
-
const sharedRateLimiter = createRateLimiter(loadRateLimiterConfig());
|
|
44
|
-
/** Production defaults — real rate limiter, stores, secret resolver, and
|
|
45
|
-
* delivery chokepoint. Tests inject fakes. */
|
|
46
|
-
export function defaultWebhookProcessDeps() {
|
|
47
|
-
return {
|
|
48
|
-
checkRate: (key) => sharedRateLimiter.checkRate(key),
|
|
49
|
-
resolveRoute: (channel, routingKey) => resolveRoute(channel, routingKey),
|
|
50
|
-
resolveSecret,
|
|
51
|
-
claimWebhookDelivery,
|
|
52
|
-
setWebhookDeliveryState,
|
|
53
|
-
deliverTrigger: (input) => deliverTrigger(input),
|
|
54
|
-
now: () => new Date(),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
/** Coarse hour bucket bounding the replay table + defining the replay window. */
|
|
58
|
-
function replayWindowStart(now) {
|
|
59
|
-
const bucket = new Date(now.getTime());
|
|
60
|
-
bucket.setUTCMinutes(0, 0, 0);
|
|
61
|
-
return bucket.toISOString();
|
|
62
|
-
}
|
|
63
|
-
/** Strip an optional `sha256=` prefix and normalize to lowercase hex. */
|
|
64
|
-
function normalizeSignature(raw) {
|
|
65
|
-
const trimmed = raw.trim();
|
|
66
|
-
const eq = trimmed.indexOf('=');
|
|
67
|
-
const value = trimmed.slice(0, eq).toLowerCase() === 'sha256' ? trimmed.slice(eq + 1) : trimmed;
|
|
68
|
-
return value.trim().toLowerCase();
|
|
69
|
-
}
|
|
70
|
-
/** Constant-time HMAC-SHA256 verification over the raw body. Returns false on
|
|
71
|
-
* any malformed/absent signature or length mismatch — never throws. */
|
|
72
|
-
function verifySignature(secret, rawBody, signatureHeader) {
|
|
73
|
-
if (signatureHeader === null || signatureHeader.trim() === '')
|
|
74
|
-
return false;
|
|
75
|
-
const provided = normalizeSignature(signatureHeader);
|
|
76
|
-
if (!/^[0-9a-f]+$/.test(provided) || provided.length % 2 !== 0)
|
|
77
|
-
return false;
|
|
78
|
-
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
79
|
-
const providedBuf = Buffer.from(provided, 'hex');
|
|
80
|
-
const expectedBuf = Buffer.from(expected, 'hex');
|
|
81
|
-
if (providedBuf.length !== expectedBuf.length)
|
|
82
|
-
return false;
|
|
83
|
-
return timingSafeEqual(providedBuf, expectedBuf);
|
|
84
|
-
}
|
|
85
|
-
function sha256Hex(input) {
|
|
86
|
-
return createHash('sha256').update(input).digest('hex');
|
|
87
|
-
}
|
|
88
|
-
/** Parse the untrusted body into the envelope payload. JSON when the content
|
|
89
|
-
* type is JSON and it parses; otherwise the raw UTF-8 string. Malformed JSON
|
|
90
|
-
* is delivered as the raw string rather than rejected — payload is untrusted
|
|
91
|
-
* data the home narrows defensively, not something ingress validates. */
|
|
92
|
-
function parsePayload(rawBody, contentType) {
|
|
93
|
-
const text = rawBody.toString('utf8');
|
|
94
|
-
if (contentType !== null && /application\/(?:[\w.+-]+\+)?json/i.test(contentType)) {
|
|
95
|
-
try {
|
|
96
|
-
return JSON.parse(text);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return text;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return text;
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* The verification + delivery core, independent of HTTP plumbing so the
|
|
106
|
-
* security invariants (signature, replay, no-routing-key) are directly
|
|
107
|
-
* testable. Returns the status + message the HTTP layer writes back.
|
|
108
|
-
*/
|
|
109
|
-
export async function processWebhookDelivery(req, deps) {
|
|
110
|
-
// 1. Pre-resolution rate limit.
|
|
111
|
-
const rate = deps.checkRate(req.routingKey);
|
|
112
|
-
if (!rate.ok) {
|
|
113
|
-
return { status: 429, message: 'rate limited', retryAfterMs: rate.retryAfterMs };
|
|
114
|
-
}
|
|
115
|
-
// 2. Route lookup.
|
|
116
|
-
const route = deps.resolveRoute('webhook', req.routingKey);
|
|
117
|
-
if (route === null || route.channel !== 'webhook') {
|
|
118
|
-
return { status: 404, message: 'unknown webhook route' };
|
|
119
|
-
}
|
|
120
|
-
// 3. Signature verification over the raw body. A route with no signing
|
|
121
|
-
// secret cannot be verified and is treated as unverifiable — no delivery.
|
|
122
|
-
if (route.channel_secret_ref === null || route.channel_secret_ref.trim() === '') {
|
|
123
|
-
return { status: 503, message: 'webhook route not verifiable' };
|
|
124
|
-
}
|
|
125
|
-
let secret;
|
|
126
|
-
try {
|
|
127
|
-
secret = deps.resolveSecret(route.channel_secret_ref);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return { status: 503, message: 'webhook route not verifiable' };
|
|
131
|
-
}
|
|
132
|
-
if (!verifySignature(secret, req.rawBody, req.signatureHeader)) {
|
|
133
|
-
return { status: 401, message: 'invalid signature' };
|
|
134
|
-
}
|
|
135
|
-
// 4. Replay / idempotency. A bad signature never reached here, so no
|
|
136
|
-
// delivery row is ever written for an unverified request.
|
|
137
|
-
const now = deps.now();
|
|
138
|
-
const payloadSha256 = sha256Hex(req.rawBody);
|
|
139
|
-
const signatureSha256 = req.signatureHeader === null ? null : sha256Hex(req.signatureHeader);
|
|
140
|
-
const replayKey = req.deliveryIdHeader !== null && req.deliveryIdHeader.trim() !== '' ? req.deliveryIdHeader.trim() : payloadSha256;
|
|
141
|
-
const windowStart = replayWindowStart(now);
|
|
142
|
-
const claim = deps.claimWebhookDelivery({
|
|
143
|
-
route_id: route.route_id,
|
|
144
|
-
replay_key: replayKey,
|
|
145
|
-
replay_window_start: windowStart,
|
|
146
|
-
payload_sha256: payloadSha256,
|
|
147
|
-
signature_sha256: signatureSha256,
|
|
148
|
-
received_at: now.toISOString(),
|
|
149
|
-
});
|
|
150
|
-
if (!claim.claimed) {
|
|
151
|
-
return { status: 200, message: 'duplicate delivery ignored' };
|
|
152
|
-
}
|
|
153
|
-
// 5. Envelope — routing key deliberately absent (R-ING-3); trigger_id is
|
|
154
|
-
// route-derived + per-delivery for stable home-side dedupe.
|
|
155
|
-
const envelope = buildTriggerEnvelope({
|
|
156
|
-
source: 'webhook',
|
|
157
|
-
trigger_id: `${route.route_id}:${replayKey}`,
|
|
158
|
-
payload: parsePayload(req.rawBody, req.contentType),
|
|
159
|
-
received_at: now.toISOString(),
|
|
160
|
-
});
|
|
161
|
-
// 6. Deliver through the shared chokepoint (authorize + wake + exec + audit).
|
|
162
|
-
const outcome = await deps.deliverTrigger({
|
|
163
|
-
home_id: route.home_id,
|
|
164
|
-
source_ref: route.route_id,
|
|
165
|
-
envelope,
|
|
166
|
-
tier: 'urgent',
|
|
167
|
-
payload_sha256: payloadSha256,
|
|
168
|
-
metadata: { byte_length: req.rawBody.length, content_type: req.contentType },
|
|
169
|
-
});
|
|
170
|
-
if (outcome.outcome === 'delivered') {
|
|
171
|
-
deps.setWebhookDeliveryState(claim.delivery.delivery_id, 'delivered');
|
|
172
|
-
return { status: 202, message: 'delivered' };
|
|
173
|
-
}
|
|
174
|
-
if (outcome.outcome === 'rejected') {
|
|
175
|
-
deps.setWebhookDeliveryState(claim.delivery.delivery_id, 'rejected', { last_error: outcome.reason ?? 'rejected' });
|
|
176
|
-
return { status: 403, message: 'delivery rejected' };
|
|
177
|
-
}
|
|
178
|
-
deps.setWebhookDeliveryState(claim.delivery.delivery_id, 'failed', { last_error: outcome.reason ?? 'failed' });
|
|
179
|
-
return { status: 502, message: 'delivery failed' };
|
|
180
|
-
}
|
|
181
|
-
/** Extract the single `routing_key` path segment from `/webhook/<key>`.
|
|
182
|
-
* Rejects an empty key, extra segments, embedded slash/backslash, or
|
|
183
|
-
* malformed percent-encoding. */
|
|
184
|
-
export function extractRoutingKey(pathname) {
|
|
185
|
-
const match = /^\/webhook\/([^/]+)$/.exec(pathname);
|
|
186
|
-
if (match === null)
|
|
187
|
-
return null;
|
|
188
|
-
const raw = match[1];
|
|
189
|
-
if (raw.includes('\\'))
|
|
190
|
-
return null;
|
|
191
|
-
let key;
|
|
192
|
-
try {
|
|
193
|
-
key = decodeURIComponent(raw);
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
if (key === '' || /[/\\]/.test(key))
|
|
199
|
-
return null;
|
|
200
|
-
return key;
|
|
201
|
-
}
|
|
202
|
-
function firstHeader(req, names) {
|
|
203
|
-
for (const name of names) {
|
|
204
|
-
const value = req.headers[name];
|
|
205
|
-
if (typeof value === 'string' && value.trim() !== '')
|
|
206
|
-
return value;
|
|
207
|
-
if (Array.isArray(value) && value.length > 0 && value[0].trim() !== '')
|
|
208
|
-
return value[0];
|
|
209
|
-
}
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
function readBody(req, maxBytes) {
|
|
213
|
-
return new Promise((resolve, reject) => {
|
|
214
|
-
const chunks = [];
|
|
215
|
-
let total = 0;
|
|
216
|
-
let tooLarge = false;
|
|
217
|
-
req.on('data', (chunk) => {
|
|
218
|
-
total += chunk.length;
|
|
219
|
-
if (total > maxBytes) {
|
|
220
|
-
tooLarge = true;
|
|
221
|
-
req.destroy();
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
chunks.push(chunk);
|
|
225
|
-
});
|
|
226
|
-
req.on('end', () => resolve({ body: Buffer.concat(chunks), tooLarge }));
|
|
227
|
-
req.on('error', (error) => {
|
|
228
|
-
if (tooLarge) {
|
|
229
|
-
resolve({ body: Buffer.alloc(0), tooLarge: true });
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
function maxBodyBytes(env = process.env) {
|
|
237
|
-
const raw = env[ENV.maxBodyBytes]?.trim();
|
|
238
|
-
if (raw === undefined || raw === '')
|
|
239
|
-
return DEFAULT_MAX_BODY_BYTES;
|
|
240
|
-
const parsed = Number(raw);
|
|
241
|
-
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
242
|
-
return DEFAULT_MAX_BODY_BYTES;
|
|
243
|
-
return parsed;
|
|
244
|
-
}
|
|
245
|
-
function sendText(res, status, message, retryAfterMs) {
|
|
246
|
-
const headers = {
|
|
247
|
-
'content-type': 'text/plain; charset=utf-8',
|
|
248
|
-
'cache-control': 'no-store',
|
|
249
|
-
'referrer-policy': 'no-referrer',
|
|
250
|
-
};
|
|
251
|
-
if (retryAfterMs !== undefined)
|
|
252
|
-
headers['retry-after'] = String(Math.ceil(retryAfterMs / 1000));
|
|
253
|
-
const body = `${message}\n`;
|
|
254
|
-
headers['content-length'] = String(Buffer.byteLength(body));
|
|
255
|
-
res.writeHead(status, headers);
|
|
256
|
-
res.end(body);
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* HTTP handler for `POST /webhook/:routing_key`. Reads the raw body (bounded),
|
|
260
|
-
* extracts the routing key + signature/delivery headers, runs
|
|
261
|
-
* `processWebhookDelivery`, and writes the resulting status. This is a PUBLIC
|
|
262
|
-
* endpoint — its only authentication is the per-route HMAC signature, so it
|
|
263
|
-
* must be mounted OUTSIDE the session/registry gate.
|
|
264
|
-
*/
|
|
265
|
-
export async function handleWebhook(req, res, deps = defaultWebhookProcessDeps()) {
|
|
266
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
267
|
-
const routingKey = extractRoutingKey(url.pathname);
|
|
268
|
-
if (routingKey === null) {
|
|
269
|
-
sendText(res, 404, 'unknown webhook route');
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
const { body, tooLarge } = await readBody(req, maxBodyBytes());
|
|
273
|
-
if (tooLarge) {
|
|
274
|
-
sendText(res, 413, 'payload too large');
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
const result = await processWebhookDelivery({
|
|
278
|
-
routingKey,
|
|
279
|
-
rawBody: body,
|
|
280
|
-
signatureHeader: firstHeader(req, SIGNATURE_HEADERS),
|
|
281
|
-
deliveryIdHeader: firstHeader(req, DELIVERY_ID_HEADERS),
|
|
282
|
-
contentType: firstHeader(req, ['content-type']),
|
|
283
|
-
}, deps);
|
|
284
|
-
sendText(res, result.status, result.message, result.retryAfterMs);
|
|
285
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|