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.
Files changed (111) 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/claude-headless.ts +271 -0
  8. package/spec-cli/src/cli.ts +112 -38
  9. package/spec-cli/src/client.ts +42 -11
  10. package/spec-cli/src/doctor.ts +174 -36
  11. package/spec-cli/src/gateway-auth.ts +194 -0
  12. package/spec-cli/src/gateway-hub.ts +360 -0
  13. package/spec-cli/src/gateway.ts +27 -19
  14. package/spec-cli/src/git.ts +12 -5
  15. package/spec-cli/src/graph.ts +54 -43
  16. package/spec-cli/src/graphStream.ts +265 -34
  17. package/spec-cli/src/guide.ts +115 -56
  18. package/spec-cli/src/harness-select.ts +20 -6
  19. package/spec-cli/src/harness.ts +295 -39
  20. package/spec-cli/src/help.ts +68 -28
  21. package/spec-cli/src/host.ts +610 -0
  22. package/spec-cli/src/identity-presets.d.ts +13 -0
  23. package/spec-cli/src/identity-presets.js +128 -0
  24. package/spec-cli/src/index.ts +158 -87
  25. package/spec-cli/src/init.ts +53 -25
  26. package/spec-cli/src/layout.ts +6 -5
  27. package/spec-cli/src/lint.ts +25 -121
  28. package/spec-cli/src/login-page.ts +12 -5
  29. package/spec-cli/src/materialize.ts +39 -13
  30. package/spec-cli/src/mentions.ts +16 -11
  31. package/spec-cli/src/message-stream.ts +147 -0
  32. package/spec-cli/src/opencode-headless.ts +95 -0
  33. package/spec-cli/src/opencode.ts +145 -0
  34. package/spec-cli/src/pi-harness.ts +124 -0
  35. package/spec-cli/src/pi-headless.ts +195 -0
  36. package/spec-cli/src/project-identity.ts +73 -0
  37. package/spec-cli/src/pty-bridge.ts +321 -521
  38. package/spec-cli/src/pty-helper.mjs +86 -0
  39. package/spec-cli/src/reaper.ts +16 -8
  40. package/spec-cli/src/reviewSnapshot.ts +23 -0
  41. package/spec-cli/src/reviews.ts +347 -0
  42. package/spec-cli/src/session-timeline.ts +14 -0
  43. package/spec-cli/src/sessions.ts +211 -75
  44. package/spec-cli/src/shim-runtime.ts +169 -0
  45. package/spec-cli/src/slash-commands.ts +76 -0
  46. package/spec-cli/src/source-files.ts +79 -0
  47. package/spec-cli/src/specs.ts +12 -7
  48. package/spec-cli/src/supervise.ts +37 -15
  49. package/spec-cli/src/tree.ts +8 -8
  50. package/spec-cli/src/uninstall.ts +56 -23
  51. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  52. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  53. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  54. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  55. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  56. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  58. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  59. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  60. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  62. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  63. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  64. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  65. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  66. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  67. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  68. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  69. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  70. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  71. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  72. package/spec-cli/templates/spec/project/spec.md +6 -5
  73. package/spec-cli/templates/spexcode.json +10 -2
  74. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  75. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
  76. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  77. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  78. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  79. package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
  80. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  81. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  82. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  83. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
  84. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
  85. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  86. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  87. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  88. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  89. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  90. package/spec-dashboard/dist/index.html +10 -10
  91. package/spec-dashboard/src/reviewFilters.js +279 -0
  92. package/spec-dashboard/src/reviewQuery.js +174 -0
  93. package/spec-dashboard/src/session.js +167 -0
  94. package/spec-eval/src/cli.ts +49 -28
  95. package/spec-eval/src/humanok.ts +1 -1
  96. package/spec-eval/src/matrix.ts +693 -0
  97. package/spec-eval/src/sessioneval.ts +726 -66
  98. package/spec-cli/src/migrate-table.ts +0 -408
  99. package/spec-cli/src/migrate.ts +0 -386
  100. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  108. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  109. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  110. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  111. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -1,19 +1,17 @@
