@crouton-kit/crouter 0.3.40 → 0.3.42

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 (195) 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 +720 -747
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/attach/view-socket.d.ts +8 -1
  9. package/dist/clients/attach/view-socket.js +15 -1
  10. package/dist/clients/web/server.js +5 -28
  11. package/dist/clients/web/web-cmd.js +1 -1
  12. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  13. package/dist/commands/canvas-rebuild-index.js +5 -5
  14. package/dist/commands/memory/shared.d.ts +7 -3
  15. package/dist/commands/memory/shared.js +35 -5
  16. package/dist/commands/memory/write.js +5 -3
  17. package/dist/commands/node-snapshot.js +9 -1
  18. package/dist/commands/node.js +37 -17
  19. package/dist/commands/push.js +8 -0
  20. package/dist/commands/revive.d.ts +10 -0
  21. package/dist/commands/revive.js +28 -10
  22. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  23. package/dist/commands/sys/doctor.js +1 -1
  24. package/dist/commands/sys/setup-core.js +3 -2
  25. package/dist/commands/sys/setup.js +1 -1
  26. package/dist/commands/worktree.d.ts +2 -0
  27. package/dist/commands/worktree.js +94 -0
  28. package/dist/core/__tests__/boot.test.js +4 -4
  29. package/dist/core/__tests__/canvas.test.js +19 -7
  30. package/dist/core/__tests__/child-followup.test.js +15 -5
  31. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  32. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  33. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  36. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  37. package/dist/core/__tests__/grace-clock.test.js +18 -2
  38. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  39. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  40. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  41. package/dist/core/__tests__/revive.test.js +18 -4
  42. package/dist/core/__tests__/session-cycles.test.js +77 -0
  43. package/dist/core/__tests__/worktree.test.js +85 -0
  44. package/dist/core/canvas/boot.js +12 -7
  45. package/dist/core/canvas/canvas.d.ts +27 -8
  46. package/dist/core/canvas/canvas.js +54 -26
  47. package/dist/core/canvas/db.js +14 -0
  48. package/dist/core/canvas/history.js +1 -0
  49. package/dist/core/canvas/paths.d.ts +10 -9
  50. package/dist/core/canvas/paths.js +10 -9
  51. package/dist/core/canvas/pid.d.ts +155 -1
  52. package/dist/core/canvas/pid.js +306 -1
  53. package/dist/core/canvas/status-glyph.d.ts +7 -0
  54. package/dist/core/canvas/status-glyph.js +10 -1
  55. package/dist/core/canvas/types.d.ts +34 -0
  56. package/dist/core/fault-classifier.js +5 -1
  57. package/dist/core/profiles/select.d.ts +4 -2
  58. package/dist/core/profiles/select.js +30 -4
  59. package/dist/core/runtime/bearings.js +4 -0
  60. package/dist/core/runtime/branded-host.d.ts +7 -0
  61. package/dist/core/runtime/branded-host.js +44 -17
  62. package/dist/core/runtime/broker-sdk.js +28 -68
  63. package/dist/core/runtime/broker.js +35 -4
  64. package/dist/core/runtime/host.d.ts +3 -3
  65. package/dist/core/runtime/host.js +148 -33
  66. package/dist/core/runtime/launch.d.ts +15 -15
  67. package/dist/core/runtime/launch.js +58 -4
  68. package/dist/core/runtime/naming.js +3 -2
  69. package/dist/core/runtime/nodes.d.ts +3 -1
  70. package/dist/core/runtime/nodes.js +1 -0
  71. package/dist/core/runtime/pi-cli.d.ts +2 -0
  72. package/dist/core/runtime/pi-cli.js +51 -0
  73. package/dist/core/runtime/placement.d.ts +1 -1
  74. package/dist/core/runtime/placement.js +5 -2
  75. package/dist/core/runtime/recap.js +2 -1
  76. package/dist/core/runtime/revive.d.ts +21 -15
  77. package/dist/core/runtime/revive.js +87 -52
  78. package/dist/core/runtime/session-cycles.d.ts +30 -0
  79. package/dist/core/runtime/session-cycles.js +77 -0
  80. package/dist/core/runtime/spawn.d.ts +4 -0
  81. package/dist/core/runtime/spawn.js +152 -100
  82. package/dist/core/runtime/tmux.d.ts +7 -10
  83. package/dist/core/runtime/tmux.js +9 -11
  84. package/dist/core/worktree.d.ts +35 -0
  85. package/dist/core/worktree.js +158 -0
  86. package/dist/daemon/crtrd.d.ts +17 -8
  87. package/dist/daemon/crtrd.js +191 -40
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.js +7 -0
  90. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  91. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  92. package/dist/pi-extensions/canvas-stophook.js +11 -5
  93. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  94. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  95. package/dist/web-client/index.html +2 -2
  96. package/docs/compat/hearth-crtr-v1.md +191 -0
  97. package/docs/public-api.md +75 -0
  98. package/package.json +3 -4
  99. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  100. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  101. package/dist/core/hearth/config.d.ts +0 -3
  102. package/dist/core/hearth/config.js +0 -108
  103. package/dist/core/hearth/guest-env.d.ts +0 -9
  104. package/dist/core/hearth/guest-env.js +0 -27
  105. package/dist/core/hearth/index.d.ts +0 -4
  106. package/dist/core/hearth/index.js +0 -4
  107. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  108. package/dist/core/hearth/model-auth-guest.js +0 -430
  109. package/dist/core/hearth/provider.d.ts +0 -36
  110. package/dist/core/hearth/provider.js +0 -10
  111. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  112. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  113. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  114. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  115. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  116. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  117. package/dist/core/hearth/providers/blaxel.js +0 -364
  118. package/dist/core/hearth/providers/types.d.ts +0 -93
  119. package/dist/core/hearth/types.d.ts +0 -155
  120. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  121. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  122. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  123. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  124. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  125. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  126. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  127. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  128. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  129. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  130. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  131. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  132. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  133. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  134. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  135. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  136. package/dist/hearth/control-plane/config.d.ts +0 -21
  137. package/dist/hearth/control-plane/config.js +0 -77
  138. package/dist/hearth/control-plane/db.d.ts +0 -30
  139. package/dist/hearth/control-plane/db.js +0 -561
  140. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  141. package/dist/hearth/control-plane/hearth-target.js +0 -68
  142. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  143. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  144. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  145. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  146. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  147. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  148. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  149. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  150. package/dist/hearth/control-plane/main.d.ts +0 -1
  151. package/dist/hearth/control-plane/main.js +0 -88
  152. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  153. package/dist/hearth/control-plane/node-message.js +0 -98
  154. package/dist/hearth/control-plane/register.d.ts +0 -15
  155. package/dist/hearth/control-plane/register.js +0 -34
  156. package/dist/hearth/control-plane/registry.d.ts +0 -22
  157. package/dist/hearth/control-plane/registry.js +0 -168
  158. package/dist/hearth/control-plane/relay.d.ts +0 -44
  159. package/dist/hearth/control-plane/relay.js +0 -711
  160. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  161. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  162. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  163. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  164. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  165. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  166. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  167. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  168. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  169. package/dist/hearth/control-plane/secrets.js +0 -134
  170. package/dist/hearth/control-plane/server.d.ts +0 -27
  171. package/dist/hearth/control-plane/server.js +0 -482
  172. package/dist/hearth/control-plane/serving.d.ts +0 -15
  173. package/dist/hearth/control-plane/serving.js +0 -106
  174. package/dist/hearth/control-plane/session.d.ts +0 -68
  175. package/dist/hearth/control-plane/session.js +0 -273
  176. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  177. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  178. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  179. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  180. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  181. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  182. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  183. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  184. package/dist/hearth/control-plane/types.d.ts +0 -86
  185. package/dist/hearth/control-plane/types.js +0 -1
  186. package/dist/hearth/control-plane/wake.d.ts +0 -86
  187. package/dist/hearth/control-plane/wake.js +0 -550
  188. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  189. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  190. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  191. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  192. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  193. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  194. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  195. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -1,482 +0,0 @@
