@legionworks/facet 1.9.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/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +23 -0
- package/README.md +158 -0
- package/dist/gallery/chunk-4g8rem85.css +656 -0
- package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
- package/dist/gallery/frame/artifact.css +8424 -0
- package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
- package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
- package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
- package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
- package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
- package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
- package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
- package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
- package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
- package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
- package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
- package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
- package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
- package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
- package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
- package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
- package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
- package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
- package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
- package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
- package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
- package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
- package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
- package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
- package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
- package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
- package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
- package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
- package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
- package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
- package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
- package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
- package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
- package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
- package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
- package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
- package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
- package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
- package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
- package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
- package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
- package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
- package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
- package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
- package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
- package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
- package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
- package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
- package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
- package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
- package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
- package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
- package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
- package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
- package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
- package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
- package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
- package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
- package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
- package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
- package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
- package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
- package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
- package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
- package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
- package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
- package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
- package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
- package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
- package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
- package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
- package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
- package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
- package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
- package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
- package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
- package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
- package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
- package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
- package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
- package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
- package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
- package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
- package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
- package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
- package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
- package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
- package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
- package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
- package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
- package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
- package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
- package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
- package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
- package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
- package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
- package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
- package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
- package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
- package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
- package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
- package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
- package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
- package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
- package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
- package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
- package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
- package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
- package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
- package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
- package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
- package/dist/gallery/frame/frame.css +183 -0
- package/dist/gallery/frame/runtime/chart.js +42744 -0
- package/dist/gallery/frame/runtime/html.js +14 -0
- package/dist/gallery/frame/runtime/markdown.js +1403 -0
- package/dist/gallery/frame/runtime/mermaid.js +32 -0
- package/dist/gallery/frame/runtime/svg.js +14 -0
- package/dist/gallery/frame/runtime/tsx.js +52 -0
- package/dist/gallery/index.html +104 -0
- package/docs/reference/cli.md +163 -0
- package/docs/reference/export.md +106 -0
- package/docs/reference/html.md +215 -0
- package/docs/reference/http.md +85 -0
- package/docs/reference/mcp.md +78 -0
- package/docs/reference/security.md +119 -0
- package/docs/reference/storage.md +93 -0
- package/docs/reference/tsx.md +101 -0
- package/docs/reference/validation.md +212 -0
- package/package.json +89 -0
- package/scripts/build-gallery.ts +127 -0
- package/scripts/launch-netns.sh +67 -0
- package/skills/facet/SKILL.md +100 -0
- package/src/cli/client.ts +311 -0
- package/src/cli/commands/create.ts +49 -0
- package/src/cli/commands/doctor.ts +249 -0
- package/src/cli/commands/export.ts +179 -0
- package/src/cli/commands/instantiate.ts +35 -0
- package/src/cli/commands/list.ts +49 -0
- package/src/cli/commands/open.ts +75 -0
- package/src/cli/commands/pin.ts +39 -0
- package/src/cli/commands/promote.ts +49 -0
- package/src/cli/commands/publish.ts +122 -0
- package/src/cli/commands/read-back.ts +56 -0
- package/src/cli/commands/status.ts +114 -0
- package/src/cli/commands/watch.ts +146 -0
- package/src/cli/main.ts +522 -0
- package/src/cli/output.ts +106 -0
- package/src/cli/parser.ts +376 -0
- package/src/cli/presenter.ts +212 -0
- package/src/cli/service-metadata.ts +134 -0
- package/src/cli/spawn-service.ts +326 -0
- package/src/css.d.ts +4 -0
- package/src/gallery-web/app.ts +1581 -0
- package/src/gallery-web/export-menu.ts +121 -0
- package/src/gallery-web/export.ts +112 -0
- package/src/gallery-web/favicon.ts +54 -0
- package/src/gallery-web/frame/entries/chart.ts +6 -0
- package/src/gallery-web/frame/entries/html.ts +6 -0
- package/src/gallery-web/frame/entries/markdown.ts +6 -0
- package/src/gallery-web/frame/entries/mermaid.ts +6 -0
- package/src/gallery-web/frame/entries/svg.ts +6 -0
- package/src/gallery-web/frame/entries/tsx.ts +6 -0
- package/src/gallery-web/frame/frame-payload.ts +91 -0
- package/src/gallery-web/frame/renderer-validation.ts +13 -0
- package/src/gallery-web/frame/renderers/chart.ts +182 -0
- package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
- package/src/gallery-web/frame/renderers/html.ts +48 -0
- package/src/gallery-web/frame/renderers/markdown.ts +81 -0
- package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
- package/src/gallery-web/frame/renderers/registry.ts +260 -0
- package/src/gallery-web/frame/renderers/svg.ts +393 -0
- package/src/gallery-web/frame/renderers/tsx.ts +61 -0
- package/src/gallery-web/frame/runtime.ts +501 -0
- package/src/gallery-web/frame/styles/artifact.css +8424 -0
- package/src/gallery-web/frame/styles/frame.css +183 -0
- package/src/gallery-web/frame/styles/html-source.css +23 -0
- package/src/gallery-web/frame/view-box.ts +33 -0
- package/src/gallery-web/frame-html.ts +72 -0
- package/src/gallery-web/index.html +104 -0
- package/src/gallery-web/session.ts +107 -0
- package/src/gallery-web/sse-client.ts +212 -0
- package/src/gallery-web/styles/app.css +420 -0
- package/src/gallery-web/styles/tokens.css +112 -0
- package/src/gallery-web/styles/verdict.css +186 -0
- package/src/gallery-web/swap.ts +64 -0
- package/src/gallery-web/theme.ts +40 -0
- package/src/gallery-web/view-state.ts +93 -0
- package/src/harness-adapters/claude-code/README.md +10 -0
- package/src/harness-adapters/claude-code/facet.sh +4 -0
- package/src/harness-adapters/codex/README.md +10 -0
- package/src/harness-adapters/codex/facet.sh +4 -0
- package/src/harness-adapters/mcp/README.md +13 -0
- package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
- package/src/harness-adapters/mcp/main.ts +15 -0
- package/src/harness-adapters/mcp/server.ts +164 -0
- package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
- package/src/harness-adapters/opencode/README.md +10 -0
- package/src/harness-adapters/opencode/facet.sh +4 -0
- package/src/runtime/compiled-entrypoints.ts +69 -0
- package/src/service/dispatcher.ts +717 -0
- package/src/service/export.ts +218 -0
- package/src/service/http-utils.ts +55 -0
- package/src/service/lexical/expectations.ts +210 -0
- package/src/service/lifecycle/idle-controller.ts +125 -0
- package/src/service/lifecycle/orphan-cleanup.ts +128 -0
- package/src/service/lifecycle/process-lock.ts +235 -0
- package/src/service/main.ts +136 -0
- package/src/service/process.ts +240 -0
- package/src/service/router-guards.ts +239 -0
- package/src/service/router.ts +627 -0
- package/src/service/security/auth.ts +168 -0
- package/src/service/security/host-origin.ts +138 -0
- package/src/service/security/http-guards.ts +13 -0
- package/src/service/security/leases.ts +182 -0
- package/src/service/security/token-store.ts +138 -0
- package/src/service/server.ts +356 -0
- package/src/service/store/database.ts +61 -0
- package/src/service/store/evidence-retention.ts +234 -0
- package/src/service/store/migrations.ts +167 -0
- package/src/service/store/repository-lifecycle.ts +121 -0
- package/src/service/store/repository.ts +732 -0
- package/src/service/store/schema.ts +218 -0
- package/src/service/stored-verdict.ts +97 -0
- package/src/service/stream.ts +301 -0
- package/src/service/verdict-enrichment.ts +45 -0
- package/src/shared/build/frame-bundle-plugins.ts +61 -0
- package/src/shared/build-mode.ts +28 -0
- package/src/shared/config/evidence-read.ts +98 -0
- package/src/shared/config/limits.ts +56 -0
- package/src/shared/config/paths.ts +96 -0
- package/src/shared/contracts/artifact-types.ts +2 -0
- package/src/shared/contracts/artifact.ts +98 -0
- package/src/shared/contracts/commands/_shared.ts +59 -0
- package/src/shared/contracts/commands/guards.ts +86 -0
- package/src/shared/contracts/commands/index.ts +204 -0
- package/src/shared/contracts/commands/names.ts +35 -0
- package/src/shared/contracts/commands/requests.ts +149 -0
- package/src/shared/contracts/commands/results.ts +200 -0
- package/src/shared/contracts/envelope.ts +140 -0
- package/src/shared/contracts/events.ts +41 -0
- package/src/shared/contracts/observed-counts.ts +19 -0
- package/src/shared/contracts/renderers.ts +6 -0
- package/src/shared/contracts/validation.ts +296 -0
- package/src/shared/errors/facet-error.ts +127 -0
- package/src/shared/errors/store-error.ts +68 -0
- package/src/shared/evidence-image.ts +37 -0
- package/src/shared/export.ts +31 -0
- package/src/shared/html/policy.ts +108 -0
- package/src/shared/html/style-vocabulary.ts +352 -0
- package/src/shared/logging/logger.ts +108 -0
- package/src/shared/logging/redact.ts +49 -0
- package/src/shared/security/frozen-csp.ts +13 -0
- package/src/shared/storage-version.ts +2 -0
- package/src/shared/tsx/execution.ts +29 -0
- package/src/shared/tsx/import-policy.ts +94 -0
- package/src/shared/util/dir-permissions.ts +94 -0
- package/src/shared/util/mermaid-nodes.ts +230 -0
- package/src/shared/util/process.ts +67 -0
- package/src/shared/util/time.ts +21 -0
- package/src/shared/version.ts +3 -0
- package/src/validation/sandbox/limits.ts +41 -0
- package/src/validation/sandbox/netns.ts +177 -0
- package/src/validation/tier0/chart.ts +177 -0
- package/src/validation/tier0/dom-shim.ts +54 -0
- package/src/validation/tier0/html.ts +408 -0
- package/src/validation/tier0/markdown.ts +285 -0
- package/src/validation/tier0/mermaid.ts +98 -0
- package/src/validation/tier0/runner.ts +504 -0
- package/src/validation/tier0/svg.ts +321 -0
- package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
- package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
- package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
- package/src/validation/tier0/tsx/ast-policy.ts +181 -0
- package/src/validation/tier0/tsx/compiler.ts +156 -0
- package/src/validation/tier0/worker-dispatch.ts +211 -0
- package/src/validation/tier0/worker-entry.ts +96 -0
- package/src/validation/tier0/worker-input.ts +75 -0
- package/src/validation/tier1/browser-process.ts +147 -0
- package/src/validation/tier1/cdp-pipe.ts +281 -0
- package/src/validation/tier1/entries/chart.ts +5 -0
- package/src/validation/tier1/entries/html.ts +6 -0
- package/src/validation/tier1/entries/markdown.ts +5 -0
- package/src/validation/tier1/entries/mermaid.ts +5 -0
- package/src/validation/tier1/entries/svg.ts +5 -0
- package/src/validation/tier1/entries/tsx.ts +5 -0
- package/src/validation/tier1/frame-target.ts +162 -0
- package/src/validation/tier1/harness-entry.ts +261 -0
- package/src/validation/tier1/harness.ts +215 -0
- package/src/validation/tier1/isolated-probe.ts +94 -0
- package/src/validation/tier1/launcher.ts +177 -0
- package/src/validation/tier1/limits.ts +80 -0
- package/src/validation/tier1/nonce.ts +17 -0
- package/src/validation/tier1/protocol-probe.ts +519 -0
- package/src/validation/tier1/runner.ts +1122 -0
- package/src/validation/tier1/verdict.ts +246 -0
- package/src/validation/tier1/webp.ts +69 -0
- package/templates/README.md +88 -0
- package/templates/bar-compare.vl.json +63 -0
- package/templates/capacity-report.tsx +102 -0
- package/templates/decision-record.md +24 -0
- package/templates/deployment-state.mmd +40 -0
- package/templates/exemplar.md +91 -0
- package/templates/facet-story.tsx +799 -0
- package/templates/fleet-dashboard.html +141 -0
- package/templates/html-release-ledger.html +91 -0
- package/templates/html-status-report.html +114 -0
- package/templates/incident-console.tsx +147 -0
- package/templates/legion-boundaries.mmd +23 -0
- package/templates/legion-flow.mmd +15 -0
- package/templates/legion-sequence.mmd +14 -0
- package/templates/legion-state.mmd +7 -0
- package/templates/metric-card.svg +29 -0
- package/templates/observability-map.svg +101 -0
- package/templates/pipeline-audit.md +108 -0
- package/templates/release-metrics.vl.json +98 -0
- package/templates/service-topology.mmd +47 -0
- package/templates/status-report.md +32 -0
- package/templates/system-map.svg +53 -0
- package/templates/timeseries.vl.json +71 -0
- package/templates/tsx-interactive-counter.tsx +19 -0
- package/templates/tsx-status-report.tsx +31 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { TSX_EXECUTION_MODES } from "../../shared/tsx/execution";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The canonical DDL applied to a fresh database. Every column the
|
|
5
|
+
* schema holds today lives here; the v1 migration runs this block
|
|
6
|
+
* verbatim. Subsequent migrations extend this table set via additive
|
|
7
|
+
* ALTER TABLE statements (never a destructive rewrite — the schema
|
|
8
|
+
* migrations ledger records each version).
|
|
9
|
+
*
|
|
10
|
+
* Fresh v8 databases apply this block first (v1), then the v2-v8
|
|
11
|
+
* fragments in order. The v8 fragment widens the CHECK to include
|
|
12
|
+
* `tsx`, adds `revisions.execution`, and adds `render_runs.compiled_path`.
|
|
13
|
+
* Migrations are tracked, so a re-run on an already-migrated DB skips
|
|
14
|
+
* already-applied versions.
|
|
15
|
+
*/
|
|
16
|
+
export const INITIAL_SCHEMA = `
|
|
17
|
+
CREATE TABLE projects(
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
project_root TEXT NOT NULL UNIQUE,
|
|
20
|
+
created_at TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
CREATE TABLE artifacts(
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
25
|
+
slug TEXT NOT NULL,
|
|
26
|
+
title TEXT NOT NULL,
|
|
27
|
+
created_at TEXT NOT NULL,
|
|
28
|
+
updated_at TEXT NOT NULL,
|
|
29
|
+
UNIQUE(project_id, slug)
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE revisions(
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
34
|
+
revision_number INTEGER NOT NULL,
|
|
35
|
+
parent_revision_id TEXT REFERENCES revisions(id),
|
|
36
|
+
artifact_type TEXT NOT NULL CHECK(artifact_type IN ('markdown','mermaid','svg','chart','html')),
|
|
37
|
+
source BLOB NOT NULL,
|
|
38
|
+
sha256 TEXT NOT NULL,
|
|
39
|
+
note TEXT,
|
|
40
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
UNIQUE(artifact_id, revision_number),
|
|
43
|
+
UNIQUE(artifact_id, sha256)
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE render_runs(
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
revision_id TEXT NOT NULL REFERENCES revisions(id),
|
|
48
|
+
tier INTEGER NOT NULL CHECK(tier IN (0,1)),
|
|
49
|
+
status TEXT NOT NULL,
|
|
50
|
+
expected_json TEXT NOT NULL,
|
|
51
|
+
observed_json TEXT NOT NULL,
|
|
52
|
+
screenshot_path TEXT,
|
|
53
|
+
console_path TEXT,
|
|
54
|
+
started_at TEXT NOT NULL,
|
|
55
|
+
finished_at TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
CREATE TABLE templates(
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
60
|
+
revision_id TEXT NOT NULL REFERENCES revisions(id),
|
|
61
|
+
name TEXT NOT NULL UNIQUE,
|
|
62
|
+
description TEXT,
|
|
63
|
+
promoted_by TEXT NOT NULL,
|
|
64
|
+
promoted_at TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The v2 DDL fragment — appended after `INITIAL_SCHEMA` for fresh
|
|
70
|
+
* databases that land on v2 directly. Existing v1 databases pick up
|
|
71
|
+
* the v2 column via the migration step in `migrations.ts`. Keeping
|
|
72
|
+
* the fragment here (and not duplicated in the migration) means the
|
|
73
|
+
* canonical DDL and the migration body cannot drift apart.
|
|
74
|
+
*/
|
|
75
|
+
export const V2_SCHEMA_FRAGMENT = `
|
|
76
|
+
ALTER TABLE render_runs ADD COLUMN retained INTEGER NOT NULL DEFAULT 0;
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
/** The v3 DDL fragment adds renderer persistence; its literal CHECK mirrors RENDERERS deliberately and is parity-tested. */
|
|
80
|
+
export const V3_SCHEMA_FRAGMENT = `
|
|
81
|
+
ALTER TABLE revisions ADD COLUMN renderer TEXT NOT NULL DEFAULT 'svg' CHECK(renderer IN ('svg','canvas'));
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
/** The v4 fragment preserves typed screenshot-capture failures across read-back. */
|
|
85
|
+
export const V4_SCHEMA_FRAGMENT = `
|
|
86
|
+
ALTER TABLE render_runs ADD COLUMN screenshot_error_json TEXT;
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
/** The v5 fragment persists the insecure execution marker with each run. */
|
|
90
|
+
export const V5_SCHEMA_FRAGMENT = `
|
|
91
|
+
ALTER TABLE render_runs ADD COLUMN insecure_json TEXT;
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The v6 rebuild widens revisions.artifact_type. SQLite cannot alter a CHECK
|
|
96
|
+
* in place, so the official create-copy-drop-rename procedure preserves child
|
|
97
|
+
* foreign-key clauses by never renaming the original table.
|
|
98
|
+
*/
|
|
99
|
+
export const V6_SCHEMA_FRAGMENT = `
|
|
100
|
+
CREATE TABLE revisions_v6(
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
103
|
+
revision_number INTEGER NOT NULL,
|
|
104
|
+
parent_revision_id TEXT REFERENCES revisions(id),
|
|
105
|
+
artifact_type TEXT NOT NULL CHECK(artifact_type IN ('markdown','mermaid','svg','chart','html')),
|
|
106
|
+
source BLOB NOT NULL,
|
|
107
|
+
sha256 TEXT NOT NULL,
|
|
108
|
+
note TEXT,
|
|
109
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
created_at TEXT NOT NULL,
|
|
111
|
+
renderer TEXT NOT NULL DEFAULT 'svg' CHECK(renderer IN ('svg','canvas')),
|
|
112
|
+
UNIQUE(artifact_id, revision_number),
|
|
113
|
+
UNIQUE(artifact_id, sha256)
|
|
114
|
+
);
|
|
115
|
+
INSERT INTO revisions_v6(
|
|
116
|
+
id, artifact_id, revision_number, parent_revision_id, artifact_type, source, sha256,
|
|
117
|
+
note, pinned, created_at, renderer
|
|
118
|
+
)
|
|
119
|
+
SELECT
|
|
120
|
+
id, artifact_id, revision_number, parent_revision_id, artifact_type, source, sha256,
|
|
121
|
+
note, pinned, created_at, renderer
|
|
122
|
+
FROM revisions;
|
|
123
|
+
DROP TABLE revisions;
|
|
124
|
+
ALTER TABLE revisions_v6 RENAME TO revisions;
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The v7 backfill repairs render_runs.observed_json rows written by
|
|
129
|
+
* prior releases that did NOT include the `opaqueRegionCount` and
|
|
130
|
+
* `externalImageCount` counters. A strict `VerdictObservedSchema.parse`
|
|
131
|
+
* would 400 read-back / export / gallery-source on every pre-arc
|
|
132
|
+
* artifact; the schema-level repair writes a 0 for every missing
|
|
133
|
+
* counter so future reads don't have to.
|
|
134
|
+
*
|
|
135
|
+
* The migration is idempotent: `json_extract(observed_json, '$.x')` is
|
|
136
|
+
* null when the field is absent, and `COALESCE` replaces that null with
|
|
137
|
+
* 0. Re-running the migration against an already-migrated row is a
|
|
138
|
+
* no-op because `COALESCE(null, 0)` and `COALESCE(0, 0)` both yield 0
|
|
139
|
+
* and the rebuilt JSON is identical to the existing one.
|
|
140
|
+
*
|
|
141
|
+
* This is the schema-side companion to the runtime tolerant read in
|
|
142
|
+
* `src/service/stored-verdict.ts` `withTolerantObserved`. The tolerant
|
|
143
|
+
* read defends the boundary against a row that was written before
|
|
144
|
+
* this migration ran; the migration moves the on-disk JSON to the
|
|
145
|
+
* current shape so future readers can be strict again if they want.
|
|
146
|
+
*/
|
|
147
|
+
export const V7_SCHEMA_FRAGMENT = `
|
|
148
|
+
UPDATE render_runs
|
|
149
|
+
SET observed_json = json_set(
|
|
150
|
+
json_set(
|
|
151
|
+
observed_json,
|
|
152
|
+
'$.opaqueRegionCount',
|
|
153
|
+
COALESCE(json_extract(observed_json, '$.opaqueRegionCount'), 0)
|
|
154
|
+
),
|
|
155
|
+
'$.externalImageCount',
|
|
156
|
+
COALESCE(json_extract(observed_json, '$.externalImageCount'), 0)
|
|
157
|
+
)
|
|
158
|
+
WHERE
|
|
159
|
+
json_type(observed_json, '$.opaqueRegionCount') IS NULL
|
|
160
|
+
OR json_type(observed_json, '$.externalImageCount') IS NULL
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The v8 rebuild widens `revisions.artifact_type` to include `tsx`,
|
|
165
|
+
* adds `revisions.execution` (D2 of the TSX design — declared mode,
|
|
166
|
+
* default `static`, backfilled for every prior row), and adds
|
|
167
|
+
* `render_runs.compiled_path` (D7 — derived bundle stored alongside
|
|
168
|
+
* the run, NULLABLE so non-TSX runs and pre-arc rows stay NULL).
|
|
169
|
+
*
|
|
170
|
+
* SQLite cannot alter a CHECK in place, so the create-copy-drop-rename
|
|
171
|
+
* pattern from v6 is reused here. Every column is copied explicitly,
|
|
172
|
+
* and `execution` is set to `'static'` for every v7 row so the
|
|
173
|
+
* NOT NULL DEFAULT on the new column is satisfied without a separate
|
|
174
|
+
* UPDATE.
|
|
175
|
+
*
|
|
176
|
+
* Trap that shipped twice in this project (opaque-content v6 and
|
|
177
|
+
* HTML v7): adding a REQUIRED column to a stored shape breaks
|
|
178
|
+
* read-back of every row written by earlier releases. The fix is
|
|
179
|
+
* BOTH halves: a tolerant read in `src/service/stored-verdict.ts`
|
|
180
|
+
* for the runtime boundary AND a backfill migration on disk. The
|
|
181
|
+
* v8 backfill below is the schema-side half; the existing
|
|
182
|
+
* `withTolerantObserved` defends the runtime half for `observed_json`
|
|
183
|
+
* while this fragment backfills `execution` and widens the CHECK.
|
|
184
|
+
*/
|
|
185
|
+
export const V8_SCHEMA_FRAGMENT = `
|
|
186
|
+
CREATE TABLE revisions_v8(
|
|
187
|
+
id TEXT PRIMARY KEY,
|
|
188
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
189
|
+
revision_number INTEGER NOT NULL,
|
|
190
|
+
parent_revision_id TEXT REFERENCES revisions(id),
|
|
191
|
+
artifact_type TEXT NOT NULL CHECK(artifact_type IN ('markdown','mermaid','svg','chart','html','tsx')),
|
|
192
|
+
source BLOB NOT NULL,
|
|
193
|
+
sha256 TEXT NOT NULL,
|
|
194
|
+
note TEXT,
|
|
195
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
196
|
+
created_at TEXT NOT NULL,
|
|
197
|
+
renderer TEXT NOT NULL DEFAULT 'svg' CHECK(renderer IN ('svg','canvas')),
|
|
198
|
+
execution TEXT NOT NULL DEFAULT 'static' CHECK(execution IN (${TSX_EXECUTION_MODES.map((mode) => `'${mode}'`).join(",")})),
|
|
199
|
+
UNIQUE(artifact_id, revision_number),
|
|
200
|
+
UNIQUE(artifact_id, sha256)
|
|
201
|
+
);
|
|
202
|
+
INSERT INTO revisions_v8(
|
|
203
|
+
id, artifact_id, revision_number, parent_revision_id, artifact_type, source, sha256,
|
|
204
|
+
note, pinned, created_at, renderer, execution
|
|
205
|
+
)
|
|
206
|
+
SELECT
|
|
207
|
+
id, artifact_id, revision_number, parent_revision_id, artifact_type, source, sha256,
|
|
208
|
+
note, pinned, created_at, renderer, 'static'
|
|
209
|
+
FROM revisions;
|
|
210
|
+
DROP TABLE revisions;
|
|
211
|
+
ALTER TABLE revisions_v8 RENAME TO revisions;
|
|
212
|
+
ALTER TABLE render_runs ADD COLUMN compiled_path TEXT;
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
export const V9_SCHEMA_FRAGMENT = `
|
|
216
|
+
ALTER TABLE render_runs ADD COLUMN screenshot_format TEXT
|
|
217
|
+
CHECK(screenshot_format IN ('png','webp'));
|
|
218
|
+
`;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Revision, RenderRun } from "../shared/contracts/artifact";
|
|
2
|
+
import { VerdictSchema, type Verdict, type VerdictObserved } from "../shared/contracts/validation";
|
|
3
|
+
import type { ArtifactRepository } from "./store/repository";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tolerate counters the pre-arc stored-observation schema did not
|
|
7
|
+
* include. Every prior release pre-dates both `externalImageCount`
|
|
8
|
+
* (added by this arc) and `opaqueRegionCount` (added by the prior
|
|
9
|
+
* opaque-content arc); a strict `VerdictObservedSchema.parse` would
|
|
10
|
+
* 400 the read-back / export / gallery-source routes on every
|
|
11
|
+
* pre-arc artifact. Default missing counters to 0 — the verdict's
|
|
12
|
+
* downstream behavior for a zero counter is what the verifier
|
|
13
|
+
* would have computed in the absence of the field (no partial,
|
|
14
|
+
* no opaque, no external_resources), and that is the conservative
|
|
15
|
+
* answer for a verifier that never observed the field at all.
|
|
16
|
+
*
|
|
17
|
+
* The companion migration (`src/service/store/migrations.ts`)
|
|
18
|
+
* backfills the missing counters to 0 in the on-disk JSON so the
|
|
19
|
+
* tolerant read is a defensive belt-and-braces against a row that
|
|
20
|
+
* was written before the migration ran. Treating the instance
|
|
21
|
+
* (`.optional()` on the schema) would fix this specific break but
|
|
22
|
+
* leave the same defect class to recur on the next counter
|
|
23
|
+
* addition; treating the boundary is what makes it durable.
|
|
24
|
+
*/
|
|
25
|
+
export function withTolerantObserved(parsed: unknown): VerdictObserved {
|
|
26
|
+
const observed = (parsed ?? {}) as Record<string, unknown>;
|
|
27
|
+
// Per-counter fallback: each field defaults to 0 when the stored
|
|
28
|
+
// JSON omits it (the pre-arc shape) and preserves the on-disk value
|
|
29
|
+
// when present. This is the durable treatment — every counter ever
|
|
30
|
+
// added can be extended the same way without re-breaking old rows.
|
|
31
|
+
const num = (key: string, fallback: number): number => {
|
|
32
|
+
const value = observed[key];
|
|
33
|
+
return typeof value === "number" ? value : fallback;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
rendererRootSvgCount: num("rendererRootSvgCount", 0),
|
|
37
|
+
graphCount: num("graphCount", 0),
|
|
38
|
+
mermaidNodeCount: num("mermaidNodeCount", 0),
|
|
39
|
+
visibleSvgCount: num("visibleSvgCount", 0),
|
|
40
|
+
opaqueRegionCount: num("opaqueRegionCount", 0),
|
|
41
|
+
externalImageCount: num("externalImageCount", 0),
|
|
42
|
+
// errorCount is required on the schema; pre-arc rows that never
|
|
43
|
+
// recorded it default to 0.
|
|
44
|
+
errorCount: num("errorCount", 0),
|
|
45
|
+
// Preserve optional fields the on-disk JSON may carry.
|
|
46
|
+
...(observed.html !== undefined ? { html: observed.html as VerdictObserved["html"] } : {}),
|
|
47
|
+
...(Array.isArray(observed.viewBoxes) ? { viewBoxes: observed.viewBoxes as string[] } : {}),
|
|
48
|
+
...(Array.isArray(observed.discriminativeErrors)
|
|
49
|
+
? {
|
|
50
|
+
discriminativeErrors:
|
|
51
|
+
observed.discriminativeErrors as VerdictObserved["discriminativeErrors"],
|
|
52
|
+
}
|
|
53
|
+
: {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function verdictFromStoredRun(revision: Revision, run: RenderRun): Verdict {
|
|
58
|
+
return VerdictSchema.parse({
|
|
59
|
+
status: run.status,
|
|
60
|
+
tier: run.tier,
|
|
61
|
+
artifactId: revision.artifactId,
|
|
62
|
+
revisionSha: revision.sha256,
|
|
63
|
+
observed: withTolerantObserved(JSON.parse(run.observedJson)),
|
|
64
|
+
...(run.screenshotErrorJson !== null
|
|
65
|
+
? { screenshotError: JSON.parse(run.screenshotErrorJson) }
|
|
66
|
+
: {}),
|
|
67
|
+
...(run.insecureJson !== null ? { insecure: JSON.parse(run.insecureJson) } : {}),
|
|
68
|
+
// Reconstruct execution from the revision row rather than the stored
|
|
69
|
+
// verdict. The render_run row carries no
|
|
70
|
+
// execution column (the marker is a verdict-level property, not a
|
|
71
|
+
// run-level one), and the marker is only emitted for TSX
|
|
72
|
+
// revisions — every other artifact type reads back with the
|
|
73
|
+
// field absent, so the wire form for non-TSX stays byte-identical.
|
|
74
|
+
//
|
|
75
|
+
// Execution-null policy (deliberate): when a TSX row's column
|
|
76
|
+
// is undefined on disk (purely defensive — the schema CHECK
|
|
77
|
+
// prevents NULL inserts and the v8 migration backfills pre-arc
|
|
78
|
+
// rows to 'static'), default to 'static' on the wire. 'static'
|
|
79
|
+
// is the canonical D2 default; emitting nothing was the prior
|
|
80
|
+
// silent behavior and an unpinned accident. Operators reading
|
|
81
|
+
// the wire form always see a marker on TSX rows.
|
|
82
|
+
...(revision.artifactType === "tsx" ? { execution: revision.execution ?? "static" } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function latestStoredVerdict(
|
|
87
|
+
repository: ArtifactRepository,
|
|
88
|
+
revision: Revision,
|
|
89
|
+
): Verdict | null {
|
|
90
|
+
const runs = ([0, 1] as const)
|
|
91
|
+
.flatMap((tier) => repository.listRenderRuns({ revisionId: revision.id, tier }))
|
|
92
|
+
.toSorted(
|
|
93
|
+
(left, right) => right.finishedAt.localeCompare(left.finishedAt) || right.tier - left.tier,
|
|
94
|
+
);
|
|
95
|
+
const run = runs[0];
|
|
96
|
+
return run === undefined ? null : verdictFromStoredRun(revision, run);
|
|
97
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE stream handler.
|
|
3
|
+
*
|
|
4
|
+
* The /api/v1/stream route emits revision-committed events to clients
|
|
5
|
+
* that hold a valid gallery lease. Auth happens BEFORE the lease check
|
|
6
|
+
* (a request without Bearer never reaches the lease logic), and the
|
|
7
|
+
* lease capability is carried via the authed fetch header — the
|
|
8
|
+
* `leaseId` is never embedded in a URL query parameter.
|
|
9
|
+
*
|
|
10
|
+
* Stream lifetime is bound to the lease: when the lease expires, the
|
|
11
|
+
* per-lease timer fires the manager's expiry-listener, which closes
|
|
12
|
+
* the controller and releases the stream's idle reason. Without this
|
|
13
|
+
* binding, the SSE connection would outlast its lease and the
|
|
14
|
+
* stream:<id> idle reason would pin the service alive forever.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
import type { z } from "zod";
|
|
20
|
+
|
|
21
|
+
import { errEnvelope } from "../shared/contracts/envelope";
|
|
22
|
+
import type { RevisionCommittedEventSchema } from "../shared/contracts/events";
|
|
23
|
+
|
|
24
|
+
import { requireBearer } from "./security/auth";
|
|
25
|
+
import { checkHostOrigin, resolveHost } from "./security/host-origin";
|
|
26
|
+
import type { GalleryLease, GalleryLeaseManager } from "./security/leases";
|
|
27
|
+
import type { FacetLogger } from "../shared/logging/logger";
|
|
28
|
+
import type { IdleController } from "./lifecycle/idle-controller";
|
|
29
|
+
import { envelopeResponse, generateRequestId } from "./http-utils";
|
|
30
|
+
|
|
31
|
+
const NO_STORE = "no-store";
|
|
32
|
+
export const STREAM_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
33
|
+
|
|
34
|
+
export function assertHeartbeatBeforeLeaseTtl(
|
|
35
|
+
heartbeatIntervalMs: number,
|
|
36
|
+
leaseTtlMs: number,
|
|
37
|
+
): void {
|
|
38
|
+
if (heartbeatIntervalMs >= leaseTtlMs) {
|
|
39
|
+
throw new RangeError("SSE heartbeat interval must be shorter than the lease TTL");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RevisionCommittedEvent = z.infer<typeof RevisionCommittedEventSchema>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-artifact fan-out for `revision:committed`. The write path
|
|
47
|
+
* (dispatcher, after commit + verdict runs) calls `emit`; every live
|
|
48
|
+
* SSE stream registered for that artifact receives the event. Sinks
|
|
49
|
+
* are isolated — one dead stream cannot wedge the others.
|
|
50
|
+
*/
|
|
51
|
+
export interface RevisionBroadcaster {
|
|
52
|
+
register(artifactId: string, send: (event: RevisionCommittedEvent) => void): () => void;
|
|
53
|
+
emit(event: RevisionCommittedEvent): void;
|
|
54
|
+
readonly size: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createRevisionBroadcaster(): RevisionBroadcaster {
|
|
58
|
+
const sinks = new Map<string, Set<(event: RevisionCommittedEvent) => void>>();
|
|
59
|
+
let size = 0;
|
|
60
|
+
return {
|
|
61
|
+
register(artifactId, send) {
|
|
62
|
+
let set = sinks.get(artifactId);
|
|
63
|
+
if (set === undefined) {
|
|
64
|
+
set = new Set();
|
|
65
|
+
sinks.set(artifactId, set);
|
|
66
|
+
}
|
|
67
|
+
set.add(send);
|
|
68
|
+
size += 1;
|
|
69
|
+
return () => {
|
|
70
|
+
const current = sinks.get(artifactId);
|
|
71
|
+
if (current === undefined) return;
|
|
72
|
+
if (current.delete(send)) size -= 1;
|
|
73
|
+
if (current.size === 0) sinks.delete(artifactId);
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
emit(event) {
|
|
77
|
+
const set = sinks.get(event.artifactId);
|
|
78
|
+
if (set === undefined) return;
|
|
79
|
+
// Spread snapshots the sinks so a callback that unregisters
|
|
80
|
+
// itself mid-fan-out does not skip the remaining peers.
|
|
81
|
+
// oxlint-disable-next-line unicorn/no-useless-spread
|
|
82
|
+
for (const send of [...set]) {
|
|
83
|
+
try {
|
|
84
|
+
send(event);
|
|
85
|
+
} catch {
|
|
86
|
+
// one dead stream must not wedge the fan-out
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
get size() {
|
|
91
|
+
return size;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface StreamHandlerDeps {
|
|
97
|
+
readonly installToken: string;
|
|
98
|
+
readonly leases: GalleryLeaseManager;
|
|
99
|
+
readonly idle: IdleController;
|
|
100
|
+
readonly logger: FacetLogger;
|
|
101
|
+
readonly expectedHost: string | (() => string);
|
|
102
|
+
readonly ownOrigin: string | (() => string);
|
|
103
|
+
readonly broadcaster: RevisionBroadcaster;
|
|
104
|
+
readonly heartbeatIntervalMs?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface StreamParsedRequest {
|
|
108
|
+
readonly url: string;
|
|
109
|
+
readonly method: string;
|
|
110
|
+
readonly host: string | null;
|
|
111
|
+
readonly origin: string | null;
|
|
112
|
+
readonly secFetchSite: string | null;
|
|
113
|
+
readonly authorization: string | null;
|
|
114
|
+
readonly headers: { get(name: string): string | null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function handleStream(deps: StreamHandlerDeps, req: StreamParsedRequest): Response {
|
|
118
|
+
const requestId = generateRequestId();
|
|
119
|
+
|
|
120
|
+
const hostCheck = checkHostOrigin({
|
|
121
|
+
method: req.method,
|
|
122
|
+
host: req.host,
|
|
123
|
+
origin: req.origin,
|
|
124
|
+
secFetchSite: req.secFetchSite,
|
|
125
|
+
expectedHost: resolveHost(deps.expectedHost),
|
|
126
|
+
ownOrigin: resolveHost(deps.ownOrigin),
|
|
127
|
+
});
|
|
128
|
+
if (!hostCheck.ok) {
|
|
129
|
+
let status = 400;
|
|
130
|
+
if (hostCheck.error.code === "invalid_request") {
|
|
131
|
+
const reason = (hostCheck.error.details as { reason?: string } | undefined)?.reason;
|
|
132
|
+
if (reason === "cross_site_mutation") status = 403;
|
|
133
|
+
else if (reason === "missing_host") status = 421;
|
|
134
|
+
}
|
|
135
|
+
return envelopeResponse(errEnvelope(requestId, hostCheck.error.toBody()), status);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const authResult = requireBearer(req.authorization, deps.installToken);
|
|
139
|
+
if (!authResult.ok) {
|
|
140
|
+
return envelopeResponse(errEnvelope(requestId, authResult.error.toBody()), 401);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Lease capability travels in the `X-Gallery-Lease` header, never in
|
|
144
|
+
// a URL query parameter. URL tokens leak via server logs, the
|
|
145
|
+
// `Referer` header on outbound navigations, and the browser history
|
|
146
|
+
// — any of those would expose the lease id to processes the bearer
|
|
147
|
+
// holder did not authorise. A query-string `?lease=` is REJECTED (not
|
|
148
|
+
// silently accepted) so a misconfigured client sees a typed 401
|
|
149
|
+
// instead of an unguarded stream.
|
|
150
|
+
const leaseId = req.headers.get("x-gallery-lease");
|
|
151
|
+
const artifactId = req.headers.get("x-gallery-artifact");
|
|
152
|
+
if (leaseId === null || artifactId === null) {
|
|
153
|
+
return envelopeResponse(
|
|
154
|
+
errEnvelope(requestId, {
|
|
155
|
+
code: "invalid_envelope",
|
|
156
|
+
message:
|
|
157
|
+
"SSE requires X-Gallery-Lease + X-Gallery-Artifact headers (no query-string fallback)",
|
|
158
|
+
retryable: false,
|
|
159
|
+
details: { reason: "lease_header_missing" },
|
|
160
|
+
}),
|
|
161
|
+
401,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const lease: GalleryLease = {
|
|
166
|
+
leaseId,
|
|
167
|
+
artifactId,
|
|
168
|
+
pid: process.pid,
|
|
169
|
+
expiresAt: Date.now() + 1_000,
|
|
170
|
+
};
|
|
171
|
+
if (!deps.leases.validate(lease)) {
|
|
172
|
+
return envelopeResponse(
|
|
173
|
+
errEnvelope(requestId, {
|
|
174
|
+
code: "invalid_envelope",
|
|
175
|
+
message: "Lease is invalid or expired",
|
|
176
|
+
retryable: false,
|
|
177
|
+
details: { reason: "lease_invalid" },
|
|
178
|
+
}),
|
|
179
|
+
401,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const streamId = randomUUID();
|
|
184
|
+
deps.idle.acquire(`stream:${streamId}`);
|
|
185
|
+
const encoder = new TextEncoder();
|
|
186
|
+
|
|
187
|
+
// Per-stream teardown. Every teardown path (cancel, lease-gone,
|
|
188
|
+
// renew-failure) funnels through `releaseStreamOnly`; calling any of
|
|
189
|
+
// them twice is a no-op (the inner `closed` guard short-circuits).
|
|
190
|
+
let closed = false;
|
|
191
|
+
let unregister: (() => void) | null = null;
|
|
192
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
193
|
+
|
|
194
|
+
// Shared stream-only teardown: clear the heartbeat interval, drop the
|
|
195
|
+
// broadcaster registration, and release the stream's idle reason. The
|
|
196
|
+
// lease is deliberately NOT touched here — the cancel path keeps the
|
|
197
|
+
// lease alive for the F5 refresh (the lease has its own TTL on the
|
|
198
|
+
// manager and is the credential the shell persists).
|
|
199
|
+
const releaseStreamOnly = (): void => {
|
|
200
|
+
if (closed) return;
|
|
201
|
+
closed = true;
|
|
202
|
+
if (heartbeat !== null) clearInterval(heartbeat);
|
|
203
|
+
unregister?.();
|
|
204
|
+
deps.idle.release(`stream:${streamId}`);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Full teardown: shared stream cleanup plus lease release. Runs when
|
|
208
|
+
// the lease is gone (expired or explicitly released).
|
|
209
|
+
const releaseLease = (): void => {
|
|
210
|
+
if (closed) return;
|
|
211
|
+
releaseStreamOnly();
|
|
212
|
+
deps.leases.release(leaseId);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const readable = new ReadableStream({
|
|
216
|
+
start(controller) {
|
|
217
|
+
const send = (event: unknown): void => {
|
|
218
|
+
try {
|
|
219
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
220
|
+
} catch {
|
|
221
|
+
// controller already closed (lease expired mid-send)
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
send({ type: "stream:open", streamId, artifactId, at: new Date().toISOString() });
|
|
225
|
+
|
|
226
|
+
// Write-path fan-out: committed revisions for THIS artifact land
|
|
227
|
+
// on this stream. Unregistered on lease expiry / disconnect.
|
|
228
|
+
unregister = deps.broadcaster.register(artifactId, (event) => send(event));
|
|
229
|
+
|
|
230
|
+
// Bind stream lifetime to lease lifetime. When the per-lease
|
|
231
|
+
// timer fires, we close the stream + release the idle reason.
|
|
232
|
+
deps.leases.onExpireNotify(leaseId, () => {
|
|
233
|
+
try {
|
|
234
|
+
controller.enqueue(
|
|
235
|
+
encoder.encode(
|
|
236
|
+
`data: ${JSON.stringify({
|
|
237
|
+
type: "stream:close",
|
|
238
|
+
streamId,
|
|
239
|
+
at: new Date().toISOString(),
|
|
240
|
+
reason: "lease_expired",
|
|
241
|
+
})}\n\n`,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
} catch {
|
|
245
|
+
// already closed
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
controller.close();
|
|
249
|
+
} catch {
|
|
250
|
+
// already closed
|
|
251
|
+
}
|
|
252
|
+
releaseLease();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
heartbeat = setInterval(() => {
|
|
256
|
+
// Re-check liveness — if the lease was removed by the expiry
|
|
257
|
+
// hook or an explicit release in the meantime, the controller
|
|
258
|
+
// is closed and the teardown already ran, so `closed` is set
|
|
259
|
+
// and we return without re-enqueueing.
|
|
260
|
+
if (closed) return;
|
|
261
|
+
try {
|
|
262
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
|
263
|
+
if (!deps.leases.renew(leaseId)) {
|
|
264
|
+
// Lease is gone (released or expired). The notification path
|
|
265
|
+
// normally fires first, but converge on the shared cleanup so
|
|
266
|
+
// the interval and idle reason are always released.
|
|
267
|
+
releaseStreamOnly();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
send({ type: "stream:heartbeat", streamId, at: new Date().toISOString() });
|
|
271
|
+
} catch {
|
|
272
|
+
// stream torn down between check and send
|
|
273
|
+
releaseStreamOnly();
|
|
274
|
+
}
|
|
275
|
+
}, deps.heartbeatIntervalMs ?? STREAM_HEARTBEAT_INTERVAL_MS);
|
|
276
|
+
if (typeof heartbeat.unref === "function") heartbeat.unref();
|
|
277
|
+
},
|
|
278
|
+
cancel() {
|
|
279
|
+
// Client-initiated disconnect: tear down the stream and the
|
|
280
|
+
// idle reason, but leave the lease alone. The lease has its own
|
|
281
|
+
// per-lease TTL on the lease manager and is the authoritative
|
|
282
|
+
// credential the shell persists to sessionStorage. Releasing it
|
|
283
|
+
// here would defeat the F5 refresh path: the cancel fires when
|
|
284
|
+
// the navigation tears down the SSE stream, the new shell would
|
|
285
|
+
// retry the lease it just released, and the typed "session
|
|
286
|
+
// expired" state would mask a perfectly valid credential.
|
|
287
|
+
// `stream:${streamId}` is the per-stream idle reason; the lease
|
|
288
|
+
// remains governed by GalleryLeaseManager.schedule.
|
|
289
|
+
releaseStreamOnly();
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return new Response(readable, {
|
|
294
|
+
status: 200,
|
|
295
|
+
headers: {
|
|
296
|
+
"content-type": "text/event-stream",
|
|
297
|
+
"cache-control": NO_STORE,
|
|
298
|
+
"x-accel-buffering": "no",
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InsecureLevel,
|
|
3
|
+
InsecureMarker,
|
|
4
|
+
TsxExecutionMode,
|
|
5
|
+
} from "../shared/contracts/validation";
|
|
6
|
+
|
|
7
|
+
export const defaultInsecureReason = (level: InsecureLevel): string =>
|
|
8
|
+
`manual insecure level ${level}`;
|
|
9
|
+
|
|
10
|
+
export function insecureMarker(
|
|
11
|
+
level: InsecureLevel,
|
|
12
|
+
reason: string | null | undefined,
|
|
13
|
+
): InsecureMarker | undefined {
|
|
14
|
+
if (level === 0) return undefined;
|
|
15
|
+
return { level, reason: reason ?? defaultInsecureReason(level) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bind the verdict to its (artifactId, revisionSha) pair and attach
|
|
20
|
+
* the insecure marker and the TSX execution marker when set.
|
|
21
|
+
* The execution marker is conditional spread so non-TSX verdicts
|
|
22
|
+
* stay BYTE-IDENTICAL to the pre-arc wire shape — the field is
|
|
23
|
+
* absent, not null. The Tier 0/1 result schema extends VerdictSchema,
|
|
24
|
+
* so the marker flows through TSX publish and read-back uniformly.
|
|
25
|
+
*/
|
|
26
|
+
export function enrichVerdict<T extends object>(
|
|
27
|
+
result: T,
|
|
28
|
+
artifactId: string,
|
|
29
|
+
revisionSha: string,
|
|
30
|
+
insecure?: InsecureMarker,
|
|
31
|
+
execution?: TsxExecutionMode,
|
|
32
|
+
): T & {
|
|
33
|
+
artifactId: string;
|
|
34
|
+
revisionSha: string;
|
|
35
|
+
insecure?: InsecureMarker;
|
|
36
|
+
execution?: TsxExecutionMode;
|
|
37
|
+
} {
|
|
38
|
+
return {
|
|
39
|
+
...result,
|
|
40
|
+
...(insecure !== undefined ? { insecure } : {}),
|
|
41
|
+
...(execution !== undefined ? { execution } : {}),
|
|
42
|
+
artifactId,
|
|
43
|
+
revisionSha,
|
|
44
|
+
};
|
|
45
|
+
}
|