1
- // @@@ spex doctor - the DIAGNOSIS surface ([[doctor]]; command renamed from `self`, which misread as the
2
- // tool itself / the global install). When a user launches their OWN claude/codex with no SpexCode process
3
- // in the launch, the workflow reaches that agent only through the files materialize() writes (the manifest
4
- // in the global store; the in-tree contract blocks + hook shims + codex trust). Bare `spex doctor` answers
5
- // "is this agent actually governed, or silently running free?" — diagnosing that materialized contract per
6
- // LAYER, looping the same HARNESSES adapter materialize delivers through (so claude AND codex are covered
7
- // with no hardcoded paths). It catches the SILENT failure: a shim whose handler is missing, a PATH that
8
- // can't resolve `spex`, a contract that never landed. Read-only today: the bare report, `contract` (print
9
- // the surface:system text any agent reads), `conflicts`. install/uninstall are STAGED (noteStaged).
1
+ // @@@ spex doctor - the opt-in, read-only project DIAGNOSIS surface ([[doctor]]). Bare doctor composes
2
+ // spec-health findings with the delivery audit: when a user launches their OWN agent with no SpexCode
3
+ // process in the launch, the workflow reaches it only through the files materialize() writes. The audit
4
+ // loops the same HARNESSES adapter materialize uses and catches missing delivery or duplicate discovery.
5
+ // `--contract` and `--conflicts` remain focused representations of that same diagnosis.
10
6
  import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
11
7
  import { join, dirname, basename } from 'node:path'
12
8
  import { fileURLToPath } from 'node:url'
13
9
  import { execFileSync } from 'node:child_process'
14
10
  import { homedir } from 'node:os'
15
- import { loadSystemConfig, loadSkillConfig } from './specs.js'
16
- import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout } from './layout.js'
11
+ import { loadSystemConfig, loadSkillConfig, loadSpecs } from './specs.js'
12
+ import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from './layout.js'
13
+ import { loadConfig } from './lint.js'
14
+ import { trackedSourceFiles } from './source-files.js'
17
15
 
18
16
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
19
17
  // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
@@ -31,6 +29,156 @@ function commonHooksDir(dir: string): string | null {
31
29
  }
32
30
  const read = (f: string): string => { try { return readFileSync(f, 'utf8') } catch { return '' } }
33
31
 
