@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,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer token authentication + constant-time comparison.
|
|
3
|
+
*
|
|
4
|
+
* `constantTimeEqual` runs in time proportional to `a.length` so a
|
|
5
|
+
* timing-attack adversary cannot recover `b` byte-by-byte. We normalize
|
|
6
|
+
* the comparison length to `b.length` (NOT `max(a.length, b.length)`)
|
|
7
|
+
* so a different-length `a` produces a deterministic mismatch without
|
|
8
|
+
* leaking position info — the body of the loop still touches the same
|
|
9
|
+
* number of slots as `b`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { FacetError } from "../../shared/errors/facet-error";
|
|
13
|
+
|
|
14
|
+
import { isMutationMethod } from "./http-guards";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Constant-time equality check. Both strings are compared slot-by-slot;
|
|
18
|
+
* the loop runs the full length of `b` regardless of where `a` first
|
|
19
|
+
* differs, and uses `|=(unsigned or)` to accumulate the diff so the
|
|
20
|
+
* timing depends only on `b.length`.
|
|
21
|
+
*/
|
|
22
|
+
export function constantTimeEqual(a: string, b: string): boolean {
|
|
23
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
24
|
+
let diff = a.length ^ b.length;
|
|
25
|
+
const len = b.length;
|
|
26
|
+
for (let i = 0; i < len; i += 1) {
|
|
27
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
28
|
+
}
|
|
29
|
+
return diff === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse an HTTP Authorization header and return the Bearer token, or
|
|
34
|
+
* null when the header is missing, malformed, or carries a different
|
|
35
|
+
* scheme. The scheme match is case-insensitive per RFC 7235.
|
|
36
|
+
*/
|
|
37
|
+
export function parseBearer(header: string | null | undefined): string | null {
|
|
38
|
+
if (header === null || header === undefined) return null;
|
|
39
|
+
const trimmed = header.trim();
|
|
40
|
+
if (trimmed.length === 0) return null;
|
|
41
|
+
const space = trimmed.indexOf(" ");
|
|
42
|
+
if (space <= 0) return null;
|
|
43
|
+
const scheme = trimmed.slice(0, space).toLowerCase();
|
|
44
|
+
if (scheme !== "bearer") return null;
|
|
45
|
+
const token = trimmed.slice(space + 1).trim();
|
|
46
|
+
if (token.length === 0) return null;
|
|
47
|
+
return token;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type BearerAuthResult =
|
|
51
|
+
| { readonly ok: true }
|
|
52
|
+
| { readonly ok: false; readonly error: FacetError };
|
|
53
|
+
|
|
54
|
+
export type AnyBearerAuthResult =
|
|
55
|
+
| ({ readonly ok: true } & { readonly matchedIndex: number })
|
|
56
|
+
| { readonly ok: false; readonly error: FacetError };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate that a request carries a Bearer token equal to one of the
|
|
60
|
+
* expected values (constant-time compare). Used for routes where the
|
|
61
|
+
* install bearer OR the operator promote bearer is acceptable — the
|
|
62
|
+
* dispatcher then chooses the verb-specific gate based on which token
|
|
63
|
+
* matched. Comparison is constant-time over every candidate so the
|
|
64
|
+
* match position does not leak via timing.
|
|
65
|
+
*/
|
|
66
|
+
export function requireAnyBearer(
|
|
67
|
+
header: string | null | undefined,
|
|
68
|
+
expected: readonly string[],
|
|
69
|
+
): AnyBearerAuthResult {
|
|
70
|
+
if (expected.length === 0) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: new FacetError("internal", "Server has no tokens configured", {
|
|
74
|
+
retryable: false,
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const token = parseBearer(header);
|
|
79
|
+
if (token === null) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: new FacetError("invalid_envelope", "Missing or malformed Authorization header", {
|
|
83
|
+
retryable: false,
|
|
84
|
+
details: { reason: "missing_bearer" },
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
let matched = -1;
|
|
89
|
+
for (let i = 0; i < expected.length; i += 1) {
|
|
90
|
+
const candidate = expected[i]!;
|
|
91
|
+
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
92
|
+
if (constantTimeEqual(token, candidate)) matched = i;
|
|
93
|
+
}
|
|
94
|
+
if (matched < 0) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: new FacetError("invalid_envelope", "Invalid bearer token", {
|
|
98
|
+
retryable: false,
|
|
99
|
+
details: { reason: "token_mismatch" },
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, matchedIndex: matched };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate that a request carries a Bearer token equal to the expected
|
|
108
|
+
* install token (constant-time compare). The caller supplies the request
|
|
109
|
+
* header and the expected token; this function returns a typed 401
|
|
110
|
+
* FacetError when the header is missing/malformed/wrong.
|
|
111
|
+
*/
|
|
112
|
+
export function requireBearer(
|
|
113
|
+
header: string | null | undefined,
|
|
114
|
+
expected: string,
|
|
115
|
+
): BearerAuthResult {
|
|
116
|
+
if (typeof expected !== "string" || expected.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: new FacetError("internal", "Server has no install token configured", {
|
|
120
|
+
retryable: false,
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const token = parseBearer(header);
|
|
125
|
+
if (token === null) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: new FacetError("invalid_envelope", "Missing or malformed Authorization header", {
|
|
129
|
+
retryable: false,
|
|
130
|
+
details: { reason: "missing_bearer" },
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (!constantTimeEqual(token, expected)) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: new FacetError("invalid_envelope", "Invalid bearer token", {
|
|
138
|
+
retryable: false,
|
|
139
|
+
details: { reason: "token_mismatch" },
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface MutationHeaderCheck {
|
|
147
|
+
readonly method: string;
|
|
148
|
+
readonly contentType: string | null | undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Enforce `Content-Type: application/json` on every state-changing
|
|
153
|
+
* request. Anything else (or no content type at all) is rejected with a
|
|
154
|
+
* typed invalid_request error so a CSRF vector that submits form-encoded
|
|
155
|
+
* bodies never reaches the dispatcher.
|
|
156
|
+
*/
|
|
157
|
+
export function checkMutationSecurityHeaders(check: MutationHeaderCheck): BearerAuthResult {
|
|
158
|
+
if (!isMutationMethod(check.method)) return { ok: true };
|
|
159
|
+
const ct = (check.contentType ?? "").trim().toLowerCase();
|
|
160
|
+
if (ct === "application/json" || ct.startsWith("application/json;")) return { ok: true };
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: new FacetError("invalid_request", "Mutations require Content-Type: application/json", {
|
|
164
|
+
retryable: false,
|
|
165
|
+
details: { received: check.contentType ?? null },
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host + Origin + Sec-Fetch-Site guard.
|
|
3
|
+
*
|
|
4
|
+
* DNS-rebinding defense: a request whose `Host` header is anything other
|
|
5
|
+
* than `127.0.0.1:<port>` is rejected — the loopback service must NOT
|
|
6
|
+
* serve a request that was forged from an external DNS name pointing
|
|
7
|
+
* at it.
|
|
8
|
+
*
|
|
9
|
+
* Cross-site forgery defense: a mutation that arrives with an `Origin`
|
|
10
|
+
* header not equal to the service's own origin OR with `Sec-Fetch-Site`
|
|
11
|
+
* set to anything other than `same-origin` / `none` is rejected. Reads
|
|
12
|
+
* (GET) intentionally bypass these checks so a same-process client can
|
|
13
|
+
* poll status without Origin noise.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { FacetError } from "../../shared/errors/facet-error";
|
|
17
|
+
|
|
18
|
+
import { isMutationMethod } from "./http-guards";
|
|
19
|
+
|
|
20
|
+
export interface HostOriginInput {
|
|
21
|
+
readonly method: string;
|
|
22
|
+
readonly host: string | null | undefined;
|
|
23
|
+
readonly origin: string | null | undefined;
|
|
24
|
+
readonly secFetchSite: string | null | undefined;
|
|
25
|
+
readonly expectedHost: string;
|
|
26
|
+
readonly ownOrigin: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type HostOriginResult =
|
|
30
|
+
| { readonly ok: true }
|
|
31
|
+
| { readonly ok: false; readonly error: FacetError };
|
|
32
|
+
|
|
33
|
+
const SAFE_SEC_FETCH_SITE = new Set(["same-origin", "none", ""]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reject when `Host` !== `expectedHost` exactly. The check is byte-exact
|
|
37
|
+
* — no canonicalization of `127.0.0.1.` with a trailing dot, no
|
|
38
|
+
* case-folding — so a forged header cannot sneak through a quirk of
|
|
39
|
+
* the URL parser. A null/empty `Host` is its own typed 400 (`reason:
|
|
40
|
+
* "missing_host"`) so the caller can distinguish "no host at all"
|
|
41
|
+
* from "host mismatch" — both fail-closed, neither is a 500.
|
|
42
|
+
*/
|
|
43
|
+
export const HOST_MISSING_REASON = "missing_host" as const;
|
|
44
|
+
|
|
45
|
+
export function checkHost(host: string | null | undefined, expectedHost: string): HostOriginResult {
|
|
46
|
+
if (host === null || host === undefined || host.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: new FacetError("invalid_request", "Missing Host header", {
|
|
50
|
+
retryable: false,
|
|
51
|
+
details: { reason: HOST_MISSING_REASON },
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (host !== expectedHost) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: new FacetError("invalid_request", `Host header does not match service origin`, {
|
|
59
|
+
retryable: false,
|
|
60
|
+
details: { reason: "host_mismatch", expected: expectedHost, received: host },
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a host configuration value: either a static string or a
|
|
69
|
+
* function that returns the live value. Exported so router.ts and
|
|
70
|
+
* stream.ts can use the same accessor (a duplicated copy in either
|
|
71
|
+
* file would risk one of them forgetting to call the function form).
|
|
72
|
+
*/
|
|
73
|
+
export function resolveHost(value: string | (() => string)): string {
|
|
74
|
+
return typeof value === "function" ? value() : value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const CROSS_SITE_REASON = "cross_site_mutation" as const;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Combined check used by the route guard. Reads (`GET`) require only a
|
|
81
|
+
* matching Host. Mutations additionally require:
|
|
82
|
+
* - Origin absent OR equal to ownOrigin
|
|
83
|
+
* - Sec-Fetch-Site absent OR in {same-origin, none}
|
|
84
|
+
* Any other value is a typed rejection. Cross-site mutation failures
|
|
85
|
+
* carry `details.reason === CROSS_SITE_REASON` so the caller can map
|
|
86
|
+
* them to a 403 (CSRF) status instead of a 400 (malformed request).
|
|
87
|
+
*/
|
|
88
|
+
export function checkHostOrigin(input: HostOriginInput): HostOriginResult {
|
|
89
|
+
const hostResult = checkHost(input.host, input.expectedHost);
|
|
90
|
+
if (!hostResult.ok) return hostResult;
|
|
91
|
+
|
|
92
|
+
if (!isMutationMethod(input.method)) return { ok: true };
|
|
93
|
+
|
|
94
|
+
const secFetchSite = (input.secFetchSite ?? "").toLowerCase();
|
|
95
|
+
if (secFetchSite.length > 0 && !SAFE_SEC_FETCH_SITE.has(secFetchSite)) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: new FacetError("invalid_request", `Cross-site mutation rejected`, {
|
|
99
|
+
retryable: false,
|
|
100
|
+
details: { reason: CROSS_SITE_REASON, secFetchSite },
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const origin = input.origin;
|
|
106
|
+
if (origin !== null && origin !== undefined && origin.length > 0) {
|
|
107
|
+
if (origin !== input.ownOrigin) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: new FacetError("invalid_request", `Origin does not match service origin`, {
|
|
111
|
+
retryable: false,
|
|
112
|
+
details: { reason: CROSS_SITE_REASON, received: origin, expected: input.ownOrigin },
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { ok: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isCrossSiteRejection(error: FacetError | undefined): boolean {
|
|
122
|
+
return (
|
|
123
|
+
error?.code === "invalid_request" &&
|
|
124
|
+
(error.details as { reason?: string } | undefined)?.reason === CROSS_SITE_REASON
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* True when a host-check rejection is a missing-host case (vs a host
|
|
130
|
+
* mismatch). Useful for the route guard to map both to 400 but log /
|
|
131
|
+
* surface them differently.
|
|
132
|
+
*/
|
|
133
|
+
export function isMissingHostRejection(error: FacetError | undefined): boolean {
|
|
134
|
+
return (
|
|
135
|
+
error?.code === "invalid_request" &&
|
|
136
|
+
(error.details as { reason?: string } | undefined)?.reason === HOST_MISSING_REASON
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP method guards shared between `auth.ts` and `host-origin.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Both files need to recognise state-changing methods (POST / PUT /
|
|
5
|
+
* DELETE / PATCH) for their respective checks (mutation Content-Type
|
|
6
|
+
* gate, cross-site forgery defense). Single source of truth so the two
|
|
7
|
+
* checks can never disagree on what counts as a mutation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function isMutationMethod(method: string): boolean {
|
|
11
|
+
const upper = method.toUpperCase();
|
|
12
|
+
return upper === "POST" || upper === "PUT" || upper === "DELETE" || upper === "PATCH";
|
|
13
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gallery lease manager.
|
|
3
|
+
*
|
|
4
|
+
* A lease is a short-lived token bound to (artifactId, pid) that
|
|
5
|
+
* authorizes an SSE stream reconnection for that specific artifact from
|
|
6
|
+
* a specific client process. The token is never embedded in a URL —
|
|
7
|
+
* clients reconnect using rotated Bearer authorization, so the SSE
|
|
8
|
+
* transport never carries a query-string token (EventSource has no way
|
|
9
|
+
* to set headers).
|
|
10
|
+
*
|
|
11
|
+
* Expiry is implemented via per-lease timers — NOT a global polling
|
|
12
|
+
* loop. This avoids the cost of a periodic wakeup and bounds the cost
|
|
13
|
+
* of the manager to the number of currently active leases.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface GalleryLease {
|
|
17
|
+
readonly leaseId: string;
|
|
18
|
+
readonly artifactId: string;
|
|
19
|
+
readonly pid: number;
|
|
20
|
+
readonly expiresAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GalleryLeaseManagerOptions {
|
|
24
|
+
readonly leaseTtlMs: number;
|
|
25
|
+
readonly now?: () => number;
|
|
26
|
+
readonly onExpire?: (entry: { leaseId: string; artifactId: string }) => void;
|
|
27
|
+
readonly onExpireNotify?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GalleryLeaseManager {
|
|
31
|
+
issue(input: { artifactId: string; pid: number }): GalleryLease;
|
|
32
|
+
validate(lease: GalleryLease): boolean;
|
|
33
|
+
renew(leaseId: string): boolean;
|
|
34
|
+
release(leaseId: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Register a callback fired exactly once when the lease goes away —
|
|
37
|
+
* either the per-lease timer firing (`expire`) or a caller-initiated
|
|
38
|
+
* `release`. Used to bind SSE stream lifetime to lease lifetime: the
|
|
39
|
+
* stream installs a hook here so its close-controller teardown runs
|
|
40
|
+
* whichever path removes the lease. Without this the stream kept its
|
|
41
|
+
* own idle reason forever and pinned the service idle path.
|
|
42
|
+
*/
|
|
43
|
+
onExpireNotify(leaseId: string, callback: () => void): void;
|
|
44
|
+
list(): readonly GalleryLease[];
|
|
45
|
+
clear(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ActiveLease {
|
|
49
|
+
readonly leaseId: string;
|
|
50
|
+
readonly artifactId: string;
|
|
51
|
+
readonly pid: number;
|
|
52
|
+
readonly expiresAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createLeaseManager(options: GalleryLeaseManagerOptions): GalleryLeaseManager {
|
|
56
|
+
const now = options.now ?? Date.now;
|
|
57
|
+
const active = new Map<string, ActiveLease>();
|
|
58
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
59
|
+
// One-shot notifications registered per lease. Fired by both `expire`
|
|
60
|
+
// and `release` — from the stream's perspective a released lease and
|
|
61
|
+
// an expired lease are the same event ("this lease is gone"). The
|
|
62
|
+
// stream handler installs its close-controller hook here so lease
|
|
63
|
+
// expiry OR explicit release closes the stream — without this binding
|
|
64
|
+
// the stream would outlast its lease and pin the idle controller.
|
|
65
|
+
const expireListeners = new Map<string, Array<() => void>>();
|
|
66
|
+
|
|
67
|
+
function expire(leaseId: string): void {
|
|
68
|
+
const removed = active.get(leaseId);
|
|
69
|
+
if (!removed) return;
|
|
70
|
+
active.delete(leaseId);
|
|
71
|
+
timers.delete(leaseId);
|
|
72
|
+
const listeners = expireListeners.get(leaseId);
|
|
73
|
+
expireListeners.delete(leaseId);
|
|
74
|
+
options.onExpire?.({ leaseId: removed.leaseId, artifactId: removed.artifactId });
|
|
75
|
+
if (listeners !== undefined) {
|
|
76
|
+
for (const cb of listeners) {
|
|
77
|
+
try {
|
|
78
|
+
cb();
|
|
79
|
+
} catch {
|
|
80
|
+
// notification handler errors must not break the timer path
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function schedule(leaseId: string, ttl: number): void {
|
|
87
|
+
const existing = timers.get(leaseId);
|
|
88
|
+
if (existing !== undefined) clearTimeout(existing);
|
|
89
|
+
const handle = setTimeout(() => expire(leaseId), ttl);
|
|
90
|
+
// Don't keep the event loop alive solely for lease timers — the
|
|
91
|
+
// service goes idle when the last reason clears, and a pending
|
|
92
|
+
// timer should not block shutdown.
|
|
93
|
+
if (typeof handle.unref === "function") handle.unref();
|
|
94
|
+
timers.set(leaseId, handle);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
issue(input) {
|
|
99
|
+
const issuedAt = now();
|
|
100
|
+
const leaseId = crypto.randomUUID();
|
|
101
|
+
const lease: ActiveLease = {
|
|
102
|
+
leaseId,
|
|
103
|
+
artifactId: input.artifactId,
|
|
104
|
+
pid: input.pid,
|
|
105
|
+
expiresAt: issuedAt + options.leaseTtlMs,
|
|
106
|
+
};
|
|
107
|
+
active.set(leaseId, lease);
|
|
108
|
+
schedule(leaseId, options.leaseTtlMs);
|
|
109
|
+
return lease;
|
|
110
|
+
},
|
|
111
|
+
validate(lease) {
|
|
112
|
+
const record = active.get(lease.leaseId);
|
|
113
|
+
if (!record) return false;
|
|
114
|
+
if (record.expiresAt <= now()) return false;
|
|
115
|
+
if (record.artifactId !== lease.artifactId) return false;
|
|
116
|
+
if (record.pid !== lease.pid) return false;
|
|
117
|
+
return true;
|
|
118
|
+
},
|
|
119
|
+
renew(leaseId) {
|
|
120
|
+
const record = active.get(leaseId);
|
|
121
|
+
if (!record) return false;
|
|
122
|
+
if (record.expiresAt <= now()) {
|
|
123
|
+
expire(leaseId);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
active.set(leaseId, { ...record, expiresAt: now() + options.leaseTtlMs });
|
|
127
|
+
schedule(leaseId, options.leaseTtlMs);
|
|
128
|
+
return true;
|
|
129
|
+
},
|
|
130
|
+
release(leaseId) {
|
|
131
|
+
const removed = active.get(leaseId);
|
|
132
|
+
if (!removed) return;
|
|
133
|
+
const handle = timers.get(leaseId);
|
|
134
|
+
if (handle !== undefined) {
|
|
135
|
+
clearTimeout(handle);
|
|
136
|
+
timers.delete(leaseId);
|
|
137
|
+
}
|
|
138
|
+
active.delete(leaseId);
|
|
139
|
+
// A caller-initiated release and a timer-driven expiry are the
|
|
140
|
+
// same event from the stream's perspective — "this lease is
|
|
141
|
+
// gone". Fire the per-lease listeners so a stream bound to this
|
|
142
|
+
// lease tears itself down (releasing its idle reason + broadcaster
|
|
143
|
+
// slot) instead of waiting forever for a timer that was just
|
|
144
|
+
// cancelled. `active` is deleted before the listeners run, so a
|
|
145
|
+
// re-entrant `release`/`renew` from inside a listener no-ops and
|
|
146
|
+
// the lease stays un-resurrectable.
|
|
147
|
+
const listeners = expireListeners.get(leaseId);
|
|
148
|
+
expireListeners.delete(leaseId);
|
|
149
|
+
if (listeners !== undefined) {
|
|
150
|
+
for (const cb of listeners) {
|
|
151
|
+
try {
|
|
152
|
+
cb();
|
|
153
|
+
} catch {
|
|
154
|
+
// notification handler errors must not break the release path
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
onExpireNotify(leaseId, callback) {
|
|
160
|
+
const existing = expireListeners.get(leaseId);
|
|
161
|
+
if (existing === undefined) {
|
|
162
|
+
expireListeners.set(leaseId, [callback]);
|
|
163
|
+
} else {
|
|
164
|
+
existing.push(callback);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
list() {
|
|
168
|
+
const cutoff = now();
|
|
169
|
+
const out: GalleryLease[] = [];
|
|
170
|
+
for (const entry of active.values()) {
|
|
171
|
+
if (entry.expiresAt > cutoff) out.push({ ...entry });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
},
|
|
175
|
+
clear() {
|
|
176
|
+
for (const handle of timers.values()) clearTimeout(handle);
|
|
177
|
+
timers.clear();
|
|
178
|
+
active.clear();
|
|
179
|
+
expireListeners.clear();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token store.
|
|
3
|
+
*
|
|
4
|
+
* Two distinct files, two distinct lifecycles:
|
|
5
|
+
*
|
|
6
|
+
* - INSTALL token (long-lived, auto-generated, mode 0600). Created on
|
|
7
|
+
* first service start. Used by the CLI to authenticate to the
|
|
8
|
+
* loopback service. Rotate() generates a new value and persists it
|
|
9
|
+
* — this is what enables "reconnect with rotated auth" without
|
|
10
|
+
* invalidating open connections: clients always re-read on
|
|
11
|
+
* reconnect.
|
|
12
|
+
*
|
|
13
|
+
* - OPERATOR PROMOTE token (NOT provisioned here). Absent → promote
|
|
14
|
+
* fails typed. Provisioning the operator token is an out-of-band
|
|
15
|
+
* action; this module simply refuses to fabricate one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { ensureOwnerOnlyDirectory } from "../../shared/util/dir-permissions";
|
|
22
|
+
|
|
23
|
+
export interface InstallTokenStoreOptions {
|
|
24
|
+
readonly tokenPath: string;
|
|
25
|
+
/** Test seam for forcing the losing first-write path without timing races. */
|
|
26
|
+
readonly beforeFirstWrite?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InstallTokenStore {
|
|
30
|
+
read(): string;
|
|
31
|
+
rotate(): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PromoteTokenStoreOptions {
|
|
35
|
+
readonly tokenPath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PromoteTokenStore {
|
|
39
|
+
exists(): boolean;
|
|
40
|
+
read(): string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generateInstallToken(): string {
|
|
44
|
+
// 32 random bytes → 64 hex chars. URL-safe and constant-time friendly.
|
|
45
|
+
const bytes = new Uint8Array(32);
|
|
46
|
+
crypto.getRandomValues(bytes);
|
|
47
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureTightPermissions(path: string): void {
|
|
51
|
+
try {
|
|
52
|
+
chmodSync(path, 0o600);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function recoverInstallTokenAfterWriteFailure(path: string, error: unknown): string {
|
|
59
|
+
if (existsSync(path)) {
|
|
60
|
+
const value = readFileSync(path, "utf8").trim();
|
|
61
|
+
if (value.length > 0) {
|
|
62
|
+
ensureTightPermissions(path);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createInstallTokenStore(options: InstallTokenStoreOptions): InstallTokenStore {
|
|
70
|
+
let cached: string | null = null;
|
|
71
|
+
|
|
72
|
+
function loadFromDisk(): string {
|
|
73
|
+
let emptyRecoveryPath: string | null = null;
|
|
74
|
+
if (existsSync(options.tokenPath)) {
|
|
75
|
+
const value = readFileSync(options.tokenPath, "utf8").trim();
|
|
76
|
+
if (value.length > 0) {
|
|
77
|
+
ensureTightPermissions(options.tokenPath);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
emptyRecoveryPath = `${options.tokenPath}.empty-${crypto.randomUUID()}`;
|
|
81
|
+
try {
|
|
82
|
+
renameSync(options.tokenPath, emptyRecoveryPath);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return loadFromDisk();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Atomic first-write: O_EXCL ensures exactly one writer wins the
|
|
89
|
+
// race. Losers (EEXIST) re-read what the winner persisted — they
|
|
90
|
+
// never cache a different token than the one on disk. The
|
|
91
|
+
// containing directory lands at mode 0700 via the shared helper so
|
|
92
|
+
// a hostile umask cannot widen the secret-bearing layout.
|
|
93
|
+
ensureOwnerOnlyDirectory(dirname(options.tokenPath));
|
|
94
|
+
const fresh = generateInstallToken();
|
|
95
|
+
try {
|
|
96
|
+
options.beforeFirstWrite?.();
|
|
97
|
+
writeFileSync(options.tokenPath, fresh, {
|
|
98
|
+
mode: 0o600,
|
|
99
|
+
flag: "wx",
|
|
100
|
+
});
|
|
101
|
+
ensureTightPermissions(options.tokenPath);
|
|
102
|
+
return fresh;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return recoverInstallTokenAfterWriteFailure(options.tokenPath, error);
|
|
105
|
+
} finally {
|
|
106
|
+
if (emptyRecoveryPath !== null) rmSync(emptyRecoveryPath, { force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
read() {
|
|
112
|
+
if (cached === null) cached = loadFromDisk();
|
|
113
|
+
else ensureTightPermissions(options.tokenPath);
|
|
114
|
+
return cached;
|
|
115
|
+
},
|
|
116
|
+
rotate() {
|
|
117
|
+
const fresh = generateInstallToken();
|
|
118
|
+
ensureOwnerOnlyDirectory(dirname(options.tokenPath));
|
|
119
|
+
writeFileSync(options.tokenPath, fresh, { mode: 0o600 });
|
|
120
|
+
ensureTightPermissions(options.tokenPath);
|
|
121
|
+
cached = fresh;
|
|
122
|
+
return fresh;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createPromoteTokenStore(options: PromoteTokenStoreOptions): PromoteTokenStore {
|
|
128
|
+
return {
|
|
129
|
+
exists() {
|
|
130
|
+
return existsSync(options.tokenPath);
|
|
131
|
+
},
|
|
132
|
+
read() {
|
|
133
|
+
if (!existsSync(options.tokenPath)) return null;
|
|
134
|
+
const value = readFileSync(options.tokenPath, "utf8").trim();
|
|
135
|
+
return value.length > 0 ? value : null;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|