@crouton-kit/crouter 0.3.38 → 0.3.39
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/core/hearth/provider.d.ts +8 -0
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.d.ts +1 -0
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +254 -0
- package/dist/core/hearth/providers/blaxel-home.d.ts +39 -0
- package/dist/core/hearth/providers/blaxel-home.js +271 -21
- package/dist/core/hearth/providers/blaxel.d.ts +5 -0
- package/dist/core/hearth/providers/blaxel.js +86 -4
- package/dist/core/hearth/providers/types.d.ts +16 -0
- package/dist/hearth/control-plane/__tests__/error-serialization.test.d.ts +1 -0
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +29 -0
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +1 -0
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +44 -0
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +1 -0
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +230 -0
- package/dist/hearth/control-plane/db.js +27 -0
- package/dist/hearth/control-plane/hearth-target.d.ts +23 -0
- package/dist/hearth/control-plane/hearth-target.js +68 -0
- package/dist/hearth/control-plane/registry.d.ts +6 -1
- package/dist/hearth/control-plane/registry.js +7 -0
- package/dist/hearth/control-plane/relay.d.ts +4 -0
- package/dist/hearth/control-plane/relay.js +72 -3
- package/dist/hearth/control-plane/secrets.d.ts +14 -0
- package/dist/hearth/control-plane/secrets.js +21 -0
- package/dist/hearth/control-plane/server.js +140 -4
- package/dist/hearth/control-plane/serving.d.ts +15 -0
- package/dist/hearth/control-plane/serving.js +106 -0
- package/dist/hearth/control-plane/types.d.ts +20 -0
- package/dist/hearth/control-plane/wake.d.ts +8 -2
- package/dist/hearth/control-plane/wake.js +241 -3
- package/package.json +1 -1
|
@@ -147,6 +147,13 @@ export function refreshProviderPointers(home_id, pointers, db = getControlPlaneD
|
|
|
147
147
|
export function setHomeStatus(home_id, status, db = getControlPlaneDb()) {
|
|
148
148
|
return updateHome(home_id, () => ({ status }), db);
|
|
149
149
|
}
|
|
150
|
+
/** Set the home's health verdict. The auto-upgrade roll (wake.ts `rollHome`)
|
|
151
|
+
* is the first natural producer of `health_verdict`: a completed roll/
|
|
152
|
+
* rollback writes `'healthy'`, a roll AND rollback both failing writes
|
|
153
|
+
* `'degraded'` (design §4/§5). */
|
|
154
|
+
export function setHealthVerdict(home_id, health_verdict, db = getControlPlaneDb()) {
|
|
155
|
+
return updateHome(home_id, () => ({ health_verdict }), db);
|
|
156
|
+
}
|
|
150
157
|
export function touchWake(home_id, input = {}, db = getControlPlaneDb()) {
|
|
151
158
|
return updateHome(home_id, () => ({
|
|
152
159
|
last_wake_at: input.at ?? nowIso(),
|
|
@@ -5,6 +5,10 @@ import { type ControlPlaneConfig } from './config.js';
|
|
|
5
5
|
import { type WakeResult } from './wake.js';
|
|
6
6
|
import type { HomeId, ResolvedHome } from './types.js';
|
|
7
7
|
import type { RelayDispatcher } from './server.js';
|
|
8
|
+
/** Serialize a non-`Error` rejection for a log line without collapsing it to `"[object Object]"`
|
|
9
|
+
* (`String(error)` on a plain object) — recreate-race-fix.md §3.6. Falls back to `String` only
|
|
10
|
+
* if the value itself isn't JSON-serializable (a circular structure, a BigInt, etc). */
|
|
11
|
+
export declare function serializeRelayError(error: unknown): string;
|
|
8
12
|
/** Join the validated origin-form request target onto `previewEndpoint` and
|
|
9
13
|
* reject anything that would escape that origin (criterion 4: "reject
|
|
10
14
|
* scheme-relative/absolute external escapes"). */
|
|
@@ -35,6 +35,7 @@ import { BROKER_READ_CAPS } from '../../core/runtime/broker-protocol.js';
|
|
|
35
35
|
import { general } from '../../core/errors.js';
|
|
36
36
|
import { loadControlPlaneConfig } from './config.js';
|
|
37
37
|
import { ensureAwake, invalidateWarmVerdict, resolveRunning } from './wake.js';
|
|
38
|
+
import { beginHttp, beginWs, endHttp, endWs } from './serving.js';
|
|
38
39
|
import { redactSecrets } from './secrets.js';
|
|
39
40
|
import { tenantSessionAuthRedactedMessage } from './session.js';
|
|
40
41
|
const hopByHopHeaders = new Set([
|
|
@@ -69,6 +70,22 @@ function redactDeep(value) {
|
|
|
69
70
|
function logError(message, details = {}) {
|
|
70
71
|
process.stderr.write(`${JSON.stringify({ level: 'error', message, details: redactDeep(details) })}\n`);
|
|
71
72
|
}
|
|
73
|
+
/** Serialize a non-`Error` rejection for a log line without collapsing it to `"[object Object]"`
|
|
74
|
+
* (`String(error)` on a plain object) — recreate-race-fix.md §3.6. Falls back to `String` only
|
|
75
|
+
* if the value itself isn't JSON-serializable (a circular structure, a BigInt, etc). */
|
|
76
|
+
export function serializeRelayError(error) {
|
|
77
|
+
try {
|
|
78
|
+
// `JSON.stringify` returns `undefined` (does not throw) for `undefined`, a function, or a
|
|
79
|
+
// symbol. Falling through to that here would hand the `message.startsWith('relay ')`/
|
|
80
|
+
// `/timed out/i` control-flow checks right after this an `undefined` string and THROW
|
|
81
|
+
// inside the catch block — exactly the control-flow change this rider promised not to make.
|
|
82
|
+
const json = JSON.stringify(error);
|
|
83
|
+
return json === undefined ? String(error) : json;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return String(error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
72
89
|
function requestPath(req) {
|
|
73
90
|
return req.url ?? '/';
|
|
74
91
|
}
|
|
@@ -339,7 +356,25 @@ export async function proxyHttp(req, res, home, deps = {}) {
|
|
|
339
356
|
const running = resolveRunningImpl(home.home_id);
|
|
340
357
|
if (running !== null) {
|
|
341
358
|
try {
|
|
342
|
-
|
|
359
|
+
// Mark this home as actively serving ONLY around the actual data
|
|
360
|
+
// transfer, never around the wake DECISION above it. Bumping the
|
|
361
|
+
// counter any earlier (e.g. unconditionally on function
|
|
362
|
+
// entry, before `ensureAwake` runs below) would make a home's very
|
|
363
|
+
// FIRST request after being suspended see ITS OWN just-begun counter
|
|
364
|
+
// and read as "serving" — `isHomeStale && !homeIsServing` would then
|
|
365
|
+
// never be true for any home, ever, silently disabling every roll
|
|
366
|
+
// (verified live: `beginHttp` then `isHomeServing` in the same tick
|
|
367
|
+
// returns true with zero other activity). Scoping begin/end tightly
|
|
368
|
+
// around each `forwardUpstream` call still protects the real target
|
|
369
|
+
// (a genuinely separate, already-open session on this home) because
|
|
370
|
+
// that session's OWN counter is independent and already non-zero.
|
|
371
|
+
beginHttp(home.home_id);
|
|
372
|
+
try {
|
|
373
|
+
await forwardUpstream(req, res, running, controller);
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
endHttp(home.home_id);
|
|
377
|
+
}
|
|
343
378
|
return;
|
|
344
379
|
}
|
|
345
380
|
catch (error) {
|
|
@@ -358,10 +393,21 @@ export async function proxyHttp(req, res, home, deps = {}) {
|
|
|
358
393
|
const awakened = await withTimeout(ensureAwakeImpl(home.home_id), config.wakeTimeoutMs, 'control plane relay wake');
|
|
359
394
|
if (controller.signal.aborted)
|
|
360
395
|
throw general('request closed while waking');
|
|
361
|
-
|
|
396
|
+
beginHttp(home.home_id);
|
|
397
|
+
try {
|
|
398
|
+
await forwardUpstream(req, res, awakened, controller);
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
endHttp(home.home_id);
|
|
402
|
+
}
|
|
362
403
|
}
|
|
363
404
|
catch (error) {
|
|
364
|
-
|
|
405
|
+
// A non-`Error` rejection (e.g. a raw object thrown from outside the SDK's recognized error
|
|
406
|
+
// shapes) must not serialize to "[object Object]" here — that loss of the real error is what
|
|
407
|
+
// cost the recreate-race incident its diagnosis (recreate-race-fix.md §3.6). `message` below
|
|
408
|
+
// still drives the control-flow checks that follow (relay-prefixed/timeout messages), which
|
|
409
|
+
// only ever come from `Error` instances (`general()`), so this branch change cannot affect them.
|
|
410
|
+
const message = error instanceof Error ? error.message : serializeRelayError(error);
|
|
365
411
|
logError('hearth control plane relay HTTP request failed', {
|
|
366
412
|
home_id: home.home_id,
|
|
367
413
|
path: requestPath(req).replace(/\?.*$/, ''),
|
|
@@ -489,6 +535,12 @@ async function proxyWebSocketBrowser(browserWs, home, deps = {}) {
|
|
|
489
535
|
const handshakeAt = Date.now();
|
|
490
536
|
let upstream = null;
|
|
491
537
|
let teardownStarted = false;
|
|
538
|
+
// Whether THIS bridge has actually incremented `wsOpen` (set true only
|
|
539
|
+
// once the upstream leg is being established — see below —
|
|
540
|
+
// and guards the `close`-handler `endWs` call so a bridge whose own
|
|
541
|
+
// `ensureAwake` failed before ever counting itself can't decrement a
|
|
542
|
+
// DIFFERENT, still-live session's counter for the same home).
|
|
543
|
+
let wsCounted = false;
|
|
492
544
|
let upstreamOpenAt = null;
|
|
493
545
|
let browserClose = null;
|
|
494
546
|
let upstreamClose = null;
|
|
@@ -551,6 +603,10 @@ async function proxyWebSocketBrowser(browserWs, home, deps = {}) {
|
|
|
551
603
|
if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
|
|
552
604
|
sendSocketClose(upstream, code, closeReason);
|
|
553
605
|
}
|
|
606
|
+
if (wsCounted) {
|
|
607
|
+
wsCounted = false;
|
|
608
|
+
endWs(home.home_id);
|
|
609
|
+
}
|
|
554
610
|
});
|
|
555
611
|
browserWs.on('error', (error) => {
|
|
556
612
|
logError('hearth control plane relay browser websocket failed', { home_id: home.home_id, error: error instanceof Error ? error.message : String(error) });
|
|
@@ -565,6 +621,19 @@ async function proxyWebSocketBrowser(browserWs, home, deps = {}) {
|
|
|
565
621
|
beginTeardown();
|
|
566
622
|
return;
|
|
567
623
|
}
|
|
624
|
+
// Mark this home as actively serving ONLY once the wake DECISION above
|
|
625
|
+
// has already resolved — not on function entry.
|
|
626
|
+
// `ensureAwake` (inside the wait above) makes the roll-vs-normal-wake
|
|
627
|
+
// call by consulting this very tracker; counting a bridge as "open"
|
|
628
|
+
// before that decision runs would make a home's very first WS reconnect
|
|
629
|
+
// after being suspended see ITS OWN not-yet-real session and read as
|
|
630
|
+
// "serving," permanently blocking the roll this connection was itself
|
|
631
|
+
// supposed to trigger (mirrors the identical HTTP hazard in `proxyHttp`
|
|
632
|
+
// above — verified live for the HTTP case; same mechanism applies here).
|
|
633
|
+
// A genuinely separate, already-open bridge for this home is unaffected:
|
|
634
|
+
// its own counter is already live and independent of this one.
|
|
635
|
+
beginWs(home.home_id);
|
|
636
|
+
wsCounted = true;
|
|
568
637
|
upstream = createUpstream(toWssUrl(awakened.preview_endpoint, target), awakened.relayToken);
|
|
569
638
|
upstream.on('open', () => {
|
|
570
639
|
if (teardownStarted || browserWs.readyState !== WebSocket.OPEN) {
|
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
* an unknown handle — the thrown message never includes a resolved value,
|
|
8
8
|
* only the handle name. */
|
|
9
9
|
export declare function resolveSecret(handle: string, env?: NodeJS.ProcessEnv): string;
|
|
10
|
+
/** The secret HANDLE for the CP admin bearer token that gates the
|
|
11
|
+
* `POST /__admin/*` ingress (the image workflow's push-notify, design
|
|
12
|
+
* §2-C). Resolves through the SAME `resolveSecret` seam as every other CP
|
|
13
|
+
* handle: the per-handle env override
|
|
14
|
+
* `CRTR_HEARTH_CP_SECRET_HEARTH_CP_ADMIN_TOKEN`, else the `HEARTH_CP_ADMIN_TOKEN`
|
|
15
|
+
* key in the CP secrets file. The GitHub repo secret `HEARTH_CP_ADMIN_TOKEN`
|
|
16
|
+
* and the deployed Fly CP must carry the SAME value. */
|
|
17
|
+
export declare const HEARTH_CP_ADMIN_TOKEN_HANDLE = "HEARTH_CP_ADMIN_TOKEN";
|
|
18
|
+
/** Resolve the CP admin bearer token, or `null` when it is not configured.
|
|
19
|
+
* Unlike `resolveSecret` (which throws on an unknown handle), this returns
|
|
20
|
+
* `null` so the admin route can FAIL CLOSED — a CP with no admin token
|
|
21
|
+
* configured rejects every `/__admin/*` request rather than crashing the
|
|
22
|
+
* request handler. Never logs the value. */
|
|
23
|
+
export declare function resolveCpAdminToken(env?: NodeJS.ProcessEnv): string | null;
|
|
10
24
|
/** Redact every secret value resolved so far this process out of a log
|
|
11
25
|
* line/message. CP code building a log line from data that may carry a
|
|
12
26
|
* resolved secret (e.g. an upstream request trace) MUST pass it through this
|
|
@@ -92,6 +92,27 @@ export function resolveSecret(handle, env = process.env) {
|
|
|
92
92
|
}
|
|
93
93
|
throw general(`unknown secret handle: ${trimmed}`, { handle: trimmed });
|
|
94
94
|
}
|
|
95
|
+
/** The secret HANDLE for the CP admin bearer token that gates the
|
|
96
|
+
* `POST /__admin/*` ingress (the image workflow's push-notify, design
|
|
97
|
+
* §2-C). Resolves through the SAME `resolveSecret` seam as every other CP
|
|
98
|
+
* handle: the per-handle env override
|
|
99
|
+
* `CRTR_HEARTH_CP_SECRET_HEARTH_CP_ADMIN_TOKEN`, else the `HEARTH_CP_ADMIN_TOKEN`
|
|
100
|
+
* key in the CP secrets file. The GitHub repo secret `HEARTH_CP_ADMIN_TOKEN`
|
|
101
|
+
* and the deployed Fly CP must carry the SAME value. */
|
|
102
|
+
export const HEARTH_CP_ADMIN_TOKEN_HANDLE = 'HEARTH_CP_ADMIN_TOKEN';
|
|
103
|
+
/** Resolve the CP admin bearer token, or `null` when it is not configured.
|
|
104
|
+
* Unlike `resolveSecret` (which throws on an unknown handle), this returns
|
|
105
|
+
* `null` so the admin route can FAIL CLOSED — a CP with no admin token
|
|
106
|
+
* configured rejects every `/__admin/*` request rather than crashing the
|
|
107
|
+
* request handler. Never logs the value. */
|
|
108
|
+
export function resolveCpAdminToken(env = process.env) {
|
|
109
|
+
try {
|
|
110
|
+
return resolveSecret(HEARTH_CP_ADMIN_TOKEN_HANDLE, env);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
95
116
|
/** Redact every secret value resolved so far this process out of a log
|
|
96
117
|
* line/message. CP code building a log line from data that may carry a
|
|
97
118
|
* resolved secret (e.g. an upstream request trace) MUST pass it through this
|
|
@@ -53,10 +53,14 @@
|
|
|
53
53
|
// handed to the dispatcher, which today simply reports "not implemented yet"
|
|
54
54
|
// without contacting any home.
|
|
55
55
|
import { createServer } from 'node:http';
|
|
56
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
57
|
+
import { hearthHomeImageRefForVersion } from '../../core/hearth/config.js';
|
|
56
58
|
import { getControlPlaneDb } from './db.js';
|
|
57
59
|
import { resolveHome } from './registry.js';
|
|
60
|
+
import { setHearthTarget } from './hearth-target.js';
|
|
58
61
|
import { resolveTenantSession, resolveTenantWriteAuth, swapQueryTokenForCookie, tenantSessionAuthRedactedMessage } from './session.js';
|
|
59
|
-
import { redactSecrets } from './secrets.js';
|
|
62
|
+
import { redactSecrets, resolveCpAdminToken } from './secrets.js';
|
|
63
|
+
import { beginOAuth, endOAuth } from './serving.js';
|
|
60
64
|
import { handleWebhookRequest, isWebhookPath, isWebhookRequest } from './register.js';
|
|
61
65
|
const notImplementedRelay = {
|
|
62
66
|
proxyHttp(_req, res) {
|
|
@@ -89,6 +93,106 @@ function redirectChat(res, target) {
|
|
|
89
93
|
function respondHealthz(res) {
|
|
90
94
|
sendText(res, 200, 'ok\n');
|
|
91
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
|
+
}
|
|
92
196
|
/** `?token=` is intentionally not accepted as durable auth (`session.ts`); the
|
|
93
197
|
* one allowed query-token use is the single-use M0 handoff swap-to-cookie,
|
|
94
198
|
* attempted here only when no durable cookie/bearer session is already
|
|
@@ -128,12 +232,14 @@ function respondRoot(req, res, db, config, store) {
|
|
|
128
232
|
}
|
|
129
233
|
redirectChat(res, target);
|
|
130
234
|
}
|
|
235
|
+
const MODEL_AUTH_START_PATH = '/__hearth/model-auth/anthropic/start';
|
|
236
|
+
const MODEL_AUTH_FINISH_PATH = '/__hearth/model-auth/anthropic/finish';
|
|
131
237
|
const DYNAMIC_ROUTES = [
|
|
132
238
|
{ method: 'POST', path: '/__crtr/source', auth: 'write' },
|
|
133
239
|
{ method: 'GET', path: '/__crtr/events', auth: 'read' },
|
|
134
240
|
{ method: 'GET', path: '/__hearth/model-auth/anthropic/status', auth: 'read' },
|
|
135
|
-
{ method: 'POST', path:
|
|
136
|
-
{ method: 'POST', path:
|
|
241
|
+
{ method: 'POST', path: MODEL_AUTH_START_PATH, auth: 'write' },
|
|
242
|
+
{ method: 'POST', path: MODEL_AUTH_FINISH_PATH, auth: 'write' },
|
|
137
243
|
];
|
|
138
244
|
const DYNAMIC_ROUTE_PATHS = new Set(DYNAMIC_ROUTES.map((route) => route.path));
|
|
139
245
|
function findDynamicRoute(method, pathname) {
|
|
@@ -177,6 +283,13 @@ function isRelayableViewOrAssetPath(pathname) {
|
|
|
177
283
|
* (or, for `auth: 'write'`, an untrusted Origin) or an unresolved home
|
|
178
284
|
* rejects before the relay dispatcher (and therefore before any
|
|
179
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). */
|
|
180
293
|
async function respondDynamicLane(req, res, db, config, store, relay, auth) {
|
|
181
294
|
const session = auth === 'write'
|
|
182
295
|
? resolveTenantWriteAuth(req, { store, publicOrigin: config.publicOrigin })
|
|
@@ -190,7 +303,18 @@ async function respondDynamicLane(req, res, db, config, store, relay, auth) {
|
|
|
190
303
|
sendText(res, 404, 'home not found\n');
|
|
191
304
|
return;
|
|
192
305
|
}
|
|
193
|
-
|
|
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
|
+
}
|
|
194
318
|
}
|
|
195
319
|
async function routeControlPlaneRequest(req, res, db, config, store, relay) {
|
|
196
320
|
const url = requestUrl(req);
|
|
@@ -215,6 +339,18 @@ async function routeControlPlaneRequest(req, res, db, config, store, relay) {
|
|
|
215
339
|
await handleWebhookRequest(req, res);
|
|
216
340
|
return;
|
|
217
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
|
+
}
|
|
218
354
|
if (DYNAMIC_ROUTE_PATHS.has(url.pathname)) {
|
|
219
355
|
const route = findDynamicRoute(req.method, url.pathname);
|
|
220
356
|
if (route === undefined) {
|
|
@@ -0,0 +1,15 @@
|
|
|
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;
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
}
|
|
@@ -64,3 +64,23 @@ export interface TenantSession {
|
|
|
64
64
|
tenant_id: TenantId;
|
|
65
65
|
user_id: UserId;
|
|
66
66
|
}
|
|
67
|
+
/** The single global auto-upgrade target row (`hearth_target`, db.ts v9).
|
|
68
|
+
* `channel` is always `'home'` (single-tenant, one image line). The DESIRED
|
|
69
|
+
* crtr version/image the fleet rolls onto; `home.template_version` is the
|
|
70
|
+
* ACTUAL running version. `last_bad_target_version`, when set, is a release
|
|
71
|
+
* whose live roll already failed — the wake-path stale predicate excludes it
|
|
72
|
+
* so no home re-rolls onto it until the target advances. */
|
|
73
|
+
export interface HearthTarget {
|
|
74
|
+
channel: 'home';
|
|
75
|
+
target_template_version: string;
|
|
76
|
+
target_image_ref: string;
|
|
77
|
+
published_at: string;
|
|
78
|
+
source: string;
|
|
79
|
+
last_bad_target_version: string | null;
|
|
80
|
+
}
|
|
81
|
+
export interface SetHearthTargetInput {
|
|
82
|
+
target_template_version: string;
|
|
83
|
+
target_image_ref: string;
|
|
84
|
+
source: string;
|
|
85
|
+
published_at?: string;
|
|
86
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type HomeBackend } from '../../core/hearth/provider.js';
|
|
2
2
|
import type { M0Config } from '../../core/hearth/types.js';
|
|
3
3
|
import { type ControlPlaneConfig } from './config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { getHearthTarget, recordBadTarget } from './hearth-target.js';
|
|
5
|
+
import { getHomeById, refreshProviderPointers, setHealthVerdict, setHomeStatus, touchReady, touchWake } from './registry.js';
|
|
5
6
|
import { resolveSecret } from './secrets.js';
|
|
6
7
|
import type { HomeId, HomeStatus } from './types.js';
|
|
7
8
|
export interface WakeResult {
|
|
@@ -40,8 +41,11 @@ declare function probeNodeReady(previewEndpoint: string, target: string, relayTo
|
|
|
40
41
|
* never reassigns these; only ad hoc verification scripts do. */
|
|
41
42
|
export declare const wakeInternals: {
|
|
42
43
|
getHomeById: typeof getHomeById;
|
|
44
|
+
getHearthTarget: typeof getHearthTarget;
|
|
45
|
+
recordBadTarget: typeof recordBadTarget;
|
|
43
46
|
refreshProviderPointers: typeof refreshProviderPointers;
|
|
44
47
|
setHomeStatus: typeof setHomeStatus;
|
|
48
|
+
setHealthVerdict: typeof setHealthVerdict;
|
|
45
49
|
touchWake: typeof touchWake;
|
|
46
50
|
touchReady: typeof touchReady;
|
|
47
51
|
resolveSecret: typeof resolveSecret;
|
|
@@ -64,10 +68,12 @@ export declare function invalidateWarmVerdict(home_id: HomeId): void;
|
|
|
64
68
|
/** Clear all warm verdicts and in-flight wake coalescing. Test/smoke-script
|
|
65
69
|
* hygiene only — production never needs this (one CP process, S2). */
|
|
66
70
|
export declare function resetWakeCacheForTests(): void;
|
|
71
|
+
export declare function serializeError(error: unknown): string;
|
|
67
72
|
/** Bring a home up and ready, returning the route to relay to once it is.
|
|
68
73
|
* Reads the registry, resolves the relay token, calls the provider
|
|
69
74
|
* wake/resume seam, ready-probes `/node/<home_agent_target>`, refreshes
|
|
70
|
-
* provider pointers on a recreate, records wake/ready timing,
|
|
75
|
+
* provider pointers on a recreate, records wake/ready timing, clears the
|
|
76
|
+
* health verdict to `healthy` on success, and returns
|
|
71
77
|
* `{ preview_endpoint, relayToken }`. Throws on a ready-probe timeout
|
|
72
78
|
* (wake-failure UX is deferred — gap note 2 — a throw is the accepted
|
|
73
79
|
* cutover behavior).
|