32
+ type AltitudeConfig = {
33
+ lineBudget: number
34
+ charBudget: number
35
+ sizeable: number
36
+ dense: number
37
+ steps: number
38
+ identifierExtensions: string[]
39
+ }
40
+
41
+ type BreadthConfig = {
42
+ maxChildren: number
43
+ }
44
+
45
+ export type HealthFinding = {
46
+ check: string
47
+ spec: string
48
+ summary: string
49
+ evidence: string[]
50
+ repair: string
51
+ }
52
+
53
+ const DEFAULT_ALTITUDE: AltitudeConfig = {
54
+ lineBudget: 50,
55
+ charBudget: 4200,
56
+ sizeable: 35,
57
+ dense: 1.3,
58
+ steps: 3,
59
+ identifierExtensions: [],
60
+ }
61
+
62
+ const DEFAULT_BREADTH: BreadthConfig = {
63
+ maxChildren: 8,
64
+ }
65
+
66
+ function loadAltitudeConfig(root: string): AltitudeConfig {
67
+ const configured = readJsonConfig(join(root, 'spexcode.json'))?.doctor?.altitude ?? {}
68
+ const merged = { ...DEFAULT_ALTITUDE, ...configured }
69
+ return {
70
+ ...merged,
71
+ identifierExtensions: (merged.identifierExtensions ?? []).map((ext: string) => ext.replace(/^\.+/, '')),
72
+ }
73
+ }
74
+
75
+ function loadBreadthConfig(root: string): BreadthConfig {
76
+ const configured = readJsonConfig(join(root, 'spexcode.json'))?.doctor?.breadth ?? {}
77
+ return { ...DEFAULT_BREADTH, ...configured }
78
+ }
79
+
80
+ // Filename rows are lint coverage's exact tracked candidates. Compatibility extensions lower to wildcard
81
+ // rows before the one identifier matcher is compiled.
82
+ function identifierFilenameCandidates(sourceFiles: string[], compatibilityExtensions: string[]): string[] {
83
+ return [...new Set([
84
+ ...sourceFiles.map((path) => basename(path)),
85
+ ...compatibilityExtensions.map((ext) => `*.${ext}`),
86
+ ])]
87
+ }
88
+
89
+ function identRe(filenameCandidates: string[]): RegExp {
90
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
91
+ const filenames = filenameCandidates
92
+ .map((candidate) => candidate.startsWith('*.')
93
+ ? `[\\w-]+\\.${escape(candidate.slice(2))}`
94
+ : escape(candidate))
95
+ .sort((a, b) => b.length - a.length)
96
+ const signals = [
97
+ '[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*',
98
+ '\\b[a-z]+_[a-z0-9_]+\\b',
99
+ '\\b\\w+\\(',
100
+ '`[^`]+`',
101
+ '\\/[\\w./-]+\\.\\w+',
102
+ ]
103
+ if (filenames.length) signals.push(`(?<![\\w./-])(?:${filenames.join('|')})(?![\\w.-])`)
104
+ return new RegExp(signals.join('|'), 'g')
105
+ }
106
+
107
+ const STEP_LINE = /^\s*(\d+[.)]\s|[-*]\s*(first|then|next|finally)\b)|(^|[,;]\s*)(first|then|next|finally),/i
108
+
109
+ function altitudeEvidence(body: string, cfg: AltitudeConfig, ident: RegExp): string[] {
110
+ const lines = body.split('\n')
111
+ const nonBlank = lines.filter((line) => line.trim()).length
112
+ let inFence = false
113
+ let signals = 0
114
+ let steps = 0
115
+ for (const line of lines) {
116
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue }
117
+ if (inFence || !line.trim()) continue
118
+ signals += line.match(ident)?.length ?? 0
119
+ if (STEP_LINE.test(line)) steps++
120
+ }
121
+ const density = signals / Math.max(1, nonBlank)
122
+ const evidence: string[] = []
123
+ if (nonBlank > cfg.lineBudget || body.length > cfg.charBudget)
124
+ evidence.push(`${nonBlank} non-blank lines / ${body.length} chars over budget (${cfg.lineBudget}/${cfg.charBudget})`)
125
+ if (nonBlank > cfg.sizeable && density > cfg.dense)
126
+ evidence.push(`code-identifier density ${density.toFixed(2)}/line over ${cfg.dense}`)
127
+ if (nonBlank > cfg.sizeable && steps >= cfg.steps)
128
+ evidence.push(`${steps} step-by-step how-to lines`)
129
+ return evidence
130
+ }
131
+
132
+ export async function specHealthDiagnosis(root: string): Promise<HealthFinding[]> {
133
+ const lint = loadConfig(root)
134
+ const altitude = loadAltitudeConfig(root)
135
+ const breadth = loadBreadthConfig(root)
136
+ const governed = trackedSourceFiles(root, lint.governedRoots, lint)
137
+ const ident = identRe(identifierFilenameCandidates(governed, altitude.identifierExtensions))
138
+ const specs = await loadSpecs(root)
139
+ const childCount = new Map<string, number>()
140
+ for (const spec of specs) if (spec.parent) childCount.set(spec.parent, (childCount.get(spec.parent) ?? 0) + 1)
141
+ const findings: HealthFinding[] = []
142
+ for (const spec of specs) {
143
+ const evidence = altitudeEvidence(spec.body, altitude, ident)
144
+ if (evidence.length) findings.push({
145
+ check: 'altitude',
146
+ spec: spec.id,
147
+ summary: 'body reads like mechanics rather than a contract',
148
+ evidence,
149
+ repair: `rewrite '${spec.id}' around observable intent and invariants; the tidy workflow can perform the semantic review`,
150
+ })
151
+ const children = childCount.get(spec.id) ?? 0
152
+ if (children >= breadth.maxChildren) findings.push({
153
+ check: 'breadth',
154
+ spec: spec.id,
155
+ summary: 'tree fan-out may be missing a natural grouping layer',
156
+ evidence: [`${children} direct child nodes (>= ${breadth.maxChildren})`],
157
+ repair: `review '${spec.id}' with the regroup workflow; group only along a real seam and leave genuine peers flat`,
158
+ })
159
+ }
160
+ return findings
161
+ }
162
+
163
+ function healthReport(findings: HealthFinding[], adopted: boolean): string[] {
164
+ const lines = ['Spec health diagnosis (opt-in advisory; never part of spex spec lint)']
165
+ if (!adopted) {
166
+ lines.push(' status : unavailable — adopt the repository with `spex init`')
167
+ return lines
168
+ }
169
+ const checks = ['altitude', 'breadth', ...new Set(findings.map((finding) => finding.check).filter((check) => check !== 'altitude' && check !== 'breadth'))]
170
+ for (const check of checks) {
171
+ const rows = findings.filter((finding) => finding.check === check)
172
+ lines.push(` ${check.padEnd(16)}: ${rows.length ? `${rows.length} finding(s)` : 'healthy'}`)
173
+ for (const finding of rows) {
174
+ lines.push(` ${finding.spec.padEnd(16)}: ${finding.summary}`)
175
+ for (const evidence of finding.evidence) lines.push(` evidence : ${evidence}`)
176
+ lines.push(` repair : ${finding.repair}`)
177
+ }
178
+ }
179
+ return lines
180
+ }
181
+
34
182
  // @@@ contractText - the layer-2 payload: the composed `surface:system` bodies, the SAME join materialize()
