spexcode 0.4.1 → 0.4.3

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 (106) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/cli.ts +88 -37
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +27 -19
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +54 -43
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +105 -55
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +181 -33
  19. package/spec-cli/src/help.ts +64 -27
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +145 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +25 -121
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +14 -0
  39. package/spec-cli/src/sessions.ts +188 -66
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +12 -7
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  48. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +49 -28
  90. package/spec-eval/src/humanok.ts +1 -1
  91. package/spec-eval/src/matrix.ts +693 -0
  92. package/spec-eval/src/sessioneval.ts +726 -66
  93. package/spec-cli/src/migrate-table.ts +0 -408
  94. package/spec-cli/src/migrate.ts +0 -386
  95. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  96. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  97. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  98. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  99. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  100. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  101. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  102. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  103. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  104. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  105. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  106. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -0,0 +1,194 @@
1
+ // @@@ gateway auth - the ONE authorization mechanism of the multi-project gateway ([[gateway-hub]]).
2
+ // Everything about passwords and visitors lives HERE, at the gateway: the project backends stay loopback
3
+ // internal services that never see a credential, a cookie, or a visitor identity. Two signed scopes only:
4
+ // an ADMIN session grants /projects (management) plus every /p/:projectId route; a PROJECT session grants
5
+ // exactly its own /p/:projectId route. A project with no configured password is open; with no admin
6
+ // password, loopback may manage implicitly while non-loopback /projects stays locked.
7
+ //
8
+ // Secrets discipline: password VERIFIERS (scrypt, salted) live only in the per-user private store
9
+ // (~/.spexcode/gateway/auth.json, 0600) — never in a repo, never in a backend record, never plaintext.
10
+ // Session tokens are HMAC-signed claims under a random per-user secret, so they survive a gateway restart
11
+ // with no server-side session table, verified in constant time. Each verifier carries a random `gen` that
12
+ // rotates on every set/clear and is embedded in the tokens it authenticates — changing or clearing a
13
+ // password instantly invalidates every session it minted.
14
+ import { createHmac, createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
15
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+ import { spexcodeHome } from './layout.js'
18
+
19
+ export type Verifier = { algo: 'scrypt'; N: number; r: number; p: number; salt: string; hash: string; gen: string }
20
+ export type AuthStore = { v: 1; secret: string; admin?: Verifier; projects: Record<string, Verifier> }
21
+ export type Scope = { s: 'admin' } | { s: 'project'; p: string }
22
+ export type Claims = { v: 1; s: 'admin' | 'project'; p?: string; g: string; t: number }
23
+ export type Decision =
24
+ | { ok: true; via: 'admin' | 'project' | 'open' | 'loopback' }
25
+ | { ok: false; reason: 'admin-login' | 'locked' | 'project-login' }
26
+
27
+ const TOKEN_TTL_MS = 30 * 24 * 3600 * 1000 // matches the single-project gateway's 30-day cookie
28
+ const SCRYPT = { N: 16384, r: 8, p: 1 } as const
29
+
30
+ // ---- the private per-user store --------------------------------------------------------------------
31
+
32
+ export function authStorePath(): string {
33
+ return join(spexcodeHome(), 'gateway', 'auth.json')
34
+ }
35
+
36
+ // first load creates the store (and its random signing secret) so sessions survive restarts; the file is
37
+ // 0600 in a 0700 dir — this is a per-USER secret store, no other principal has business reading it.
38
+ export function loadAuthStore(): AuthStore {
39
+ try {
40
+ const raw = JSON.parse(readFileSync(authStorePath(), 'utf8'))
41
+ if (raw?.v === 1 && typeof raw.secret === 'string' && raw.secret) return { v: 1, secret: raw.secret, admin: raw.admin, projects: raw.projects ?? {} }
42
+ } catch { /* absent or unreadable → fresh below; malformed secrets must not be half-trusted */ }
43
+ const fresh: AuthStore = { v: 1, secret: randomBytes(32).toString('base64url'), projects: {} }
44
+ saveAuthStore(fresh)
45
+ return fresh
46
+ }
47
+
48
+ function saveAuthStore(store: AuthStore): void {
49
+ const file = authStorePath()
50
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
51
+ const tmp = `${file}.tmp`
52
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
53
+ renameSync(tmp, file)
54
+ chmodSync(file, 0o600) // rename preserves the tmp mode, but never let a pre-existing looser file win
55
+ }
56
+
57
+ // ---- password verifiers (scrypt, salted, constant-time) --------------------------------------------
58
+
59
+ export function makeVerifier(password: string): Verifier {
60
+ const salt = randomBytes(16)
61
+ const hash = scryptSync(password, salt, 32, SCRYPT)
62
+ return { algo: 'scrypt', ...SCRYPT, salt: salt.toString('base64url'), hash: hash.toString('base64url'), gen: randomBytes(8).toString('base64url') }
63
+ }
64
+
65
+ export function verifyPassword(v: Verifier | undefined, password: string): boolean {
66
+ if (!v || v.algo !== 'scrypt' || typeof password !== 'string') return false
67
+ const got = scryptSync(password, Buffer.from(v.salt, 'base64url'), 32, { N: v.N, r: v.r, p: v.p })
68
+ return constEq(got, Buffer.from(v.hash, 'base64url'))
69
+ }
70
+
71
+ function constEq(a: Buffer | string, b: Buffer | string): boolean {
72
+ const ab = Buffer.isBuffer(a) ? a : Buffer.from(a)
73
+ const bb = Buffer.isBuffer(b) ? b : Buffer.from(b)
74
+ return ab.length === bb.length && timingSafeEqual(ab, bb)
75
+ }
76
+
77
+ // ---- verifier management (the gateway admin APIs write through these) ------------------------------
78
+
79
+ export function setAdminPassword(password: string): AuthStore {
80
+ const store = loadAuthStore()
81
+ store.admin = makeVerifier(password)
82
+ saveAuthStore(store)
83
+ return store
84
+ }
85
+ export function clearAdminPassword(): AuthStore {
86
+ const store = loadAuthStore()
87
+ delete store.admin
88
+ saveAuthStore(store)
89
+ return store
90
+ }
91
+ export function setProjectPassword(projectId: string, password: string): AuthStore {
92
+ const store = loadAuthStore()
93
+ store.projects[projectId] = makeVerifier(password)
94
+ saveAuthStore(store)
95
+ return store
96
+ }
97
+ export function clearProjectPassword(projectId: string): AuthStore {
98
+ const store = loadAuthStore()
99
+ delete store.projects[projectId]
100
+ saveAuthStore(store)
101
+ return store
102
+ }
103
+
104
+ // ---- signed session tokens --------------------------------------------------------------------------
105
+
106
+ function sign(secret: string, payload: string): string {
107
+ return createHmac('sha256', Buffer.from(secret, 'base64url')).update(payload).digest('base64url')
108
+ }
109
+
110
+ // a token exists only for a scope that has a verifier — its `g` claim is that verifier's current gen.
111
+ export function mintToken(store: AuthStore, scope: Scope, nowMs = Date.now()): string {
112
+ const v = scope.s === 'admin' ? store.admin : store.projects[scope.p]
113
+ if (!v) throw new Error(`gateway-auth: cannot mint a ${scope.s} token with no verifier configured`)
114
+ const claims: Claims = { v: 1, s: scope.s, ...(scope.s === 'project' ? { p: scope.p } : {}), g: v.gen, t: nowMs }
115
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
116
+ return `${payload}.${sign(store.secret, payload)}`
117
+ }
118
+
119
+ // full validity lives here: signature (constant-time), shape, expiry, and the gen check against the
120
+ // CURRENT verifier — a cleared or re-set password leaves no valid tokens behind.
121
+ export function verifyToken(store: AuthStore, token: string, nowMs = Date.now()): Claims | null {
122
+ const dot = token.indexOf('.')
123
+ if (dot <= 0) return null
124
+ const payload = token.slice(0, dot), sig = token.slice(dot + 1)
125
+ if (!constEq(sig, sign(store.secret, payload))) return null
126
+ let claims: Claims
127
+ try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) } catch { return null }
128
+ if (claims?.v !== 1 || typeof claims.t !== 'number' || typeof claims.g !== 'string') return null
129
+ if (nowMs > claims.t + TOKEN_TTL_MS || claims.t > nowMs + 60_000) return null
130
+ if (claims.s === 'admin') return store.admin && constEq(claims.g, store.admin.gen) ? claims : null
131
+ if (claims.s === 'project' && typeof claims.p === 'string') {
132
+ const v = store.projects[claims.p]
133
+ return v && constEq(claims.g, v.gen) ? claims : null
134
+ }
135
+ return null
136
+ }
137
+
138
+ // ---- cookies ----------------------------------------------------------------------------------------
139
+ // Cookie names are keyed by the gateway's public port (cookies are host-scoped; the port is the
140
+ // discriminator — same rationale as the single-project gateway) and, for project scopes, by a hash of the
141
+ // projectId (ids are path-derived and may hold non-token chars). The NAME is only a mailbox: authorization
142
+ // always re-validates the token's own projectId claim against the requested route — never the cookie's
143
+ // name, and never its Path attribute, both of which are client-controlled.
144
+
145
+ export function adminCookieName(port: number): string { return `spex_admin_${port}` }
146
+ export function projectCookieName(port: number, projectId: string): string {
147
+ return `spex_proj_${port}_${createHash('sha256').update(projectId).digest('hex').slice(0, 12)}`
148
+ }
149
+
150
+ export function cookieOf(header: string | undefined, name: string): string | null {
151
+ for (const part of (header ?? '').split(';')) {
152
+ const eq = part.indexOf('=')
153
+ if (eq > 0 && part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim())
154
+ }
155
+ return null
156
+ }
157
+
158
+ // ---- loopback ---------------------------------------------------------------------------------------
159
+ // The implicit-admin decision reads the SOCKET's remote address only. Never a header (X-Forwarded-For,
160
+ // X-Real-IP): headers are attacker-controlled, and this gateway is designed to face the internet directly,
161
+ // not to sit behind a trusted proxy.
162
+ export function isLoopback(addr: string | null | undefined): boolean {
163
+ if (!addr) return false
164
+ const a = addr.startsWith('::ffff:') ? addr.slice(7) : addr
165
+ return a === '::1' || /^127(\.\d{1,3}){3}$/.test(a)
166
+ }
167
+
168
+ // ---- the decision -----------------------------------------------------------------------------------
169
+
170
+ export type Route = { kind: 'admin' } | { kind: 'project'; projectId: string }
171
+
172
+ export function authorize(store: AuthStore, route: Route, cookieHeader: string | undefined, remoteAddr: string | null | undefined, port: number): Decision {
173
+ const adminTok = cookieOf(cookieHeader, adminCookieName(port))
174
+ const adminClaims = adminTok ? verifyToken(store, adminTok) : null
175
+ const adminOk = adminClaims?.s === 'admin'
176
+
177
+ if (route.kind === 'admin') {
178
+ if (adminOk) return { ok: true, via: 'admin' }
179
+ // no admin verifier configured: loopback manages implicitly (the bootstrap path — set the first
180
+ // password from the machine itself); anyone else finds /projects locked, not open.
181
+ if (!store.admin) return isLoopback(remoteAddr) ? { ok: true, via: 'loopback' } : { ok: false, reason: 'locked' }
182
+ return { ok: false, reason: 'admin-login' }
183
+ }
184
+
185
+ // project route — admin reaches every project; then the project's own gate decides.
186
+ if (adminOk) return { ok: true, via: 'admin' }
187
+ if (!store.projects[route.projectId]) return { ok: true, via: 'open' }
188
+ const tok = cookieOf(cookieHeader, projectCookieName(port, route.projectId))
189
+ const claims = tok ? verifyToken(store, tok) : null
190
+ // the projectId CLAIM must match the route — presenting project A's token under project B's cookie
191
+ // name (or any Path trick) authorizes nothing.
192
+ if (claims?.s === 'project' && claims.p === route.projectId) return { ok: true, via: 'project' }
193
+ return { ok: false, reason: 'project-login' }
194
+ }
@@ -0,0 +1,360 @@
1
+ // @@@ gateway hub - the multi-project face of [[public-mode]]: ONE gateway fronting every project backend
2
+ // this user runs. The backends stay loopback internal services (each `spex serve` records its endpoint in
3
+ // ~/.spexcode/projects/<enc>/backend.json — that record IS the hub's registry); the hub terminates the
4
+ // outside world, decides authorization ([[gateway-auth]] — the single mechanism, two signed scopes), and
5
+ // reverse-proxies into the matching loopback backend with the gateway's own cookies STRIPPED, so a backend
6
+ // never sees a credential.
7
+ //
8
+ // The route contract:
9
+ // /login /logout admin session (the designed login page)
10
+ // GET /projects admin: list the registry (+ gating state); with a host shell
11
+ // mounted, an explicit text/html GET gets the Projects UI instead
12
+ // PUT|DELETE /projects/admin-password admin: set/clear the admin password
13
+ // PUT|DELETE /projects/:id/password admin: set/clear one project's password
14
+ // /p/:projectId/login|logout project session for that project
15
+ // ANY /p/:projectId/* (+ WS upgrade) authorized → proxied, prefix-stripped, to that project's backend
16
+ // Authorization never trusts the cookie's name or Path — the token's projectId claim is validated against
17
+ // the :projectId in the URL on every request (see gateway-auth.ts).
18
+ import http from 'node:http'
19
+ import https from 'node:https'
20
+ import net from 'node:net'
21
+ import { readdirSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import {
24
+ adminCookieName, authorize, clearAdminPassword, clearProjectPassword, loadAuthStore, mintToken,
25
+ projectCookieName, setAdminPassword, setProjectPassword, verifyPassword, type AuthStore,
26
+ } from './gateway-auth.js'
27
+ import { loginPage } from './login-page.js'
28
+ import { listenOrExit } from './listen.js'
29
+ import { installConnectionReaper } from './reaper.js'
30
+ import { spexcodeHome, encodeProject } from './layout.js'
31
+ import { readEndpointRecord } from './host.js'
32
+ import { readGatewayIdentity, type ResolvedIdentity } from './project-identity.js'
33
+
34
+ export type HubProject = { id: string; identity: ResolvedIdentity; url: string; port: number; gated: boolean }
35
+ // @@@ extension seam - the hub stays the ONE routing+authorization server; a host-level caller
36
+ // ([[host-gateway]]'s `spex dashboard`) plugs richer behavior in here instead of running a second
37
+ // gateway beside it. All three hooks are optional; bare startHubGateway behavior is unchanged.
38
+ export type HubExtensions = {
39
+ // enriched GET /projects rows (reconciled online/offline entries + the durable catalog); the hub
40
+ // still owns the envelope ({adminGated, projects}) and the admin gate in front of it.
41
+ listProjects?: (store: AuthStore) => Promise<object[]> | object[]
42
+ // extra ADMIN-scope routes under /projects/* (stream, registration, project operations) — called only
43
+ // AFTER admin authorization, for /projects paths the hub itself doesn't handle. True = handled.
44
+ adminRoute?: (req: http.IncomingMessage, res: http.ServerResponse, path: string) => Promise<boolean> | boolean
45
+ // paths the hub would 404 (everything outside /projects, /p, /login) — the dashboard SPA shell +
46
+ // assets. Unauthorized by design: the shell is code, not data; every data call it makes re-enters
47
+ // the authorized routes.
48
+ fallback?: (req: http.IncomingMessage, res: http.ServerResponse, path: string) => void
49
+ }
50
+ export type HubOpts = { port: number; host?: string; tls?: { cert: string; key: string } | null; label?: string; onBindFail?: () => void; extensions?: HubExtensions }
51
+
52
+ // ---- registry ---------------------------------------------------------------------------------------
53
+ // A project = a live backend record under ~/.spexcode/projects/<enc>/backend.json (written by supervise.ts
54
+ // at bind time). The <enc> dir name is the projectId. ONE record-read seam ([[host-gateway]]'s
55
+ // readEndpointRecord — the identity-carrying shape): a legacy or torn record is not routable, and a record
56
+ // sitting in a slot its own root does not encode to is not trusted. Only LOOPBACK upstream urls are
57
+ // honored — the hub proxies into this machine's trust boundary, never out to an arbitrary host a crafted
58
+ // record names.
59
+
60
+ function upstreamOf(id: string): { url: string; port: number; identity: ResolvedIdentity } | null {
61
+ const rec = readEndpointRecord(join(spexcodeHome(), 'projects', id, 'backend.json'))
62
+ if (!rec || encodeProject(rec.root) !== id) return null
63
+ let u: URL
64
+ try { u = new URL(rec.url) } catch { return null }
65
+ if (u.protocol !== 'http:' || (u.hostname !== '127.0.0.1' && u.hostname !== 'localhost')) {
66
+ console.error(`[hub] ignoring project '${id}': backend url ${rec.url} is not a loopback http endpoint`)
67
+ return null
68
+ }
69
+ const port = Number(u.port || 80)
70
+ if (!Number.isInteger(port) || port <= 0) return null
71
+ return { url: rec.url, port, identity: rec.identity }
72
+ }
73
+
74
+ // a projectId arrives as ONE url path segment; reject anything that could re-shape the registry path.
75
+ function validProjectId(id: string): boolean {
76
+ return id.length > 0 && id.length <= 256 && id !== '.' && id !== '..' &&
77
+ !id.includes('/') && !id.includes('\\') && !id.includes('\0')
78
+ }
79
+
80
+ export function listHubProjects(store: AuthStore): HubProject[] {
81
+ const dir = join(spexcodeHome(), 'projects')
82
+ let entries: string[] = []
83
+ try { entries = readdirSync(dir) } catch { /* no projects yet */ }
84
+ const out: HubProject[] = []
85
+ for (const id of entries) {
86
+ const up = upstreamOf(id)
87
+ if (!up) continue
88
+ out.push({ id, identity: up.identity, url: up.url, port: up.port, gated: !!store.projects[id] })
89
+ }
90
+ return out.sort((a, b) => a.id.localeCompare(b.id))
91
+ }
92
+
93
+ // ---- helpers ----------------------------------------------------------------------------------------
94
+
95
+ function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
96
+ res.writeHead(status, { 'Content-Type': 'application/json' })
97
+ res.end(JSON.stringify(body))
98
+ }
99
+ function sendHtml(res: http.ServerResponse, status: number, html: string): void {
100
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' })
101
+ res.end(html)
102
+ }
103
+ function redirect(res: http.ServerResponse, location: string, setCookie?: string): void {
104
+ res.writeHead(302, { Location: location, ...(setCookie ? { 'Set-Cookie': setCookie } : {}) })
105
+ res.end()
106
+ }
107
+
108
+ function readBody(req: http.IncomingMessage, limit = 4096): Promise<string> {
109
+ return new Promise((resolve) => {
110
+ let body = ''
111
+ req.on('data', (d) => { body += d; if (body.length > limit) { req.destroy(); resolve('') } })
112
+ req.on('end', () => resolve(body))
113
+ req.on('error', () => resolve(''))
114
+ })
115
+ }
116
+
117
+ // the login POST accepts the designed page's form encoding or JSON (the future admin UI)
118
+ function passwordOf(req: http.IncomingMessage, body: string): string {
119
+ try {
120
+ return req.headers['content-type']?.includes('application/json')
121
+ ? String(JSON.parse(body).password ?? '')
122
+ : new URLSearchParams(body).get('password') ?? ''
123
+ } catch { return '' }
124
+ }
125
+
126
+ // the gateway's own cookies never cross into a backend — a backend knows nothing about visitors, and a
127
+ // leaked gateway token in some backend log would be a credential spill. Everything else passes through.
128
+ function stripGatewayCookies(header: string | undefined): string {
129
+ return (header ?? '').split(';').map((s) => s.trim())
130
+ .filter((p) => p && !/^spex_(admin|proj|auth)_/i.test(p.slice(0, Math.max(p.indexOf('='), 0))))
131
+ .join('; ')
132
+ }
133
+
134
+ function cookieAttrs(secure: boolean): string {
135
+ return `; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
136
+ }
137
+
138
+ // ---- the hub ----------------------------------------------------------------------------------------
139
+
140
+ export function startHubGateway(opts: HubOpts): http.Server {
141
+ const secure = !!opts.tls
142
+ const port = opts.port
143
+ const ext = opts.extensions ?? {}
144
+ const attrs = cookieAttrs(secure)
145
+ const clearCookie = (name: string) => `${name}=; HttpOnly; Path=/; Max-Age=0`
146
+
147
+ const handler = async (req: http.IncomingMessage, res: http.ServerResponse) => {
148
+ // the store is re-read per request: a password set/clear (this process or another) is live at once,
149
+ // and there is no in-memory session state to invalidate.
150
+ const store = loadAuthStore()
151
+ const rawUrl = req.url || '/'
152
+ const q = rawUrl.indexOf('?')
153
+ const path = q >= 0 ? rawUrl.slice(0, q) : rawUrl
154
+ const query = q >= 0 ? rawUrl.slice(q) : ''
155
+ const remote = req.socket.remoteAddress
156
+ const cookies = req.headers.cookie
157
+ const adminz = () => authorize(store, { kind: 'admin' }, cookies, remote, port)
158
+
159
+ // ---- admin surface ----
160
+ if (path === '/') return redirect(res, '/projects')
161
+ if (path === '/login') {
162
+ if (!store.admin) return req.method === 'POST'
163
+ ? sendJson(res, 403, { error: 'no admin password is configured — /projects is manageable from loopback only' })
164
+ : redirect(res, '/projects')
165
+ if (req.method === 'POST') {
166
+ const pw = passwordOf(req, await readBody(req))
167
+ if (verifyPassword(store.admin, pw)) return redirect(res, '/projects', `${adminCookieName(port)}=${mintToken(store, { s: 'admin' })}${attrs}`)
168
+ return sendHtml(res, 401, loginPage(true))
169
+ }
170
+ return sendHtml(res, 200, loginPage(false))
171
+ }
172
+ if (path === '/logout') return redirect(res, '/login', clearCookie(adminCookieName(port)))
173
+
174
+ if (path === '/projects' || path.startsWith('/projects/')) {
175
+ // browser navigation (the `/` redirect lands here with Accept: text/html) wants the Projects UI,
176
+ // not the catalog envelope. With a host shell mounted, an explicit text/html GET is served the SPA
177
+ // shell — code, not data, same unauthorized-by-design posture as the fallback seam: every data call
178
+ // the shell makes re-enters the JSON route below with its auth gate intact. API fetches
179
+ // (application/json, */*, no Accept) keep the JSON catalog unchanged, as does the bare hub.
180
+ if (path === '/projects' && req.method === 'GET' && ext.fallback && (req.headers.accept ?? '').includes('text/html')) {
181
+ return ext.fallback(req, res, path)
182
+ }
183
+ const d = adminz()
184
+ if (!d.ok) {
185
+ return d.reason === 'locked'
186
+ ? sendJson(res, 403, { error: 'admin surface is locked: no admin password is configured and this is not a loopback connection' })
187
+ : sendJson(res, 401, { error: 'authentication required', login: '/login' })
188
+ }
189
+ if (path === '/projects' && req.method === 'GET') {
190
+ const gateway = readGatewayIdentity()
191
+ return sendJson(res, 200, {
192
+ adminGated: !!store.admin,
193
+ gateway: { ...gateway.identity, revision: gateway.revision },
194
+ projects: await (ext.listProjects ? ext.listProjects(store) : listHubProjects(store)),
195
+ })
196
+ }
197
+ if (path === '/projects/admin-password') {
198
+ if (req.method === 'PUT') {
199
+ const pw = passwordOf(req, await readBody(req))
200
+ if (!pw) return sendJson(res, 400, { error: 'body must be JSON {"password": "<non-empty>"}' })
201
+ const next = setAdminPassword(pw)
202
+ // the setter stays authenticated: implicit-loopback access ends the moment the password exists,
203
+ // so hand them a session minted under the new verifier in the same response.
204
+ res.setHeader('Set-Cookie', `${adminCookieName(port)}=${mintToken(next, { s: 'admin' })}${attrs}`)
205
+ return sendJson(res, 200, { ok: true })
206
+ }
207
+ if (req.method === 'DELETE') {
208
+ clearAdminPassword()
209
+ res.setHeader('Set-Cookie', clearCookie(adminCookieName(port)))
210
+ return sendJson(res, 200, { ok: true })
211
+ }
212
+ return sendJson(res, 405, { error: 'PUT or DELETE' })
213
+ }
214
+ const m = path.match(/^\/projects\/([^/]+)\/password$/)
215
+ if (m) {
216
+ const id = decodeURIComponent(m[1])
217
+ if (!validProjectId(id)) return sendJson(res, 404, { error: 'unknown project' })
218
+ if (req.method === 'PUT') {
219
+ if (!upstreamOf(id)) return sendJson(res, 404, { error: 'unknown project' })
220
+ const pw = passwordOf(req, await readBody(req))
221
+ if (!pw) return sendJson(res, 400, { error: 'body must be JSON {"password": "<non-empty>"}' })
222
+ setProjectPassword(id, pw)
223
+ return sendJson(res, 200, { ok: true })
224
+ }
225
+ if (req.method === 'DELETE') { // clearing works even for a retired registry entry — the cleanup path
226
+ clearProjectPassword(id)
227
+ return sendJson(res, 200, { ok: true })
228
+ }
229
+ return sendJson(res, 405, { error: 'PUT or DELETE' })
230
+ }
231
+ // host-level admin routes ([[host-gateway]]: /projects/stream, registration, project operations)
232
+ // ride the SAME admin gate — the extension only ever sees an authorized request.
233
+ if (ext.adminRoute && await ext.adminRoute(req, res, path)) return
234
+ return sendJson(res, 404, { error: 'not found' })
235
+ }
236
+
237
+ // ---- project surface ----
238
+ const pm = path.match(/^\/p\/([^/]+)(\/.*)?$/)
239
+ if (pm) {
240
+ const id = decodeURIComponent(pm[1])
241
+ if (!validProjectId(id)) return sendJson(res, 404, { error: 'unknown project' })
242
+ const up = upstreamOf(id)
243
+ if (!up) return sendJson(res, 404, { error: 'unknown project' })
244
+ const sub = pm[2] || '/'
245
+ const base = `/p/${encodeURIComponent(id)}`
246
+ const gated = !!store.projects[id]
247
+ if (sub === '/login') {
248
+ if (!gated) return redirect(res, `${base}/`)
249
+ if (req.method === 'POST') {
250
+ const pw = passwordOf(req, await readBody(req))
251
+ if (verifyPassword(store.projects[id], pw)) {
252
+ return redirect(res, `${base}/`, `${projectCookieName(port, id)}=${mintToken(store, { s: 'project', p: id })}${attrs}`)
253
+ }
254
+ return sendHtml(res, 401, loginPage(true, { action: `${base}/login`, heading: 'Project access', sub: `Enter the password for this project.` }))
255
+ }
256
+ return sendHtml(res, 200, loginPage(false, { action: `${base}/login`, heading: 'Project access', sub: `Enter the password for this project.` }))
257
+ }
258
+ if (sub === '/logout') return redirect(res, `${base}/login`, clearCookie(projectCookieName(port, id)))
259
+ // browser navigation into the scoped page (`/p/<id>/`, any non-api sub) is content-negotiated
260
+ // exactly like GET /projects, and — like the fallback it rides — PRE-authorization: the shell is
261
+ // code, not data ([[projects-hub]]'s one credential card renders in-app off the scoped api's 401,
262
+ // and a direct guest must reach that card, not a dead-end redirect). With a host fallback mounted,
263
+ // an explicit text/html GET serves the SPA shell (its root-absolute assets resolve outside /p onto
264
+ // the same fallback); every api/SSE/health fetch (json, */*, event-stream) and the WS upgrade keep
265
+ // the auth gate and proxy to the backend untouched.
266
+ if (req.method === 'GET' && !sub.startsWith('/api') && ext.fallback && (req.headers.accept ?? '').includes('text/html')) {
267
+ return ext.fallback(req, res, '/')
268
+ }
269
+ const d = authorize(store, { kind: 'project', projectId: id }, cookies, remote, port)
270
+ if (!d.ok) {
271
+ if (sub.startsWith('/api')) return sendJson(res, 401, { error: 'authentication required', login: `${base}/login` })
272
+ return redirect(res, `${base}/login`)
273
+ }
274
+ return proxyTo(req, res, up.port, sub + query)
275
+ }
276
+
277
+ // outside /projects and /p/: the dashboard shell + assets when a host extension serves them, 404 bare.
278
+ if (ext.fallback) return ext.fallback(req, res, path)
279
+ return sendJson(res, 404, { error: 'not found' })
280
+ }
281
+
282
+ // a handler throw answers 500 and never becomes an unhandled rejection — the hub owns a public port
283
+ // and must keep serving (same posture as the supervisor's process guards).
284
+ const safe = (req: http.IncomingMessage, res: http.ServerResponse) =>
285
+ void handler(req, res).catch((e) => {
286
+ console.error(`[hub] request failed: ${(e as Error).message}`)
287
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' })
288
+ res.end('{"error":"internal error"}')
289
+ })
290
+ const server = secure
291
+ ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key }, safe)
292
+ : http.createServer(safe)
293
+ installConnectionReaper(server)
294
+
295
+ // the terminal WebSocket rides an HTTP upgrade on a /p/:id path: same authorization, then a raw byte
296
+ // pipe to that project's backend with the prefix stripped and the gateway cookies filtered out.
297
+ server.on('upgrade', (req, socket, head) => {
298
+ const store = loadAuthStore()
299
+ const rawUrl = req.url || '/'
300
+ const q = rawUrl.indexOf('?')
301
+ const path = q >= 0 ? rawUrl.slice(0, q) : rawUrl
302
+ const pm = path.match(/^\/p\/([^/]+)(\/.*)?$/)
303
+ if (!pm) return socket.destroy()
304
+ const id = decodeURIComponent(pm[1])
305
+ if (!validProjectId(id)) return socket.destroy()
306
+ const up = upstreamOf(id)
307
+ if (!up) return socket.destroy()
308
+ const d = authorize(store, { kind: 'project', projectId: id }, req.headers.cookie, req.socket.remoteAddress, port)
309
+ if (!d.ok) return socket.destroy()
310
+ const sub = (pm[2] || '/') + (q >= 0 ? rawUrl.slice(q) : '')
311
+ const upstream = net.connect(up.port, '127.0.0.1', () => {
312
+ upstream.write(`${req.method} ${sub} HTTP/1.1\r\n` + filteredRawHeaders(req))
313
+ if (head && head.length) upstream.write(head)
314
+ socket.pipe(upstream); upstream.pipe(socket)
315
+ })
316
+ const bail = () => { socket.destroy(); upstream.destroy() }
317
+ socket.on('error', bail); upstream.on('error', bail)
318
+ // pair the halves fully (the supervisor's rule): an http server's sockets are allowHalfOpen, so a bare
319
+ // client FIN ('end') would otherwise leave a zombie socket the server can never close — an upgraded
320
+ // stream never half-closes legitimately, so either half's FIN or close tears down both.
321
+ socket.on('end', bail); upstream.on('end', bail)
322
+ socket.once('close', () => upstream.destroy())
323
+ upstream.once('close', () => socket.destroy())
324
+ })
325
+
326
+ const onListen = () => {
327
+ const scheme = secure ? 'https' : 'http'
328
+ console.log(`[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${port} — /projects + /p/:projectId/*`)
329
+ }
330
+ listenOrExit(server, port, { host: opts.host, label: opts.label ?? 'hub gateway', cleanup: opts.onBindFail, onListen })
331
+ return server
332
+ }
333
+
334
+ // replay an upgrade's headers with the Cookie header rewritten to exclude the gateway's own cookies.
335
+ function filteredRawHeaders(req: http.IncomingMessage): string {
336
+ let s = ''
337
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
338
+ const name = req.rawHeaders[i]
339
+ if (name.toLowerCase() === 'cookie') {
340
+ const kept = stripGatewayCookies(req.rawHeaders[i + 1])
341
+ if (kept) s += `Cookie: ${kept}\r\n`
342
+ continue
343
+ }
344
+ s += `${name}: ${req.rawHeaders[i + 1]}\r\n`
345
+ }
346
+ return s + '\r\n'
347
+ }
348
+
349
+ // reverse-proxy one authorized request to a project's loopback backend, prefix already stripped.
350
+ function proxyTo(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path: string): void {
351
+ const headers: http.OutgoingHttpHeaders = { ...req.headers }
352
+ const kept = stripGatewayCookies(req.headers.cookie)
353
+ if (kept) headers.cookie = kept; else delete headers.cookie
354
+ const up = http.request({ host: '127.0.0.1', port: upstreamPort, path, method: req.method, headers }, (upRes) => {
355
+ res.writeHead(upRes.statusCode || 502, upRes.headers)
356
+ upRes.pipe(res)
357
+ })
358
+ up.on('error', () => { if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' }); res.end('project backend unreachable') })
359
+ req.pipe(up)
360
+ }
@@ -151,16 +151,14 @@ export function startGateway(opts: GatewayOpts): void {
151
151
  }
152
152
 
153
153
  // server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
154
- // public server in public mode, so it carries the SAME reaping as the child. The `reap` HTTP timeouts are
155
- // kept (harmless) but are NOT the mechanismMEASURED (eval server-reaps-abandoned-connections), Node's
156
- // headersTimeout/requestTimeout don't reap an INCOMPLETE request; the real reaper is the socket-level
157
- // `installConnectionReaper` (reaper.ts), applied below at every http server the serving path creates.
158
- // Idle keep-alive / slow-loris / never-completing request only; the gated WS upgrade (handled below) is an
159
- // active stream and exempt for its lifetime.
160
- const reap = { keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000 }
154
+ // public server in public mode, so it carries the SAME reaping as the child: the socket-level
155
+ // `installConnectionReaper` (reaper.ts), the single owner of the header/idle deadlines it disables
156
+ // Node's own overlapping HTTP timeouts, which were measured to race and shadow it (issue #65), so no
157
+ // timeout options are passed here. Idle keep-alive / slow-loris / never-completing request only; the
158
+ // gated WS upgrade (handled below) is an active stream and exempt for its lifetime.
161
159
  const server = secure
162
- ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key, ...reap }, handler)
163
- : http.createServer(reap, handler)
160
+ ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key }, handler)
161
+ : http.createServer(handler)
164
162
  installConnectionReaper(server)
165
163
 
166
164
  // @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
@@ -175,6 +173,12 @@ export function startGateway(opts: GatewayOpts): void {
175
173
  })
176
174
  const bail = () => { socket.destroy(); up.destroy() }
177
175
  socket.on('error', bail); up.on('error', bail)
176
+ // pair the halves fully (the supervisor's rule): an http server's sockets are allowHalfOpen, so a bare
177
+ // client FIN ('end') would otherwise leave this side open forever — an upgraded stream never
178
+ // half-closes legitimately, so either half's FIN or close tears down both.
179
+ socket.on('end', bail); up.on('end', bail)
180
+ socket.once('close', () => up.destroy())
181
+ up.once('close', () => socket.destroy())
178
182
  })
179
183
 
180
184
  // `spex serve ui` passes an explicit host (loopback by default, --host to widen); `--public` passes
@@ -194,7 +198,9 @@ export function startGateway(opts: GatewayOpts): void {
194
198
  listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen })
195
199
  }
196
200
 
197
- function rawHeaders(req: http.IncomingMessage): string {
201
+ // re-serialize an upgrade request's headers for replay against the upstream (exported for the host
202
+ // gateway's per-project WS pipe, which replays the same way).
203
+ export function rawHeaders(req: http.IncomingMessage): string {
198
204
  let s = ''
199
205
  for (let i = 0; i < req.rawHeaders.length; i += 2) s += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`
200
206
  return s + '\r\n'
@@ -221,8 +227,10 @@ const wantsGzip = (req: http.IncomingMessage) => /\bgzip\b/.test(String(req.head
221
227
 
222
228
  // reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
223
229
  // stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
224
- function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number) {
225
- const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: req.url, method: req.method, headers: req.headers }, (upRes) => {
230
+ // `path` overrides the upstream path (the host gateway strips its /p/:projectId prefix); default = as-is.
231
+ // Exported: the host gateway ([[host-gateway]]) proxies per-project traffic through this same function.
232
+ export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path?: string) {
233
+ const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: path ?? req.url, method: req.method, headers: req.headers }, (upRes) => {
226
234
  const type = String(upRes.headers['content-type'] || '')
227
235
  const skip = !wantsGzip(req) || upRes.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream')
228
236
  if (skip) { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); return }
@@ -238,30 +246,30 @@ function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstream
238
246
  // serve the built dashboard (vite dist). Unknown non-file paths fall back to index.html (SPA). Path
239
247
  // traversal is blocked by normalising and confining to distDir. Compressible files ship gzipped, memoized
240
248
  // per (path, mtime) — a dist file is immutable per build, so each is compressed once, not per request.
249
+ // Exported: the host gateway serves the same dist through the same function.
241
250
  const gzMemo = new Map<string, { mtime: number; gz: Buffer }>()
242
- function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, distDir: string, urlPath: string) {
251
+ export function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, distDir: string, urlPath: string) {
243
252
  const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '')
244
253
  let file = join(distDir, rel)
245
254
  if (!file.startsWith(distDir)) file = join(distDir, 'index.html')
246
255
  if (urlPath === '/' || !existsSync(file)) {
247
- // @@@ missing-asset 404 - only extensionless SPA routes fall back to index.html. A missing FILE
248
- // request (it has an extension) is a stale hashed chunk from a pre-rebuild page still open in some
249
- // browser: answering it with HTML trips the strict module-MIME check and hides the miss from the
250
- // client — 404 instead, so the shell's vite:preloadError recovery can see it and reload.
256
+ // @@@ missing-asset 404 - a missing extensioned path is a stale hashed chunk, not an SPA route: answer
257
+ // 404, not HTML (which trips the module-MIME check), so the shell's reload recovery sees it ([[public-mode]]).
251
258
  if (urlPath !== '/' && extname(file)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found') }
252
259
  file = join(distDir, 'index.html')
253
260
  }
254
261
  if (!existsSync(file)) { res.writeHead(503); return res.end('dashboard build missing') }
255
262
  const type = MIME[extname(file)] || 'application/octet-stream'
263
+ const cacheControl = /[\\/]assets[\\/]/.test(file) ? 'public, max-age=31536000, immutable' : 'no-cache'
256
264
  const raw = readFileSync(file)
257
265
  if (wantsGzip(req) && COMPRESSIBLE.test(type)) {
258
266
  const mtime = statSync(file).mtimeMs
259
267
  let hit = gzMemo.get(file)
260
268
  if (!hit || hit.mtime !== mtime) { hit = { mtime, gz: gzipSync(raw) }; gzMemo.set(file, hit) }
261
- res.writeHead(200, { 'Content-Type': type, 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' })
269
+ res.writeHead(200, { 'Content-Type': type, 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding', 'Cache-Control': cacheControl })
262
270
  return res.end(hit.gz)
263
271
  }
264
- res.writeHead(200, { 'Content-Type': type })
272
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cacheControl })
265
273
  res.end(raw)
266
274
  }
267
275