1
- // control-plane/server.ts — the CP front door. Generalizes the wake-proxy's
2
- // server.ts (route dispatch) and proxy.ts's gate ordering into the CP shape:
3
- // session-then-registry resolution before anything ever reaches a home, and
4
- // a swappable relay dispatch boundary for the dynamic lanes the actual
5
- // HTTP+WS relay engine (P4.3) will fill in.
6
- //
7
- // Route tree (master plan P4.1 / part-plan T2.2). Every dynamic lane is an
8
- // exact method+path entry in `DYNAMIC_ROUTES` below; anything else on a
9
- // known dynamic-lane path is a 405, and anything unrecognized is a 404 —
10
- // neither ever reaches the relay:
11
- // GET /__hearth/healthz -> liveness, no auth
12
- // GET/HEAD / -> resolveTenantSession ->
13
- // resolveHome -> 302 to
14
- // the existing in-guest
15
- // client path carrying
16
- // ONLY the
17
- // server-derived
18
- // `home_agent_target`;
19
- // absent/invalid session
20
- // or unresolved home
21
- // never reaches a home
22
- // (no relay/wake call)
23
- // WS /node/<id> -> dynamic lane, upgrade
24
- // only (never a normal
25
- // HTTP dispatcher entry),
26
- // gated by the same
27
- // write-capable auth as
28
- // write lanes (trusted-
29
- // Origin cookie OR
30
- // no-Origin Bearer) and
31
- // an exact single-segment
32
- // `<id>` matcher
33
- // POST /__crtr/source -> dynamic lane, write-auth
34
- // GET /__crtr/events -> dynamic lane, read-auth
35
- // GET /__hearth/model-auth/anthropic/status -> dynamic lane, read-auth
36
- // POST /__hearth/model-auth/anthropic/start -> dynamic lane, write-auth
37
- // POST /__hearth/model-auth/anthropic/finish -> dynamic lane, write-auth
38
- // GET/HEAD /views/<view> -> dynamic lane, read-auth
39
- // (the SPA shell `GET /`
40
- // redirects to)
41
- // GET/HEAD <static asset path> -> dynamic lane, read-auth
42
- // (the JS/CSS/image
43
- // assets the view shell
44
- // loads) — still gated by
45
- // session+registry, NOT a
46
- // public/unauthenticated
47
- // allowlist
48
- //
49
- // The relay engine itself (ensureAwake + HTTP/WS forwarding to
50
- // preview_endpoint) is P4.2/P4.3 scope, not this file's. The `RelayDispatcher`
51
- // seam below is the stub boundary: every dynamic-lane request is gated by
52
- // session(+write-origin where required)+registry resolution here, then
53
- // handed to the dispatcher, which today simply reports "not implemented yet"
54
- // without contacting any home.
55
- import { createServer } from 'node:http';
56
- import { createHash, timingSafeEqual } from 'node:crypto';
57
- import { hearthHomeImageRefForVersion } from '../../core/hearth/config.js';
58
- import { getControlPlaneDb } from './db.js';
59
- import { resolveHome } from './registry.js';
60
- import { setHearthTarget } from './hearth-target.js';
61
- import { resolveTenantSession, resolveTenantWriteAuth, swapQueryTokenForCookie, tenantSessionAuthRedactedMessage } from './session.js';
62
- import { redactSecrets, resolveCpAdminToken } from './secrets.js';
63
- import { beginOAuth, endOAuth } from './serving.js';
64
- import { handleWebhookRequest, isWebhookPath, isWebhookRequest } from './register.js';
65
- const notImplementedRelay = {
66
- proxyHttp(_req, res) {
67
- sendText(res, 501, 'hearth control-plane relay is not implemented yet (lands in P4.3)\n');
68
- },
69
- proxyUpgrade(_req, socket) {
70
- socket.destroy();
71
- },
72
- };
73
- function requestUrl(req) {
74
- return new URL(req.url ?? '/', 'http://127.0.0.1');
75
- }
76
- function sendText(res, statusCode, body) {
77
- res.writeHead(statusCode, {
78
- 'content-type': 'text/plain; charset=utf-8',
79
- 'content-length': Buffer.byteLength(body),
80
- 'cache-control': 'no-store',
81
- 'referrer-policy': 'no-referrer',
82
- });
83
- res.end(body);
84
- }
85
- function redirectChat(res, target) {
86
- res.writeHead(302, {
87
- location: `/views/chat?target=${encodeURIComponent(target)}`,
88
- 'cache-control': 'no-store',
89
- 'referrer-policy': 'no-referrer',
90
- });
91
- res.end();
92
- }
93
- function respondHealthz(res) {
94
- sendText(res, 200, 'ok\n');
95
- }
96
- const ADMIN_HEARTH_IMAGE_PATH = '/__admin/hearth-image';
97
- // The auto-upgrade notify body is tiny ({template_version, image_ref}); cap
98
- // hard so a malformed/oversized POST can't buffer unbounded.
99
- const ADMIN_BODY_MAX_BYTES = 4 * 1024;
100
- const STRICT_SEMVER_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
101
- /** Fixed-length digest of a UTF-8 string — always 32 bytes (sha256), so
102
- * comparing two digests never branches on the ORIGINAL strings' lengths. */
103
- function sha256(value) {
104
- return createHash('sha256').update(value, 'utf8').digest();
105
- }
106
- /** Constant-time bearer check against the configured CP admin token. Fails
107
- * CLOSED: no `Authorization: Bearer` header, or no token configured on the
108
- * CP, or a mismatch — all return false without leaking which. Compares
109
- * sha256 digests (always 32 bytes each) rather than the raw token buffers,
110
- * so there is no length-inequality short-circuit that would leak the
111
- * configured token's length before `timingSafeEqual` runs — equal plaintext
112
- * still implies equal digest, and every digest is the same size regardless
113
- * of what was presented. */
114
- function isAdminAuthorized(req) {
115
- const configured = resolveCpAdminToken();
116
- if (configured === null || configured === '')
117
- return false;
118
- const header = req.headers.authorization;
119
- if (typeof header !== 'string')
120
- return false;
121
- const match = /^Bearer[ ]+(.+)$/.exec(header.trim());
122
- if (match === null)
123
- return false;
124
- return timingSafeEqual(sha256(match[1]), sha256(configured));
125
- }
126
- function readRequestBody(req, maxBytes) {
127
- return new Promise((resolve, reject) => {
128
- const chunks = [];
129
- let total = 0;
130
- let tooLarge = false;
131
- req.on('data', (chunk) => {
132
- total += chunk.length;
133
- if (total > maxBytes) {
134
- tooLarge = true;
135
- req.destroy();
136
- return;
137
- }
138
- chunks.push(chunk);
139
- });
140
- req.on('end', () => resolve({ body: Buffer.concat(chunks), tooLarge }));
141
- req.on('error', (error) => {
142
- if (tooLarge) {
143
- resolve({ body: Buffer.alloc(0), tooLarge: true });
144
- return;
145
- }
146
- reject(error instanceof Error ? error : new Error(String(error)));
147
- });
148
- });
149
- }
150
- /** `POST /__admin/hearth-image` — the image-build workflow's push-notify
151
- * (design §2-C). Bearer-authed against the CP admin token (constant-time),
152
- * BEFORE and outside the session/registry gate (it is a machine ingress, not
153
- * a browser lane). Validates the body is strict semver + `image_ref ===
154
- * hearthHomeImageRefForVersion(template_version)` (the notify cannot register
155
- * an image name that doesn't match its version), then idempotently upserts
156
- * the single global target row — a re-post of the same version is a no-op. It
157
- * never contacts a home; the actual roll happens lazily on each home's next
158
- * wake (wake.ts). */
159
- async function respondAdminHearthImage(req, res, db) {
160
- if (!isAdminAuthorized(req)) {
161
- sendText(res, 401, 'admin authorization required\n');
162
- return;
163
- }
164
- const { body, tooLarge } = await readRequestBody(req, ADMIN_BODY_MAX_BYTES);
165
- if (tooLarge) {
166
- sendText(res, 413, 'request body too large\n');
167
- return;
168
- }
169
- let parsed;
170
- try {
171
- parsed = JSON.parse(body.toString('utf8'));
172
- }
173
- catch {
174
- sendText(res, 400, 'body must be valid JSON\n');
175
- return;
176
- }
177
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
178
- sendText(res, 400, 'body must be a JSON object with template_version and image_ref\n');
179
- return;
180
- }
181
- const record = parsed;
182
- const templateVersion = typeof record.template_version === 'string' ? record.template_version.trim() : '';
183
- const imageRef = typeof record.image_ref === 'string' ? record.image_ref.trim() : '';
184
- if (!STRICT_SEMVER_RE.test(templateVersion)) {
185
- sendText(res, 400, 'template_version must be strict semver (X.Y.Z)\n');
186
- return;
187
- }
188
- const expectedImageRef = hearthHomeImageRefForVersion(templateVersion);
189
- if (imageRef !== expectedImageRef) {
190
- sendText(res, 400, `image_ref must equal ${expectedImageRef} for template_version ${templateVersion}\n`);
191
- return;
192
- }
193
- const target = setHearthTarget({ target_template_version: templateVersion, target_image_ref: imageRef, source: 'image-workflow' }, db);
194
- sendText(res, 200, `hearth target = ${target.target_template_version} (${target.target_image_ref})\n`);
195
- }
196
- /** `?token=` is intentionally not accepted as durable auth (`session.ts`); the
197
- * one allowed query-token use is the single-use M0 handoff swap-to-cookie,
198
- * attempted here only when no durable cookie/bearer session is already
199
- * present, and only ever turned into a session via that one origin-checked
200
- * swap function — never read/trusted directly by this route. */
201
- function resolveRootSession(req, res, config, store) {
202
- const direct = resolveTenantSession(req, { store });
203
- if (direct !== null)
204
- return direct;
205
- try {
206
- return swapQueryTokenForCookie(req, res, { publicOrigin: config.publicOrigin, store, cookie: { secure: config.cookieSecure } });
207
- }
208
- catch {
209
- return null;
210
- }
211
- }
212
- /** `GET /` entry resolution. Resolves fresh on every load (R-VIEW-1): no
213
- * session -> no registry/home read at all; session resolves to no home, or a
214
- * home with no `home_agent_target` yet -> still no relay/wake/provider call.
215
- * The redirect target is always the value this process just read from the
216
- * registry — never a client-supplied `?target=`. */
217
- function respondRoot(req, res, db, config, store) {
218
- const session = resolveRootSession(req, res, config, store);
219
- if (session === null) {
220
- sendText(res, 401, 'hearth session required\n');
221
- return;
222
- }
223
- const home = resolveHome(session.tenant_id, session.user_id, db);
224
- if (home === null) {
225
- sendText(res, 404, 'home not found\n');
226
- return;
227
- }
228
- const target = home.home_agent_target?.trim() ?? '';
229
- if (target === '') {
230
- sendText(res, 503, 'home not yet provisioned\n');
231
- return;
232
- }
233
- redirectChat(res, target);
234
- }
235
- const MODEL_AUTH_START_PATH = '/__hearth/model-auth/anthropic/start';
236
- const MODEL_AUTH_FINISH_PATH = '/__hearth/model-auth/anthropic/finish';
237
- const DYNAMIC_ROUTES = [
238
- { method: 'POST', path: '/__crtr/source', auth: 'write' },
239
- { method: 'GET', path: '/__crtr/events', auth: 'read' },
240
- { method: 'GET', path: '/__hearth/model-auth/anthropic/status', auth: 'read' },
241
- { method: 'POST', path: MODEL_AUTH_START_PATH, auth: 'write' },
242
- { method: 'POST', path: MODEL_AUTH_FINISH_PATH, auth: 'write' },
243
- ];
244
- const DYNAMIC_ROUTE_PATHS = new Set(DYNAMIC_ROUTES.map((route) => route.path));
245
- function findDynamicRoute(method, pathname) {
246
- return DYNAMIC_ROUTES.find((route) => route.path === pathname && route.method === method);
247
- }
248
- /** `GET /` redirects to `/views/<view>?target=...` (review finding #1) but
249
- * that path never reached the relay — it 404'd even for a valid session.
250
- * These two matchers extend the dynamic-lane gate (session+registry, same as
251
- * every other lane above) to the SPA view shell and the static assets it
252
- * loads, so the redirect target is actually servable. This is deliberately
253
- * NOT a public/unauthenticated allowlist (the part-plan does not call for
254
- * one) — every match below still goes through `respondDynamicLane`'s
255
- * session+registry gate before `relay.proxyHttp` is ever invoked. */
256
- function isViewShellPath(pathname) {
257
- return /^\/views\/[^/?#]+$/.test(pathname);
258
- }
259
- const STATIC_ASSET_ROOT_PATHS = new Set([
260
- '/favicon.ico',
261
- '/favicon.svg',
262
- '/favicon.png',
263
- '/apple-touch-icon.png',
264
- '/apple-touch-icon-precomposed.png',
265
- '/manifest.webmanifest',
266
- '/site.webmanifest',
267
- '/robots.txt',
268
- '/vite.svg',
269
- ]);
270
- const STATIC_ASSET_EXTENSIONS = new Set(['.css', '.js', '.mjs', '.map', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.json', '.txt']);
271
- function isStaticAssetPath(pathname) {
272
- if (pathname.startsWith('/assets/'))
273
- return true;
274
- if (STATIC_ASSET_ROOT_PATHS.has(pathname))
275
- return true;
276
- const match = /^\/[^/?#]+(\.[^/?#]+)$/.exec(pathname);
277
- return match !== null && STATIC_ASSET_EXTENSIONS.has(match[1].toLowerCase());
278
- }
279
- function isRelayableViewOrAssetPath(pathname) {
280
- return isViewShellPath(pathname) || isStaticAssetPath(pathname);
281
- }
282
- /** Session+registry gate shared by every dynamic lane. Absent/invalid session
283
- * (or, for `auth: 'write'`, an untrusted Origin) or an unresolved home
284
- * rejects before the relay dispatcher (and therefore before any
285
- * wake/provider/home contact) is ever invoked. */
286
- /** OAuth-in-progress marker: the CP's own proxied `start`/`finish` dispatch
287
- * is the seam — mark a window open the moment a
288
- * `start` is about to be proxied (regardless of what the guest does with
289
- * it), close it once the `finish` proxy call resolves (success OR failure;
290
- * a failed finish still ends the CP-visible window — the bounded TTL in
291
- * `serving.ts` is the backstop for a `start` that never gets a `finish` at
292
- * all, e.g. an abandoned browser tab). */
293
- async function respondDynamicLane(req, res, db, config, store, relay, auth) {
294
- const session = auth === 'write'
295
- ? resolveTenantWriteAuth(req, { store, publicOrigin: config.publicOrigin })
296
- : resolveTenantSession(req, { store });
297
- if (session === null) {
298
- sendText(res, 401, 'hearth session required\n');
299
- return;
300
- }
301
- const home = resolveHome(session.tenant_id, session.user_id, db);
302
- if (home === null) {
303
- sendText(res, 404, 'home not found\n');
304
- return;
305
- }
306
- const pathname = requestUrl(req).pathname;
307
- const isOAuthStart = pathname === MODEL_AUTH_START_PATH;
308
- const isOAuthFinish = pathname === MODEL_AUTH_FINISH_PATH;
309
- if (isOAuthStart)
310
- beginOAuth(home.home_id);
311
- try {
312
- await relay.proxyHttp(req, res, home);
313
- }
314
- finally {
315
- if (isOAuthFinish)
316
- endOAuth(home.home_id);
317
- }
318
- }
319
- async function routeControlPlaneRequest(req, res, db, config, store, relay) {
320
- const url = requestUrl(req);
321
- if (req.method === 'GET' && url.pathname === '/__hearth/healthz') {
322
- respondHealthz(res);
323
- return;
324
- }
325
- if ((req.method === 'GET' || req.method === 'HEAD') && url.pathname === '/') {
326
- respondRoot(req, res, db, config, store);
327
- return;
328
- }
329
- // Public webhook ingress lane (`POST /webhook/:routing_key`). Authenticated
330
- // ONLY by the per-route HMAC signature verified inside the handler, so it
331
- // sits BEFORE — and deliberately outside — the session/registry gate every
332
- // dynamic lane below goes through. A wrong method on the webhook path is a
333
- // 405, never a fall-through to the relay.
334
- if (isWebhookPath(url.pathname)) {
335
- if (!isWebhookRequest(req.method, url.pathname)) {
336
- sendText(res, 405, 'method not allowed\n');
337
- return;
338
- }
339
- await handleWebhookRequest(req, res);
340
- return;
341
- }
342
- // Admin push-notify lane. Authenticated by the CP admin bearer token inside
343
- // the handler, so — like the webhook lane — it sits BEFORE and outside the
344
- // session/registry gate. A wrong method on the admin path is a 405, never a
345
- // fall-through to the relay.
346
- if (url.pathname === ADMIN_HEARTH_IMAGE_PATH) {
347
- if (req.method !== 'POST') {
348
- sendText(res, 405, 'method not allowed\n');
349
- return;
350
- }
351
- await respondAdminHearthImage(req, res, db);
352
- return;
353
- }
354
- if (DYNAMIC_ROUTE_PATHS.has(url.pathname)) {
355
- const route = findDynamicRoute(req.method, url.pathname);
356
- if (route === undefined) {
357
- sendText(res, 405, 'method not allowed\n');
358
- return;
359
- }
360
- await respondDynamicLane(req, res, db, config, store, relay, route.auth);
361
- return;
362
- }
363
- if ((req.method === 'GET' || req.method === 'HEAD') && isRelayableViewOrAssetPath(url.pathname)) {
364
- await respondDynamicLane(req, res, db, config, store, relay, 'read');
365
- return;
366
- }
367
- sendText(res, 404, 'not found\n');
368
- }
369
- /** Exact single-segment `/node/<id>` matcher (review finding #3). Rejects an
370
- * empty id, additional path segments/trailing slash, an embedded `/` or `\`
371
- * (pre- or post-decode), `..` traversal, and malformed percent-encoding —
372
- * only a single clean path segment reaches `relay.proxyUpgrade`. Mirrors the
373
- * M0 `extractNodeId` shape (`wake-proxy/proxy.ts`) without the loose
374
- * `startsWith('/node/')` prefix match it replaces. */
375
- function extractNodeId(pathname) {
376
- const match = /^\/node\/([^/]+)$/.exec(pathname);
377
- if (match === null)
378
- return null;
379
- const raw = match[1];
380
- if (raw.includes('\\'))
381
- return null;
382
- let nodeId;
383
- try {
384
- nodeId = decodeURIComponent(raw);
385
- }
386
- catch {
387
- return null;
388
- }
389
- if (nodeId === '' || /[/\\]/.test(nodeId) || nodeId.includes('..'))
390
- return null;
391
- return nodeId;
392
- }
393
- /** WS upgrade gate for `/node/<id>`. Uses the same write-capable browser auth
394
- * as write HTTP lanes (`resolveTenantWriteAuth`: trusted-Origin cookie OR
395
- * no-Origin/non-browser Bearer — review finding #3) rather than plain
396
- * `resolveTenantSession`, so a cross-site page cannot ride a victim's cookie
397
- * into the bidirectional `/node/<id>` lane the way a same-site/no-Origin
398
- * WS handshake can. Then mirrors `respondDynamicLane`'s registry gate before
399
- * handing the socket to the relay dispatcher. */
400
- async function handleUpgrade(req, socket, head, db, config, store, relay) {
401
- const url = requestUrl(req);
402
- const nodeId = extractNodeId(url.pathname);
403
- if (nodeId === null) {
404
- socket.destroy();
405
- return;
406
- }
407
- const session = resolveTenantWriteAuth(req, { store, publicOrigin: config.publicOrigin });
408
- if (session === null) {
409
- socket.destroy();
410
- return;
411
- }
412
- const home = resolveHome(session.tenant_id, session.user_id, db);
413
- if (home === null) {
414
- socket.destroy();
415
- return;
416
- }
417
- relay.proxyUpgrade(req, socket, head, home);
418
- }
419
- function stripReferrerHeaders(req) {
420
- delete req.headers.referer;
421
- delete req.headers.referrer;
422
- }
423
- /** Redact secrets/session material out of a server-side error log line. The
424
- * client never sees this text (see the 500 handler below) — it exists only
425
- * so stderr never carries a raw bearer/cookie/relay-token value. */
426
- function redactServerErrorMessage(message) {
427
- return redactSecrets(tenantSessionAuthRedactedMessage(message));
428
- }
429
- /** Log a route/relay failure server-side only. The browser always gets the
430
- * generic 500 body written by the caller — raw exception text (which can
431
- * carry upstream URLs, headers, bearer/session strings, or DB paths once
432
- * P4.3 plugs in the real relay) never reaches the client. */
433
- function logRequestError(error, req) {
434
- const raw = error instanceof Error ? (error.stack ?? error.message) : String(error);
435
- const details = {
436
- level: 'error',
437
- message: 'hearth control plane request failed',
438
- method: req.method,
439
- path: requestUrl(req).pathname,
440
- error: redactServerErrorMessage(raw),
441
- };
442
- process.stderr.write(`${JSON.stringify(details)}\n`);
443
- }
444
- /** Build the CP HTTP+WS server. No registry seeding and no idle timers are
445
- * started here or anywhere reachable from this constructor (master plan
446
- * P4.1 / S3 — the provider owns idle->suspend, not the CP). `deps` lets
447
- * callers inject the db handle, a session store, and a relay dispatcher —
448
- * `main.ts` uses the real env-resolved defaults; tests/smoke checks inject
449
- * fakes for all three without touching global env state. */
450
- export function createControlPlaneServer(config, deps = {}) {
451
- const db = deps.db ?? getControlPlaneDb();
452
- const store = deps.sessionStore;
453
- const relay = deps.relay ?? notImplementedRelay;
454
- const server = createServer((req, res) => {
455
- void routeControlPlaneRequest(req, res, db, config, store, relay).catch((error) => {
456
- logRequestError(error, req);
457
- if (!res.headersSent) {
458
- sendText(res, 500, 'internal server error\n');
459
- }
460
- else {
461
- res.destroy();
462
- }
463
- });
464
- });
465
- server.prependListener('request', (req, res) => {
466
- stripReferrerHeaders(req);
467
- if (requestUrl(req).pathname === '/')
468
- res.setHeader('Referrer-Policy', 'no-referrer');
469
- });
470
- server.prependListener('upgrade', (req) => {
471
- stripReferrerHeaders(req);
472
- });
473
- server.on('connect', (_req, socket) => {
474
- socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n');
475
- });
476
- server.on('upgrade', (req, socket, head) => {
477
- void handleUpgrade(req, socket, head, db, config, store, relay).catch(() => {
478
- socket.destroy();
479
- });
480
- });
481
- return server;
482
- }
@@ -1,15 +0,0 @@
1
- import type { HomeId } from './types.js';
2
- export declare function beginHttp(id: HomeId): void;
3
- export declare function endHttp(id: HomeId): void;
4
- export declare function beginWs(id: HomeId): void;
5
- export declare function endWs(id: HomeId): void;
6
- export declare function beginOAuth(id: HomeId): void;
7
- export declare function endOAuth(id: HomeId): void;
8
- /** Is `id` currently serving — any active relay HTTP request, any open relay
9
- * WebSocket bridge, or a live (non-stale) OAuth start->finish window? This is
10
- * the ONLY signal `wake.ts`'s `homeIsServing` consults — it does not depend
11
- * on `resolveRunning`/`WARM_VERDICT_TTL_MS` at all. */
12
- export declare function isHomeServing(id: HomeId): boolean;
13
- /** Test/smoke-script hygiene only — production never needs this (one CP
14
- * process, S2, and every counter self-clears on its own end/dec path). */
15
- export declare function resetServingForTests(): void;
@@ -1,106 +0,0 @@
1
- // control-plane/serving.ts — CP-layer active-session tracker, independent of
2
- // the wake path's warm-verdict TTL.
3
- //
4
- // `wake.ts`'s `resolveRunning`/`WARM_VERDICT_TTL_MS` answers "did we recently
5
- // confirm this home is up" for exactly 10s — a cheap fast-path cache, not a
6
- // live-session signal. A destructive roll must never fire while a home has
7
- // an open browser/broker WebSocket, an in-flight HTTP request, or an open
8
- // OAuth start->finish window, no matter how long that session has been
9
- // running (design §3's "no active turn / no in-flight OAuth" safety
10
- // invariant).
11
- //
12
- // Three independent live-count maps, keyed by HomeId, are bumped directly at
13
- // the relay's HTTP/WS lifecycle seams and at the CP's OAuth start/finish
14
- // dispatch — NOT derived from any cache/TTL. A home is "serving" whenever
15
- // any one of them is non-zero. Single CP process (S2), in-process only, no
16
- // persistence — a CP restart naturally drops to zero, which is correct (a
17
- // fresh process has no in-flight anything).
18
- const httpInflight = new Map();
19
- const wsOpen = new Map();
20
- const oauthInProgress = new Map();
21
- // Timestamp of the most recent `beginOAuth` call for this home — the TTL
22
- // clock for the bounded-safety rule below. Refreshed on every `beginOAuth`,
23
- // so each new `start` gets its own full TTL window regardless of how stale
24
- // an earlier, still-uncompleted start's marker had become. Cleared once the
25
- // counter returns to 0 (no window is open, nothing to bound).
26
- const oauthStartedAt = new Map();
27
- // Bounded safety — "do not let a dropped OAuth pin a home un-rollable
28
- // forever": a `start` with no matching `finish` (browser tab closed
29
- // mid-flow, network drop, abandoned auth) must not hold `isHomeServing` true
30
- // indefinitely. Chose a TTL over a "clear on idle" scheme because idle here
31
- // has no other natural signal to key off (no heartbeat on the OAuth lane)
32
- // and a TTL is simplest to reason about / test. 5 minutes is generous for a
33
- // real human completing an OAuth redirect+approve+callback round trip (the
34
- // M0 in-guest flow is a single localhost redirect, seconds in the happy
35
- // path) while still bounding the worst case to a single-digit-minutes window
36
- // rather than forever.
37
- const OAUTH_MARKER_TTL_MS = 5 * 60_000;
38
- function inc(map, id) {
39
- map.set(id, (map.get(id) ?? 0) + 1);
40
- }
41
- function dec(map, id) {
42
- const current = map.get(id) ?? 0;
43
- const next = current - 1;
44
- if (next <= 0) {
45
- map.delete(id);
46
- }
47
- else {
48
- map.set(id, next);
49
- }
50
- }
51
- export function beginHttp(id) {
52
- inc(httpInflight, id);
53
- }
54
- export function endHttp(id) {
55
- dec(httpInflight, id);
56
- }
57
- export function beginWs(id) {
58
- inc(wsOpen, id);
59
- }
60
- export function endWs(id) {
61
- dec(wsOpen, id);
62
- }
63
- export function beginOAuth(id) {
64
- // Every real OAuth `start` is a fresh auth window and earns its own full
65
- // TTL, not just the first one since the counter hit 0 — otherwise a stale,
66
- // abandoned start's marker would age out from under a brand-new retry that
67
- // shares its counter, letting that retry's own wake see `!isHomeServing`.
68
- oauthStartedAt.set(id, Date.now());
69
- inc(oauthInProgress, id);
70
- }
71
- export function endOAuth(id) {
72
- dec(oauthInProgress, id);
73
- if ((oauthInProgress.get(id) ?? 0) === 0)
74
- oauthStartedAt.delete(id);
75
- }
76
- /** Is an OAuth window open for `id` right now, per the counter, and still
77
- * within the bounded-safety TTL of the MOST RECENT `beginOAuth`? A marker
78
- * past TTL is treated as stale (does NOT count as serving) so a dropped
79
- * `start` can't pin a home un-rollable forever; the counter itself is left
80
- * alone (an eventual late `finish` still decrements it correctly, and `dec`
81
- * floors at 0 regardless), and a later `beginOAuth` on the same counter
82
- * refreshes the timestamp so its own window is judged live. */
83
- function oauthMarkerLive(id) {
84
- const count = oauthInProgress.get(id) ?? 0;
85
- if (count <= 0)
86
- return false;
87
- const startedAt = oauthStartedAt.get(id);
88
- if (startedAt === undefined)
89
- return true; // defensive: count>0 with no timestamp — treat as live
90
- return Date.now() - startedAt < OAUTH_MARKER_TTL_MS;
91
- }
92
- /** Is `id` currently serving — any active relay HTTP request, any open relay
93
- * WebSocket bridge, or a live (non-stale) OAuth start->finish window? This is
94
- * the ONLY signal `wake.ts`'s `homeIsServing` consults — it does not depend
95
- * on `resolveRunning`/`WARM_VERDICT_TTL_MS` at all. */
96
- export function isHomeServing(id) {
97
- return (httpInflight.get(id) ?? 0) > 0 || (wsOpen.get(id) ?? 0) > 0 || oauthMarkerLive(id);
98
- }
99
- /** Test/smoke-script hygiene only — production never needs this (one CP
100
- * process, S2, and every counter self-clears on its own end/dec path). */
101
- export function resetServingForTests() {
102
- httpInflight.clear();
103
- wsOpen.clear();
104
- oauthInProgress.clear();
105
- oauthStartedAt.clear();
106
- }
@@ -1,68 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import type { TenantSession } from './types.js';
3
- export declare const HEARTH_SESSION_COOKIE = "hearth_session";
4
- type AuthSource = 'cookie' | 'bearer' | 'query-swap';
5
- export interface TenantSessionAuth extends TenantSession {
6
- source: AuthSource;
7
- }
8
- export interface TenantSessionTokenSeed extends TenantSession {
9
- /** Raw cookie/bearer session token. Preferred for dogfood env seeding when
10
- * query-token handoff must set a cookie. If omitted, `token_hash` can still
11
- * validate existing cookies/bearer clients but cannot be emitted into a new
12
- * Set-Cookie header. */
13
- token?: string;
14
- token_hash?: string;
15
- /** Optional one-time M0 handoff token. It is not accepted by
16
- * `resolveTenantSession`; it can only be consumed by
17
- * `swapQueryTokenForCookie`, which requires a trusted Origin and marks the
18
- * query hash spent in this CP process. */
19
- query_token?: string;
20
- query_token_hash?: string;
21
- }
22
- export interface TenantSessionStore {
23
- records: readonly NormalizedTenantSessionToken[];
24
- consumed_query_token_hashes: Set<string>;
25
- }
26
- interface NormalizedTenantSessionToken extends TenantSession {
27
- token_hash: string;
28
- token: string | null;
29
- query_token_hash: string | null;
30
- }
31
- export interface SessionCookieOptions {
32
- secure?: boolean;
33
- sameSite?: 'Lax' | 'Strict' | 'None';
34
- maxAgeSeconds?: number;
35
- path?: string;
36
- }
37
- export interface ResolveTenantSessionOptions {
38
- store?: TenantSessionStore;
39
- }
40
- export interface ResolveTenantWriteAuthOptions extends ResolveTenantSessionOptions {
41
- publicOrigin: string;
42
- }
43
- export interface QueryTokenSwapOptions extends ResolveTenantWriteAuthOptions {
44
- cookie?: SessionCookieOptions;
45
- }
46
- export declare function createTenantSessionStore(seeds: readonly TenantSessionTokenSeed[]): TenantSessionStore;
47
- export declare function loadTenantSessionStore(env?: NodeJS.ProcessEnv): TenantSessionStore;
48
- export declare function setSessionCookie(res: ServerResponse, token: string, options?: SessionCookieOptions): void;
49
- export declare function clearSessionCookie(res: ServerResponse, options?: SessionCookieOptions): void;
50
- /** Validate durable session credentials only: HttpOnly cookie for browsers or
51
- * Bearer for non-browser clients. Query tokens are intentionally ignored here
52
- * so a copied `?token=` URL is never durable authorization. */
53
- export declare function resolveTenantSession(req: IncomingMessage, options?: ResolveTenantSessionOptions): TenantSessionAuth | null;
54
- /** State-changing browser lanes require a valid session AND a trusted Origin;
55
- * query tokens are not accepted here. Bearer remains available only to
56
- * non-browser clients (no Origin header). */
57
- export declare function resolveTenantWriteAuth(req: IncomingMessage, options: ResolveTenantWriteAuthOptions): TenantSessionAuth | null;
58
- /** Consume an optional M0 handoff `?token=` exactly once and swap it to the
59
- * HttpOnly/Secure/SameSite Hearth session cookie. This is the only query-token
60
- * path. Safe top-level navigations (`GET`/`HEAD`) are permitted without a
61
- * matching `Origin` header (see `originAcceptableForQueryTokenSwap`); any
62
- * other method still requires a trusted Origin, matching the write-lane
63
- * policy. Returns null on replay, invalid token, missing token, or a
64
- * rejected Origin. */
65
- export declare function swapQueryTokenForCookie(req: IncomingMessage, res: ServerResponse, options: QueryTokenSwapOptions): TenantSessionAuth | null;
66
- export declare function tenantSessionAuthRedactedMessage(message: string): string;
67
- export declare function resetTenantSessionStoreCacheForTests(): void;
68
- export {};