35
183
  // folds into each harness's contract file — so a BYOA agent reads byte-identical guidance.
36
184
  function contractText(): { names: string[]; body: string } {
@@ -170,9 +318,9 @@ async function doubleDeliveryReport(base: string): Promise<{ lines: string[]; co
170
318
  if (conflict) {
171
319
  L.push('Repair — SpexCode is reaching this agent through MORE THAN ONE discovery channel. Keep exactly one:')
172
320
  L.push(' • remove the independently-installed plugin bundle (delete its dir, or `claude plugin uninstall spexcode`); or')
173
- L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json "harnesses" to a plugin target')
174
- L.push(' (e.g. ["plugin",{"plugin":".claude"}] {"plugin":".claude"}) so `spex materialize` prunes the loose shim/contract/skills; or')
175
- L.push(' • remove the loose copy directly (`spex doctor uninstall` [staged] / `spex uninstall`).')
321
+ L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json to')
322
+ L.push(' `"harnesses": [{"plugin":".claude"}]`, then run `spex materialize` to prune the loose shim/contract/skills; or')
323
+ L.push(' • remove SpexCode\'s generated delivery with `spex uninstall`.')
176
324
  } else {
177
325
  L.push('No double-delivery: each harness is reached by at most one spexcode-stamped channel.')
178
326
  }
@@ -233,8 +381,14 @@ async function doctor(): Promise<number> {
233
381
  })
234
382
  line('issues workflow', `${issuesEnabled() ? 'ON' : 'OFF'} (spexcode.json issues.enabled)`)
235
383
  if (legacy.length) line(' LEGACY key', `\`proposals\` found in ${legacy.map((f) => join(cfgHome, f)).join(', ')} — no longer read; rename it to "issues": { "enabled": … }`)
384
+ const projectConfig = readJsonConfig(join(base, 'spexcode.json'))
385
+ if (Object.prototype.hasOwnProperty.call(projectConfig.lint ?? {}, 'maxChildren'))
386
+ line('RETIRED key', '`lint.maxChildren` is no longer read — move the value to `doctor.breadth.maxChildren`')
236
387
  }
