@crouton-kit/crouter 0.3.34 → 0.3.35

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 (70) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +10 -5
  2. package/dist/clients/attach/attach-cmd.js +732 -704
  3. package/dist/core/__tests__/fixtures/fake-engine.js +18 -8
  4. package/dist/core/__tests__/flagship-lifecycle.test.js +4 -1
  5. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +1 -1
  6. package/dist/core/__tests__/full/detach-focus.test.js +15 -1
  7. package/dist/core/__tests__/live-mutation-verbs.test.js +8 -2
  8. package/dist/core/__tests__/live-mutation.test.js +7 -2
  9. package/dist/core/hearth/config.js +3 -6
  10. package/dist/core/hearth/index.d.ts +0 -1
  11. package/dist/core/hearth/index.js +0 -1
  12. package/dist/core/hearth/model-auth-guest.js +9 -14
  13. package/dist/core/hearth/providers/blaxel.js +3 -3
  14. package/dist/core/hearth/types.d.ts +0 -1
  15. package/dist/web-client/assets/index-DUThOUzU.css +2 -0
  16. package/dist/web-client/assets/index-IAJVtuVe.js +80 -0
  17. package/dist/web-client/index.html +2 -2
  18. package/package.json +2 -3
  19. package/dist/core/hearth/registry.d.ts +0 -15
  20. package/dist/core/hearth/registry.js +0 -180
  21. package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.d.ts +0 -1
  22. package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.js +0 -289
  23. package/dist/hearth/wake-proxy/__tests__/config-timeout.test.d.ts +0 -1
  24. package/dist/hearth/wake-proxy/__tests__/config-timeout.test.js +0 -34
  25. package/dist/hearth/wake-proxy/__tests__/guest-source.test.d.ts +0 -1
  26. package/dist/hearth/wake-proxy/__tests__/guest-source.test.js +0 -203
  27. package/dist/hearth/wake-proxy/__tests__/hardening.test.d.ts +0 -1
  28. package/dist/hearth/wake-proxy/__tests__/hardening.test.js +0 -59
  29. package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.d.ts +0 -1
  30. package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.js +0 -372
  31. package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.d.ts +0 -1
  32. package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.js +0 -258
  33. package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.d.ts +0 -1
  34. package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.js +0 -437
  35. package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.d.ts +0 -1
  36. package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.js +0 -15
  37. package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.d.ts +0 -1
  38. package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.js +0 -141
  39. package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.d.ts +0 -1
  40. package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.js +0 -143
  41. package/dist/hearth/wake-proxy/anthropic-oauth.d.ts +0 -58
  42. package/dist/hearth/wake-proxy/anthropic-oauth.js +0 -189
  43. package/dist/hearth/wake-proxy/auth.d.ts +0 -22
  44. package/dist/hearth/wake-proxy/auth.js +0 -128
  45. package/dist/hearth/wake-proxy/config.d.ts +0 -2
  46. package/dist/hearth/wake-proxy/config.js +0 -151
  47. package/dist/hearth/wake-proxy/guest-source.d.ts +0 -14
  48. package/dist/hearth/wake-proxy/guest-source.js +0 -130
  49. package/dist/hearth/wake-proxy/hearth-status.d.ts +0 -44
  50. package/dist/hearth/wake-proxy/hearth-status.js +0 -267
  51. package/dist/hearth/wake-proxy/home.d.ts +0 -61
  52. package/dist/hearth/wake-proxy/home.js +0 -333
  53. package/dist/hearth/wake-proxy/main.d.ts +0 -1
  54. package/dist/hearth/wake-proxy/main.js +0 -134
  55. package/dist/hearth/wake-proxy/model-auth.d.ts +0 -13
  56. package/dist/hearth/wake-proxy/model-auth.js +0 -345
  57. package/dist/hearth/wake-proxy/proxy.d.ts +0 -35
  58. package/dist/hearth/wake-proxy/proxy.js +0 -716
  59. package/dist/hearth/wake-proxy/public-source-gate.d.ts +0 -9
  60. package/dist/hearth/wake-proxy/public-source-gate.js +0 -409
  61. package/dist/hearth/wake-proxy/redact.d.ts +0 -1
  62. package/dist/hearth/wake-proxy/redact.js +0 -17
  63. package/dist/hearth/wake-proxy/server.d.ts +0 -11
  64. package/dist/hearth/wake-proxy/server.js +0 -142
  65. package/dist/hearth/wake-proxy/state.d.ts +0 -18
  66. package/dist/hearth/wake-proxy/state.js +0 -342
  67. package/dist/hearth/wake-proxy/types.d.ts +0 -76
  68. package/dist/hearth/wake-proxy/types.js +0 -1
  69. package/dist/web-client/assets/index-BRKxe-hy.js +0 -80
  70. package/dist/web-client/assets/index-BZUxTkv5.css +0 -2
