@crouton-kit/crouter 0.3.40 → 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.
Files changed (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /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 {};