237
388
 
389
+ const health = adopted ? await specHealthDiagnosis(base) : []
390
+ L.push('\n' + healthReport(health, adopted).join('\n'))
391
+
238
392
  // --- preconditions: nothing downstream fires without these ---
239
393
  L.push('\nPreconditions (without these nothing downstream fires)')
240
394
  for (const bin of ['spex', 'claude', 'codex']) {
@@ -347,37 +501,23 @@ async function conflicts(): Promise<number> {
347
501
  return conflict ? 1 : 0
348
502
  }
349
503
 
350
-
351
- // install/uninstall are STAGED: wiring layer-3 hooks into a standalone repo is only SAFE once the hooks
352
- // detect a missing managed session and degrade. So the diagnosis ships first; the installer lands behind it.
353
- function noteStaged(verb: string): number {
354
- console.error(`spex doctor ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
355
- (the live hooks must detect a missing managed session and degrade before they can be safely wired into your
356
- own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor --contract\` prints
357
- the workflow text you can hand any agent.`)
504
+ function migrationRemoved(): number {
505
+ console.error('spex: `spex doctor --migrate` was removed in v0.4.0 — migrate this tree with a 0.3.x SpexCode release, then reinstall the current release. Nothing was changed.')
358
506
  return 2
359
507
  }
360
508
 
361
509
  function usage(): number {
362
- console.error(`spex doctor — diagnose how the SpexCode workflow reaches your agent
363
- (bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
510
+ console.error(`spex doctor — diagnose spec health and how the SpexCode workflow reaches your agent
511
+ (bare) spec-health findings + delivery report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
364
512
  --contract print the surface:system contract text (hand it to any agent)
365
- --conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
366
- --migrate one-shot 0.2.x → 0.3.0 vocabulary migration for an adopter repo (renames + hook-asset
367
- upgrade + body rewrites; refuses on a dirty tree / undrained sessions / an already-migrated
368
- tree; stages everything, commits nothing; removed in 0.4.0)
369
- install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
370
- uninstall [staged] reverse exactly what install wrote`)
513
+ --conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)`)
371
514
  return 0
372
515
  }
373
516
 
374
517
  export async function runDoctor(args: string[]): Promise<number> {
375
518
  // contract/conflicts are FLAGS, not subcommands ([[cli-surface]] §4: another representation of the same
376
519
  // diagnosis read, not a distinct action). The old positional spellings signpost — report, never run.
377
- // --migrate is the one MUTATING flag: the term-limited 0.2.x→0.3.0 adopter migrator ([[migrate]],
378
- // ships with 0.3.0, deleted in 0.4.0) — dispatched first so its refusal/summary is never diluted
379
- // by the diagnosis report.
380
- if (args.includes('--migrate')) return await (await import('./migrate.js')).runMigrate()
520
+ if (args.includes('--migrate')) return migrationRemoved()
381
521
  if (args.includes('--contract')) return contract()
382
522
  if (args.includes('--conflicts')) return await conflicts()
383
523
  switch (args[0]) {
@@ -385,8 +525,6 @@ export async function runDoctor(args: string[]): Promise<number> {
385
525
  case 'contract': case 'conflicts':
386
526
  console.error(`spex: \`spex doctor ${args[0]}\` was removed in v0.3.0 — use: spex doctor --${args[0]}`)
387
527
  return 2
388
- case 'install': return noteStaged('install')
389
- case 'uninstall': return noteStaged('uninstall')
390
528
  case 'help': case '--help': case '-h': return usage()
391
529
  default: console.error(`spex doctor: unknown subcommand "${args[0]}"`); usage(); return 2
392
530
  }
@@ -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
+ }