@crouton-kit/crouter 0.3.39 → 0.3.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +706 -733
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
- package/dist/core/hearth/providers/blaxel-home.js +0 -386
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
|
@@ -1,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 {};
|