spexcode 0.4.1 → 0.5.0
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/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/claude-headless.ts +271 -0
- package/spec-cli/src/cli.ts +112 -38
- package/spec-cli/src/client.ts +42 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +115 -56
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +295 -39
- package/spec-cli/src/help.ts +68 -28
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +158 -87
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +6 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/pi-headless.ts +195 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +211 -75
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +10 -2
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
|
@@ -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
|
+
}
|
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -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
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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
|
|
163
|
-
: http.createServer(
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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 -
|
|
248
|
-
//
|
|
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
|
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -443,7 +443,7 @@ function parseNameStatus(out: string): { code: string; from: string; to: string
|
|
|
443
443
|
return rows
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
-
export type ReviewDiffFile = { path: string; status: string; additions: number; deletions: number }
|
|
446
|
+
export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
|
|
447
447
|
const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
|
|
448
448
|
export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
|
|
449
449
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
@@ -453,14 +453,21 @@ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<R
|
|
|
453
453
|
run(['diff', '--numstat', '-M', `${base}..HEAD`]),
|
|
454
454
|
run(['diff', '--name-status', '-M', `${base}..HEAD`]),
|
|
455
455
|
])
|
|
456
|
-
const status = new Map<string, string>()
|
|
457
|
-
for (const r of parseNameStatus(statusOut)) status.set(r.to, DIFF_STATUS[r.code] ?? r.code)
|
|
456
|
+
const status = new Map<string, { status: string; from: string }>()
|
|
457
|
+
for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
|
|
458
458
|
const files: ReviewDiffFile[] = []
|
|
459
459
|
for (const line of numstatOut.split('\n')) {
|
|
460
460
|
const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
|
|
461
461
|
if (!m) continue
|
|
462
|
-
const { to } = parseStatPath(m[3])
|
|
463
|
-
|
|
462
|
+
const { from, to } = parseStatPath(m[3])
|
|
463
|
+
const detail = status.get(to)
|
|
464
|
+
files.push({
|
|
465
|
+
path: to,
|
|
466
|
+
...(from !== to ? { oldPath: detail?.from ?? from } : {}),
|
|
467
|
+
status: detail?.status ?? 'modified',
|
|
468
|
+
additions: m[1] === '-' ? 0 : +m[1],
|
|
469
|
+
deletions: m[2] === '-' ? 0 : +m[2],
|
|
470
|
+
})
|
|
464
471
|
}
|
|
465
472
|
return files
|
|
466
473
|
}
|