@@ -1,143 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { EventEmitter } from 'node:events';
3
- import { mkdtempSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { WebSocket } from 'ws';
8
- import { configureWakeProxyRuntime } from '../home.js';
9
- import { proxyInternals } from '../proxy.js';
10
- import { getTenantActivitySnapshot } from '../state.js';
11
- // Regression for REPORT.md secondary defect: `activeCounts.ws` stayed non-zero after browsers
12
- // disconnected (stuck at 3 with no browser connected). Every terminal WS path — browser close,
13
- // upstream error, and wake timeout — must release the 'ws' activity so the count drains back to 0.
14
- const machineId = 'igd2fkrn1y1ol3vw5i6mt';
15
- const nodeId = 'mqt64sus-e71caa94';
16
- const routeUrl = 'https://7878-igd2fkrn1y1ol3vw5i6mt.example.dev';
17
- const now = '2026-06-25T00:00:00.000Z';
18
- function makeHome(tenantId) {
19
- return {
20
- tenantId,
21
- providerName: 'none',
22
- machineId,
23
- homeStorage: {
24
- kind: 'volume',
25
- durability: 'durable-volume',
26
- longTermContract: true,
27
- ownerMachineId: machineId,
28
- guestHome: '/home/agent',
29
- volumeId: '48b59d07-8cc9-46f9-bdfd-ac99a55dff40',
30
- mountPath: '/home/agent',
31
- },
32
- state: 'running',
33
- nodeId,
34
- relayToken: 'relay-token',
35
- webRoute: {
36
- routeId: '7878-igd2fkrn1y1ol3vw5i6mt.example.dev',
37
- machineId,
38
- targetPort: 7878,
39
- url: routeUrl,
40
- concreteUrl: routeUrl,
41
- concreteUrlSource: 'provider',
42
- createdAt: now,
43
- raw: null,
44
- },
45
- createdAt: now,
46
- updatedAt: now,
47
- };
48
- }
49
- function makeRuntime(tenantId, wakeTimeoutMs) {
50
- const tempDir = mkdtempSync(join(tmpdir(), 'crouter-hearth-ws-'));
51
- const registryPath = join(tempDir, 'homes.json');
52
- const statePath = join(tempDir, 'state.json');
53
- writeFileSync(registryPath, `${JSON.stringify({ [tenantId]: makeHome(tenantId) }, null, 2)}\n`, 'utf8');
54
- const runtime = {
55
- proxy: {
56
- host: '127.0.0.1',
57
- port: 7878,
58
- publicOrigin: 'https://hearth-wake-proxy.fly.dev',
59
- statePath,
60
- m0RegistryPath: registryPath,
61
- ownerUserId: 'owner-user',
62
- defaultTenantId: tenantId,
63
- ownerTokenHash: 'hash',
64
- idlePauseMs: 0,
65
- wakeTimeoutMs,
66
- readyTimeoutMs: 2_000,
67
- cookieSecure: true,
68
- seedHome: null,
69
- },
70
- m0: {
71
- providerName: 'none',
72
- registryPath,
73
- guestUser: 'agent',
74
- guestHome: '/home/agent',
75
- guestCodePath: '/opt/crouter',
76
- webPort: 7878,
77
- nodeKind: 'general',
78
- piPackages: [],
79
- piAuthJson: null,
80
- relayToken: null,
81
- },
82
- };
83
- configureWakeProxyRuntime(runtime);
84
- return runtime;
85
- }
86
- // Minimal WebSocket stand-in: the proxy only touches readyState, on/off/emit, ping, terminate,
87
- // send, and close. Events are driven manually; close() records but does not auto-emit so the two
88
- // legs don't cascade through each other.
89
- class FakeWs extends EventEmitter {
90
- readyState = WebSocket.OPEN;
91
- closeCalls = [];
92
- terminateCount = 0;
93
- ping() { }
94
- send() { }
95
- terminate() {
96
- this.terminateCount += 1;
97
- this.readyState = WebSocket.CLOSED;
98
- }
99
- close(code, reason) {
100
- this.closeCalls.push({ code: code ?? 1000, reason: reason ?? '' });
101
- this.readyState = WebSocket.CLOSED;
102
- }
103
- }
104
- function wsCount(tenantId) {
105
- return getTenantActivitySnapshot(tenantId).counts.ws;
106
- }
107
- test('ws activity drains to 0 when the browser closes', async () => {
108
- const tenantId = 'ws-close';
109
- const runtime = makeRuntime(tenantId, 30_000);
110
- const browser = new FakeWs();
111
- const upstream = new FakeWs();
112
- await proxyInternals.proxyWebSocketBrowser(browser, nodeId, tenantId, runtime, {
113
- ensureAwake: async () => ({ routeUrl, relayToken: 'relay-token' }),
114
- createUpstream: () => upstream,
115
- });
116
- assert.equal(wsCount(tenantId), 1);
117
- browser.emit('close', 1000, Buffer.from('bye'));
118
- assert.equal(wsCount(tenantId), 0);
119
- });
120
- test('ws activity drains to 0 when the upstream leg errors', async () => {
121
- const tenantId = 'ws-upstream-error';
122
- const runtime = makeRuntime(tenantId, 30_000);
123
- const browser = new FakeWs();
124
- const upstream = new FakeWs();
125
- await proxyInternals.proxyWebSocketBrowser(browser, nodeId, tenantId, runtime, {
126
- ensureAwake: async () => ({ routeUrl, relayToken: 'relay-token' }),
127
- createUpstream: () => upstream,
128
- });
129
- assert.equal(wsCount(tenantId), 1);
130
- upstream.emit('error', new Error('upstream boom'));
131
- assert.equal(wsCount(tenantId), 0);
132
- });
133
- test('ws activity drains to 0 when the wake times out', async () => {
134
- const tenantId = 'ws-wake-timeout';
135
- const runtime = makeRuntime(tenantId, 20);
136
- const browser = new FakeWs();
137
- await proxyInternals.proxyWebSocketBrowser(browser, nodeId, tenantId, runtime, {
138
- // Never resolves — withWakeTimeout(20ms) rejects and the catch path must release the activity.
139
- ensureAwake: () => new Promise(() => { }),
140
- createUpstream: () => new FakeWs(),
141
- });
142
- assert.equal(wsCount(tenantId), 0);
143
- });
@@ -1,58 +0,0 @@
1
- import type { TenantId } from '../../core/hearth/types.js';
2
- export declare const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
3
- export declare const ANTHROPIC_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
4
- export declare const ANTHROPIC_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
5
- export declare const ANTHROPIC_REDIRECT_URI = "http://localhost:53692/callback";
6
- export declare const ANTHROPIC_SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
7
- export declare const ANTHROPIC_SESSION_TTL_MS: number;
8
- export declare const ANTHROPIC_CALLBACK_MAX_URL_LENGTH = 2048;
9
- export declare const ANTHROPIC_TOKEN_TIMEOUT_MS = 30000;
10
- export declare const ANTHROPIC_TOKEN_SKEW_MS: number;
11
- export interface AnthropicOAuthCredential {
12
- type: 'oauth';
13
- refresh: string;
14
- access: string;
15
- expires: number;
16
- }
17
- export interface AnthropicOAuthSession {
18
- tenantId: TenantId;
19
- sessionId: string;
20
- state: string;
21
- verifier: string;
22
- createdAt: number;
23
- expiresAt: number;
24
- }
25
- export interface AnthropicOAuthStartSuccess {
26
- ok: true;
27
- sessionId: string;
28
- authUrl: string;
29
- expiresAt: number;
30
- }
31
- export type AnthropicOAuthStartResult = AnthropicOAuthStartSuccess;
32
- export interface AnthropicOAuthFinishInput {
33
- tenantId: TenantId;
34
- sessionId: string;
35
- redirectUrl: string;
36
- }
37
- export interface AnthropicOAuthFinishSuccess extends AnthropicOAuthCredential {
38
- ok: true;
39
- sessionId: string;
40
- credential: AnthropicOAuthCredential;
41
- expiresAt: number;
42
- }
43
- export interface AnthropicOAuthFinishFailure {
44
- ok: false;
45
- status: 400 | 401 | 409 | 502;
46
- error: string;
47
- }
48
- export type AnthropicOAuthFinishResult = AnthropicOAuthFinishSuccess | AnthropicOAuthFinishFailure;
49
- export interface AnthropicOAuthDeps {
50
- now?: () => number;
51
- randomBytes?: (size: number) => Buffer;
52
- sha256?: (input: string) => Buffer;
53
- fetch?: typeof fetch;
54
- fetchTimeoutMs?: number;
55
- }
56
- export declare function startAnthropicOAuthSession(tenantId: TenantId, now?: () => number, deps?: Pick<AnthropicOAuthDeps, 'randomBytes' | 'sha256'>): AnthropicOAuthStartResult;
57
- export declare function finishAnthropicOAuthSession(input: AnthropicOAuthFinishInput, deps?: AnthropicOAuthDeps): Promise<AnthropicOAuthFinishResult>;
58
- export declare function redactAnthropicOAuthMessage(message: string): string;
@@ -1,189 +0,0 @@
1
- import { createHash, randomBytes as nodeRandomBytes } from 'node:crypto';
2
- import { redactWakeProxyMessage } from './redact.js';
3
- export const ANTHROPIC_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
4
- export const ANTHROPIC_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
5
- export const ANTHROPIC_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
6
- export const ANTHROPIC_REDIRECT_URI = 'http://localhost:53692/callback';
7
- export const ANTHROPIC_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
8
- export const ANTHROPIC_SESSION_TTL_MS = 10 * 60_000;
9
- export const ANTHROPIC_CALLBACK_MAX_URL_LENGTH = 2048;
10
- export const ANTHROPIC_TOKEN_TIMEOUT_MS = 30_000;
11
- export const ANTHROPIC_TOKEN_SKEW_MS = 5 * 60_000;
12
- const sessions = new Map();
13
- function defaultRandomBytes(size) {
14
- return nodeRandomBytes(size);
15
- }
16
- function defaultSha256(value) {
17
- return createHash('sha256').update(value, 'utf8').digest();
18
- }
19
- function purgeExpiredSessions(now, tenantId) {
20
- for (const [key, session] of sessions) {
21
- if ((tenantId === undefined || key === tenantId) && session.expiresAt <= now) {
22
- sessions.delete(key);
23
- }
24
- }
25
- }
26
- function buildAuthUrl(session, challenge) {
27
- const params = new URLSearchParams({
28
- // claude.ai/oauth/authorize requires `code=true` (the Claude Code authorize
29
- // flow param); omitting it makes the endpoint reject with "Invalid request
30
- // format". Claude Code also requires `state` to be the PKCE verifier so
31
- // the authorize request satisfies `code_challenge === S256(state)`; the
32
- // installed @earendil-works/pi-ai adapter does the same (`state: verifier`).
33
- code: 'true',
34
- client_id: ANTHROPIC_CLIENT_ID,
35
- response_type: 'code',
36
- redirect_uri: ANTHROPIC_REDIRECT_URI,
37
- scope: ANTHROPIC_SCOPES,
38
- state: session.state,
39
- code_challenge: challenge,
40
- code_challenge_method: 'S256',
41
- });
42
- return `${ANTHROPIC_AUTHORIZE_URL}?${params.toString()}`;
43
- }
44
- function buildSession(tenantId, now, randomBytes, sha256) {
45
- const sessionId = randomBytes(16).toString('base64url');
46
- const verifier = randomBytes(32).toString('base64url');
47
- const session = {
48
- tenantId,
49
- sessionId,
50
- state: verifier,
51
- verifier,
52
- createdAt: now,
53
- expiresAt: now + ANTHROPIC_SESSION_TTL_MS,
54
- };
55
- const challenge = sha256(verifier).toString('base64url');
56
- return { session, authUrl: buildAuthUrl(session, challenge) };
57
- }
58
- function safeError(status, error) {
59
- return { ok: false, status, error: redactWakeProxyMessage(error) };
60
- }
61
- function parseCallbackUrl(raw) {
62
- if (raw.length > ANTHROPIC_CALLBACK_MAX_URL_LENGTH) {
63
- return safeError(400, 'Anthropic callback URL is too long');
64
- }
65
- let url;
66
- try {
67
- url = new URL(raw);
68
- }
69
- catch {
70
- return safeError(400, 'Anthropic callback URL is invalid');
71
- }
72
- if (url.protocol !== 'http:' || url.hostname !== 'localhost' || url.port !== '53692' || url.pathname !== '/callback') {
73
- return safeError(400, 'Anthropic callback URL must be http://localhost:53692/callback');
74
- }
75
- const error = url.searchParams.get('error');
76
- if (error !== null && error.trim() !== '') {
77
- return safeError(400, `Anthropic callback reported error=${error}`);
78
- }
79
- const code = url.searchParams.get('code')?.trim() ?? '';
80
- const state = url.searchParams.get('state')?.trim() ?? '';
81
- if (code === '' || state === '') {
82
- return safeError(400, 'Anthropic callback is missing code or state');
83
- }
84
- return { code, state };
85
- }
86
- async function postJson(url, body, deps) {
87
- const controller = new AbortController();
88
- const timeout = setTimeout(() => {
89
- controller.abort(new Error('Anthropic OAuth token exchange timed out'));
90
- }, deps.fetchTimeoutMs);
91
- timeout.unref();
92
- try {
93
- const response = await deps.fetch(url, {
94
- method: 'POST',
95
- headers: {
96
- 'content-type': 'application/json',
97
- accept: 'application/json',
98
- },
99
- body: JSON.stringify(body),
100
- signal: controller.signal,
101
- });
102
- const responseBody = await response.text();
103
- if (!response.ok) {
104
- return safeError(502, `Anthropic token exchange failed with HTTP ${response.status} ${response.statusText}${responseBody.trim() === '' ? '' : `: ${responseBody}`}`);
105
- }
106
- return { ok: true, body: responseBody };
107
- }
108
- catch (error) {
109
- const message = error instanceof Error ? error.message : String(error);
110
- return safeError(502, `Anthropic token exchange request failed: ${message}`);
111
- }
112
- finally {
113
- clearTimeout(timeout);
114
- }
115
- }
116
- function validateTokenExchangeResponse(body, now) {
117
- let parsed;
118
- try {
119
- parsed = JSON.parse(body);
120
- }
121
- catch {
122
- return safeError(502, `Anthropic token exchange returned invalid JSON: ${body}`);
123
- }
124
- const access = typeof parsed.access_token === 'string' ? parsed.access_token.trim() : '';
125
- const refresh = typeof parsed.refresh_token === 'string' ? parsed.refresh_token.trim() : '';
126
- const expiresIn = typeof parsed.expires_in === 'number' ? parsed.expires_in : Number.NaN;
127
- if (access === '' || refresh === '' || !Number.isFinite(expiresIn) || expiresIn <= 0) {
128
- return safeError(502, 'Anthropic token exchange response is missing access_token, refresh_token, or positive expires_in');
129
- }
130
- return { type: 'oauth', access, refresh, expires: now() + expiresIn * 1000 - ANTHROPIC_TOKEN_SKEW_MS };
131
- }
132
- export function startAnthropicOAuthSession(tenantId, now = Date.now, deps = {}) {
133
- const randomBytes = deps.randomBytes ?? defaultRandomBytes;
134
- const sha256 = deps.sha256 ?? defaultSha256;
135
- const current = now();
136
- // A fresh start always supersedes any prior pending session for this tenant
137
- // (stale lock, double-click, or lost popup). The newest session wins; any
138
- // superseded callback then fails the sessionId/state checks in
139
- // finishAnthropicOAuthSession, so old state can never write credentials.
140
- // GC expired sessions across all tenants while we hold the map.
141
- purgeExpiredSessions(current);
142
- const { session, authUrl } = buildSession(tenantId, current, randomBytes, sha256);
143
- sessions.set(tenantId, session);
144
- return { ok: true, sessionId: session.sessionId, authUrl, expiresAt: session.expiresAt };
145
- }
146
- export async function finishAnthropicOAuthSession(input, deps = {}) {
147
- const now = deps.now ?? Date.now;
148
- const fetchImpl = deps.fetch ?? fetch;
149
- const fetchTimeoutMs = deps.fetchTimeoutMs ?? ANTHROPIC_TOKEN_TIMEOUT_MS;
150
- const current = now();
151
- purgeExpiredSessions(current, input.tenantId);
152
- const session = sessions.get(input.tenantId);
153
- if (session === undefined) {
154
- return safeError(400, 'Anthropic OAuth session not found or expired');
155
- }
156
- sessions.delete(input.tenantId);
157
- if (session.sessionId !== input.sessionId) {
158
- return safeError(400, 'Anthropic OAuth session id does not match the active session');
159
- }
160
- const parsed = parseCallbackUrl(input.redirectUrl);
161
- if ('status' in parsed)
162
- return parsed;
163
- if (parsed.state !== session.state) {
164
- return safeError(400, 'Anthropic OAuth callback state mismatch');
165
- }
166
- const result = await postJson(ANTHROPIC_TOKEN_URL, {
167
- grant_type: 'authorization_code',
168
- client_id: ANTHROPIC_CLIENT_ID,
169
- code: parsed.code,
170
- state: parsed.state,
171
- redirect_uri: ANTHROPIC_REDIRECT_URI,
172
- code_verifier: session.verifier,
173
- }, { fetch: fetchImpl, fetchTimeoutMs });
174
- if ('status' in result)
175
- return result;
176
- const credential = validateTokenExchangeResponse(result.body, now);
177
- if ('status' in credential)
178
- return credential;
179
- return {
180
- ok: true,
181
- sessionId: session.sessionId,
182
- ...credential,
183
- credential,
184
- expiresAt: credential.expires,
185
- };
186
- }
187
- export function redactAnthropicOAuthMessage(message) {
188
- return redactWakeProxyMessage(message);
189
- }
@@ -1,22 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import type { HearthProxyConfig, HearthProxyState, HearthWakeProxyRuntime } from './types.js';
3
- export type OwnerAuthSource = 'query' | 'bearer' | 'cookie';
4
- export interface OwnerAuthResult {
5
- ownerUserId: string;
6
- tenantId: string;
7
- source: OwnerAuthSource;
8
- token: string;
9
- }
10
- export interface ResolveOwnerAuthOptions {
11
- rejectQuery?: boolean;
12
- requireCookieOrigin?: boolean;
13
- }
14
- export declare function currentProxyState(runtime: HearthWakeProxyRuntime): HearthProxyState;
15
- export declare function resolveOwnerAuth(req: IncomingMessage, config: HearthProxyConfig, state: HearthProxyState, options?: ResolveOwnerAuthOptions): OwnerAuthResult | null;
16
- export declare function resolveOwnerWriteAuth(req: IncomingMessage, config: HearthProxyConfig, state: HearthProxyState): OwnerAuthResult | null;
17
- export declare function requireOwner(req: IncomingMessage, res: ServerResponse, config: HearthProxyConfig, state: HearthProxyState): {
18
- ownerUserId: string;
19
- tenantId: string;
20
- } | null;
21
- export declare function setOwnerCookie(res: ServerResponse, token: string, config: HearthProxyConfig): void;
22
- export declare function ownerAuthRedactedMessage(message: string): string;
@@ -1,128 +0,0 @@
1
- import { createHash, timingSafeEqual } from 'node:crypto';
2
- import { general } from '../../core/errors.js';
3
- import { ensureProxyState, loadProxyState } from './state.js';
4
- const COOKIE_NAME = 'hearth_owner';
5
- function sha256Hex(token) {
6
- return createHash('sha256').update(token, 'utf8').digest('hex');
7
- }
8
- function normalizeHash(hash) {
9
- const trimmed = hash.trim();
10
- const body = trimmed.startsWith('sha256:') ? trimmed.slice('sha256:'.length) : trimmed;
11
- return /^[0-9a-fA-F]{64}$/.test(body) ? body.toLowerCase() : '';
12
- }
13
- function parseCookieHeader(header) {
14
- const out = {};
15
- if (header === undefined || header.trim() === '')
16
- return out;
17
- for (const part of header.split(';')) {
18
- const idx = part.indexOf('=');
19
- if (idx <= 0)
20
- continue;
21
- const key = part.slice(0, idx).trim();
22
- const value = part.slice(idx + 1).trim();
23
- if (key !== '')
24
- out[key] = value;
25
- }
26
- return out;
27
- }
28
- function requestUrl(req) {
29
- return new URL(req.url ?? '/', 'http://127.0.0.1');
30
- }
31
- export function currentProxyState(runtime) {
32
- return loadProxyState(runtime.proxy.statePath) ?? ensureProxyState(runtime.proxy);
33
- }
34
- function queryToken(req) {
35
- const token = requestUrl(req).searchParams.get('token')?.trim() ?? '';
36
- return token === '' ? null : token;
37
- }
38
- function bearerToken(req) {
39
- const authorization = req.headers.authorization;
40
- if (typeof authorization !== 'string')
41
- return null;
42
- const match = /^Bearer\s+(.+)$/.exec(authorization.trim());
43
- const token = match?.[1]?.trim() ?? '';
44
- return token === '' ? null : token;
45
- }
46
- function cookieToken(req) {
47
- const token = parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
48
- if (token === undefined || token.trim() === '')
49
- return null;
50
- try {
51
- return decodeURIComponent(token.trim());
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
57
- function tokenMatches(secretHash, token) {
58
- const expected = normalizeHash(secretHash);
59
- if (expected === '')
60
- return false;
61
- const candidate = sha256Hex(token);
62
- return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(candidate, 'hex'));
63
- }
64
- export function resolveOwnerAuth(req, config, state, options = {}) {
65
- const query = queryToken(req);
66
- if (query !== null) {
67
- if (options.rejectQuery === true)
68
- return null;
69
- if (tokenMatches(state.owner.tokenHash, query)) {
70
- return { ownerUserId: state.owner.ownerUserId, tenantId: state.owner.defaultTenantId, source: 'query', token: query };
71
- }
72
- return null;
73
- }
74
- const bearer = bearerToken(req);
75
- if (bearer !== null) {
76
- if (tokenMatches(state.owner.tokenHash, bearer)) {
77
- return { ownerUserId: state.owner.ownerUserId, tenantId: state.owner.defaultTenantId, source: 'bearer', token: bearer };
78
- }
79
- return null;
80
- }
81
- const cookie = cookieToken(req);
82
- if (cookie !== null) {
83
- if (!tokenMatches(state.owner.tokenHash, cookie))
84
- return null;
85
- const origin = req.headers.origin?.trim() ?? '';
86
- if (options.requireCookieOrigin === true) {
87
- if (origin === '' || origin !== config.publicOrigin) {
88
- throw general('owner cookie origin mismatch', { expectedOrigin: config.publicOrigin, origin: origin === '' ? null : origin });
89
- }
90
- }
91
- else if (origin !== '' && origin !== config.publicOrigin) {
92
- throw general('owner cookie origin mismatch', { expectedOrigin: config.publicOrigin, origin });
93
- }
94
- return { ownerUserId: state.owner.ownerUserId, tenantId: state.owner.defaultTenantId, source: 'cookie', token: cookie };
95
- }
96
- return null;
97
- }
98
- export function resolveOwnerWriteAuth(req, config, state) {
99
- return resolveOwnerAuth(req, config, state, { rejectQuery: true, requireCookieOrigin: true });
100
- }
101
- export function requireOwner(req, res, config, state) {
102
- const auth = resolveOwnerAuth(req, config, state);
103
- if (auth !== null)
104
- return { ownerUserId: auth.ownerUserId, tenantId: auth.tenantId };
105
- if (!res.headersSent) {
106
- res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
107
- }
108
- res.end('owner token required\n');
109
- return null;
110
- }
111
- export function setOwnerCookie(res, token, config) {
112
- void config.cookieSecure;
113
- const cookie = [
114
- `${COOKIE_NAME}=${encodeURIComponent(token)}`,
115
- 'Path=/',
116
- 'HttpOnly',
117
- 'Secure',
118
- 'SameSite=Lax',
119
- `Max-Age=${365 * 24 * 60 * 60}`,
120
- ].join('; ');
121
- res.setHeader('Set-Cookie', cookie);
122
- }
123
- export function ownerAuthRedactedMessage(message) {
124
- return message
125
- .replace(/(Authorization:\s*Bearer\s+)[^\s]+/gi, '$1[redacted]')
126
- .replace(/([?&]token=)[^&#\s]+/gi, '$1[redacted]')
127
- .replace(/(hearth_owner=)[^;\s]+/gi, '$1[redacted]');
128
- }
@@ -1,2 +0,0 @@
1
- import type { HearthProxyConfig } from './types.js';
2
- export declare function loadHearthProxyConfig(env?: NodeJS.ProcessEnv): HearthProxyConfig;