@crvouga/atlas 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +178 -604
- package/dist/bun/src/driver.d.ts +41 -7
- package/dist/bun/src/driver.js +47 -6
- package/dist/bun/src/index.d.ts +8 -0
- package/dist/bun/src/index.js +8 -0
- package/dist/core/src/cli.d.ts +14 -1
- package/dist/core/src/cli.js +30 -1
- package/dist/core/src/config.d.ts +70 -4
- package/dist/core/src/config.js +62 -3
- package/dist/core/src/events/cloudevents.d.ts +54 -9
- package/dist/core/src/events/cloudevents.js +60 -9
- package/dist/core/src/graph.d.ts +21 -0
- package/dist/core/src/graph.js +26 -0
- package/dist/core/src/index.d.ts +11 -0
- package/dist/core/src/index.js +11 -0
- package/dist/core/src/lint.d.ts +30 -0
- package/dist/core/src/lint.js +13 -0
- package/dist/core/src/plan.d.ts +39 -1
- package/dist/core/src/plan.js +28 -1
- package/dist/core/src/privacy.d.ts +29 -2
- package/dist/core/src/privacy.js +19 -2
- package/dist/core/src/report/formats.d.ts +34 -4
- package/dist/core/src/report/formats.js +24 -4
- package/dist/core/src/report/markdown.d.ts +15 -0
- package/dist/core/src/report/markdown.js +8 -0
- package/dist/core/src/run/clients.d.ts +44 -5
- package/dist/core/src/run/clients.js +46 -2
- package/dist/core/src/run/execute.d.ts +69 -16
- package/dist/core/src/run/execute.js +67 -16
- package/dist/core/src/run/frames.d.ts +13 -4
- package/dist/core/src/run/frames.js +19 -4
- package/dist/core/src/run/http.d.ts +34 -1
- package/dist/core/src/run/http.js +14 -1
- package/dist/core/src/run/types.d.ts +76 -3
- package/dist/core/src/run/types.js +19 -3
- package/dist/core/src/signals.d.ts +49 -5
- package/dist/core/src/signals.js +22 -5
- package/dist/core/src/spec/compose.d.ts +23 -1
- package/dist/core/src/spec/compose.js +10 -1
- package/dist/core/src/spec/index.d.ts +9 -1
- package/dist/core/src/spec/index.js +9 -1
- package/dist/core/src/spec/load.d.ts +22 -1
- package/dist/core/src/spec/load.js +31 -1
- package/dist/core/src/spec/parse.d.ts +16 -2
- package/dist/core/src/spec/parse.js +25 -2
- package/dist/core/src/spec/scxml.d.ts +25 -4
- package/dist/core/src/spec/scxml.js +36 -4
- package/dist/core/src/spec/types.d.ts +52 -1
- package/dist/core/src/spec/types.js +24 -0
- package/dist/detox/src/index.d.ts +54 -11
- package/dist/detox/src/index.js +45 -7
- package/dist/playwright/src/driver.d.ts +39 -3
- package/dist/playwright/src/driver.js +27 -3
- package/dist/playwright/src/index.d.ts +10 -0
- package/dist/playwright/src/index.js +10 -0
- package/dist/playwright/src/media.d.ts +50 -4
- package/dist/playwright/src/media.js +55 -4
- package/dist/playwright/src/signals.d.ts +44 -5
- package/dist/playwright/src/signals.js +29 -5
- package/dist/reports/src/attachment-files.d.ts +44 -0
- package/dist/reports/src/attachment-files.js +121 -0
- package/dist/reports/src/attachment-response.d.ts +31 -0
- package/dist/reports/src/attachment-response.js +55 -0
- package/dist/reports/src/aws-credentials.d.ts +48 -0
- package/dist/reports/src/aws-credentials.js +221 -0
- package/dist/reports/src/browser-navigation.d.ts +14 -0
- package/dist/reports/src/browser-navigation.js +7 -0
- package/dist/reports/src/bundle.d.ts +34 -2
- package/dist/reports/src/bundle.js +38 -2
- package/dist/reports/src/catalog.d.ts +34 -3
- package/dist/reports/src/catalog.js +17 -3
- package/dist/reports/src/cli-stores.d.ts +36 -0
- package/dist/reports/src/cli-stores.js +73 -0
- package/dist/reports/src/clock.d.ts +7 -2
- package/dist/reports/src/clock.js +5 -2
- package/dist/reports/src/completion-order.d.ts +18 -5
- package/dist/reports/src/completion-order.js +18 -5
- package/dist/reports/src/compose.d.ts +80 -0
- package/dist/reports/src/compose.js +117 -0
- package/dist/reports/src/copy-store.d.ts +29 -0
- package/dist/reports/src/copy-store.js +52 -0
- package/dist/reports/src/directory-outline.d.ts +38 -0
- package/dist/reports/src/directory-outline.js +103 -0
- package/dist/reports/src/directory-store.d.ts +20 -0
- package/dist/reports/src/directory-store.js +166 -0
- package/dist/reports/src/doctor.d.ts +29 -0
- package/dist/reports/src/doctor.js +83 -0
- package/dist/reports/src/effective-delta.d.ts +34 -0
- package/dist/reports/src/effective-delta.js +25 -0
- package/dist/reports/src/evidence-detail.d.ts +64 -0
- package/dist/reports/src/evidence-detail.js +92 -0
- package/dist/reports/src/evidence-live.d.ts +40 -4
- package/dist/reports/src/evidence-live.js +52 -4
- package/dist/reports/src/evidence-types.d.ts +70 -1
- package/dist/reports/src/evidence-types.js +5 -1
- package/dist/reports/src/evidence-view.d.ts +58 -6
- package/dist/reports/src/evidence-view.js +44 -5
- package/dist/reports/src/explorer-types.d.ts +40 -3
- package/dist/reports/src/explorer-types.js +8 -0
- package/dist/reports/src/explorer-versions.d.ts +9 -1
- package/dist/reports/src/explorer-versions.js +18 -1
- package/dist/reports/src/export-evidence.d.ts +16 -1
- package/dist/reports/src/export-evidence.js +52 -1
- package/dist/reports/src/fetcher.d.ts +27 -3
- package/dist/reports/src/fetcher.js +11 -3
- package/dist/reports/src/format-date.d.ts +6 -0
- package/dist/reports/src/format-date.js +6 -0
- package/dist/reports/src/format.d.ts +16 -0
- package/dist/reports/src/format.js +10 -0
- package/dist/reports/src/frames.d.ts +36 -3
- package/dist/reports/src/frames.js +22 -3
- package/dist/reports/src/hash.d.ts +18 -0
- package/dist/reports/src/hash.js +13 -0
- package/dist/reports/src/host.d.ts +29 -0
- package/dist/reports/src/host.js +64 -0
- package/dist/reports/src/html-frame.d.ts +5 -6
- package/dist/reports/src/html-frame.js +12 -1
- package/dist/reports/src/http-store.d.ts +52 -0
- package/dist/reports/src/http-store.js +145 -0
- package/dist/reports/src/http.d.ts +7 -0
- package/dist/reports/src/http.js +6 -0
- package/dist/reports/src/ingest.d.ts +54 -0
- package/dist/reports/src/ingest.js +210 -0
- package/dist/reports/src/init.d.ts +40 -0
- package/dist/reports/src/init.js +364 -0
- package/dist/reports/src/junit.d.ts +33 -6
- package/dist/reports/src/junit.js +87 -21
- package/dist/reports/src/keyboard.d.ts +20 -0
- package/dist/reports/src/keyboard.js +11 -0
- package/dist/reports/src/keys.d.ts +21 -3
- package/dist/reports/src/keys.js +21 -3
- package/dist/reports/src/layer-axes.d.ts +48 -0
- package/dist/reports/src/layer-axes.js +71 -0
- package/dist/reports/src/layer-catalog.d.ts +28 -1
- package/dist/reports/src/layer-catalog.js +19 -2
- package/dist/reports/src/layer-merge.d.ts +98 -9
- package/dist/reports/src/layer-merge.js +154 -33
- package/dist/reports/src/layer-source.d.ts +18 -0
- package/dist/reports/src/layer-source.js +14 -0
- package/dist/reports/src/layer-store.d.ts +42 -0
- package/dist/reports/src/layer-store.js +63 -5
- package/dist/reports/src/layer-types.d.ts +34 -1
- package/dist/reports/src/layers-route.d.ts +47 -4
- package/dist/reports/src/layers-route.js +88 -12
- package/dist/reports/src/media-types.d.ts +11 -0
- package/dist/reports/src/media-types.js +22 -0
- package/dist/reports/src/namespace-guard.d.ts +10 -1
- package/dist/reports/src/namespace-guard.js +10 -1
- package/dist/reports/src/navigation.d.ts +12 -0
- package/dist/reports/src/navigation.js +7 -0
- package/dist/reports/src/object-io.d.ts +11 -4
- package/dist/reports/src/object-io.js +4 -4
- package/dist/reports/src/object-store-conformance.d.ts +27 -0
- package/dist/reports/src/object-store-conformance.js +87 -0
- package/dist/reports/src/object-store.d.ts +33 -24
- package/dist/reports/src/object-store.js +17 -24
- package/dist/reports/src/parse-with.d.ts +16 -0
- package/dist/reports/src/parse-with.js +14 -0
- package/dist/reports/src/performance-part.d.ts +25 -1
- package/dist/reports/src/performance-part.js +15 -1
- package/dist/reports/src/performance.d.ts +22 -2
- package/dist/reports/src/performance.js +12 -2
- package/dist/reports/src/persist.d.ts +12 -3
- package/dist/reports/src/persist.js +12 -3
- package/dist/reports/src/phone.d.ts +18 -1
- package/dist/reports/src/phone.js +18 -1
- package/dist/reports/src/protocol.d.ts +30 -4
- package/dist/reports/src/protocol.js +15 -4
- package/dist/reports/src/publication.d.ts +21 -1
- package/dist/reports/src/publication.js +21 -1
- package/dist/reports/src/publish-checks.d.ts +35 -0
- package/dist/reports/src/publish-checks.js +122 -0
- package/dist/reports/src/publish.d.ts +44 -9
- package/dist/reports/src/publish.js +40 -7
- package/dist/reports/src/read.d.ts +46 -16
- package/dist/reports/src/read.js +38 -14
- package/dist/reports/src/recording-bundle.d.ts +13 -2
- package/dist/reports/src/recording-bundle.js +4 -2
- package/dist/reports/src/recording-keys.d.ts +15 -1
- package/dist/reports/src/recording-keys.js +15 -1
- package/dist/reports/src/remote-report.d.ts +28 -7
- package/dist/reports/src/remote-report.js +15 -7
- package/dist/reports/src/report-cli.d.ts +51 -0
- package/dist/reports/src/report-cli.js +485 -0
- package/dist/reports/src/report-index.d.ts +99 -0
- package/dist/reports/src/report-index.js +337 -0
- package/dist/reports/src/report-loader.d.ts +10 -0
- package/dist/reports/src/report-loader.js +6 -0
- package/dist/reports/src/report-parts.d.ts +29 -3
- package/dist/reports/src/report-parts.js +40 -3
- package/dist/reports/src/report-status.d.ts +48 -0
- package/dist/reports/src/report-status.js +156 -0
- package/dist/reports/src/report-types.d.ts +73 -0
- package/dist/reports/src/report-types.js +3 -0
- package/dist/reports/src/reporter.d.ts +67 -0
- package/dist/reports/src/reporter.js +128 -0
- package/dist/reports/src/reports-explorer-route.d.ts +541 -0
- package/dist/reports/src/reports-explorer-route.js +11 -0
- package/dist/reports/src/reports-list-route.d.ts +9 -0
- package/dist/reports/src/reports-list-route.js +9 -0
- package/dist/reports/src/reports-route.d.ts +561 -22
- package/dist/reports/src/reports-route.js +11 -3
- package/dist/reports/src/reports-store.d.ts +19 -2
- package/dist/reports/src/reports-store.js +10 -2
- package/dist/reports/src/resilient-cache.d.ts +14 -1
- package/dist/reports/src/resilient-cache.js +8 -1
- package/dist/reports/src/retention.d.ts +17 -1
- package/dist/reports/src/retention.js +5 -1
- package/dist/reports/src/run-bundle.d.ts +63 -0
- package/dist/reports/src/run-bundle.js +159 -0
- package/dist/reports/src/s3-store.d.ts +79 -0
- package/dist/reports/src/s3-store.js +295 -0
- package/dist/reports/src/scenario.d.ts +14 -4
- package/dist/reports/src/scenario.js +5 -4
- package/dist/reports/src/sigv4.d.ts +72 -0
- package/dist/reports/src/sigv4.js +96 -0
- package/dist/reports/src/snapshot-bundle.d.ts +24 -1
- package/dist/reports/src/snapshot-bundle.js +27 -2
- package/dist/reports/src/snapshot-surface.d.ts +20 -9
- package/dist/reports/src/snapshot-surface.js +32 -19
- package/dist/reports/src/source-content.d.ts +23 -0
- package/dist/reports/src/source-content.js +30 -1
- package/dist/reports/src/source-entries.d.ts +13 -0
- package/dist/reports/src/source-entries.js +6 -0
- package/dist/reports/src/source-part.d.ts +15 -0
- package/dist/reports/src/source-part.js +8 -0
- package/dist/reports/src/source-reader.d.ts +11 -0
- package/dist/reports/src/source-reader.js +5 -0
- package/dist/reports/src/spec-evidence.d.ts +12 -2
- package/dist/reports/src/spec-evidence.js +6 -2
- package/dist/reports/src/spec-inventory.d.ts +15 -1
- package/dist/reports/src/spec-inventory.js +22 -1
- package/dist/reports/src/spec-link.d.ts +28 -0
- package/dist/reports/src/spec-link.js +70 -0
- package/dist/reports/src/spec-set.d.ts +63 -0
- package/dist/reports/src/spec-set.js +177 -0
- package/dist/reports/src/status.d.ts +10 -0
- package/dist/reports/src/status.js +5 -0
- package/dist/reports/src/store-prefix.d.ts +28 -0
- package/dist/reports/src/store-prefix.js +22 -0
- package/dist/reports/src/store-url.d.ts +89 -0
- package/dist/reports/src/store-url.js +160 -0
- package/dist/reports/src/test-catalog-entity.d.ts +11 -0
- package/dist/reports/src/test-catalog-entity.js +6 -0
- package/dist/reports/src/types.d.ts +6 -1
- package/dist/reports/src/use-report-nav.d.ts +35 -0
- package/dist/reports/src/use-report-nav.js +12 -0
- package/dist/schema/src/api.d.ts +799 -0
- package/dist/schema/src/api.js +201 -0
- package/dist/schema/src/cli-results.d.ts +210 -0
- package/dist/schema/src/cli-results.js +124 -0
- package/dist/schema/src/common.d.ts +53 -0
- package/dist/schema/src/common.js +42 -0
- package/dist/schema/src/index.d.ts +14 -0
- package/dist/schema/src/index.js +14 -0
- package/dist/schema/src/layer.d.ts +2273 -0
- package/dist/schema/src/layer.js +462 -0
- package/dist/schema/src/manifest.d.ts +95 -0
- package/dist/schema/src/manifest.js +84 -0
- package/dist/schema/src/report-sources.d.ts +28 -0
- package/dist/schema/src/report-sources.js +25 -0
- package/dist/schema/src/runs-index.d.ts +16 -0
- package/dist/schema/src/runs-index.js +15 -0
- package/dist/schema/src/server-config.d.ts +93 -0
- package/dist/schema/src/server-config.js +73 -0
- package/dist/schema/src/spec.d.ts +69 -0
- package/dist/schema/src/spec.js +58 -0
- package/dist/server/src/app.d.ts +62 -0
- package/dist/server/src/app.js +419 -0
- package/dist/server/src/auth.d.ts +41 -0
- package/dist/server/src/auth.js +59 -0
- package/dist/server/src/config.d.ts +83 -0
- package/dist/server/src/config.js +187 -0
- package/dist/server/src/dev.d.ts +12 -0
- package/dist/server/src/dev.js +80 -0
- package/dist/server/src/events.d.ts +29 -0
- package/dist/server/src/events.js +101 -0
- package/dist/server/src/host.d.ts +30 -0
- package/dist/server/src/host.js +115 -0
- package/dist/server/src/index.d.ts +16 -0
- package/dist/server/src/index.js +16 -0
- package/dist/server/src/llms.d.ts +19 -0
- package/dist/server/src/llms.js +63 -0
- package/dist/server/src/main.d.ts +30 -0
- package/dist/server/src/main.js +112 -0
- package/dist/server/src/openapi.d.ts +59 -0
- package/dist/server/src/openapi.js +374 -0
- package/dist/server/src/problem.d.ts +8 -0
- package/dist/server/src/problem.js +39 -0
- package/dist/server/src/site.d.ts +13 -0
- package/dist/server/src/site.js +43 -0
- package/dist/server/src/store-api.d.ts +28 -0
- package/dist/server/src/store-api.js +159 -0
- package/dist/server/src/validate.d.ts +71 -0
- package/dist/server/src/validate.js +197 -0
- package/dist/vitest/src/index.d.ts +27 -5
- package/dist/vitest/src/index.js +18 -5
- package/dist/webdriver/src/driver.d.ts +63 -11
- package/dist/webdriver/src/driver.js +57 -6
- package/dist/webdriver/src/index.d.ts +8 -0
- package/dist/webdriver/src/index.js +1 -0
- package/dist/webdriver/src/protocol.d.ts +75 -6
- package/dist/webdriver/src/protocol.js +79 -6
- package/docs/agents.md +137 -0
- package/docs/deploying.md +345 -0
- package/docs/dogfooding.md +175 -0
- package/docs/extending.md +242 -0
- package/docs/integrating.md +485 -0
- package/docs/reference/bun.md +141 -0
- package/docs/reference/cli.md +146 -0
- package/docs/reference/core.md +2426 -0
- package/docs/reference/detox.md +150 -0
- package/docs/reference/http-api.md +303 -0
- package/docs/reference/playwright.md +303 -0
- package/docs/reference/reports.md +7098 -0
- package/docs/reference/schema.md +1651 -0
- package/docs/reference/server.md +685 -0
- package/docs/reference/vitest.md +45 -0
- package/docs/reference/webdriver.md +330 -0
- package/docs/report-backends.md +144 -0
- package/docs/report-contract.md +400 -0
- package/docs/reporting-issues.md +160 -0
- package/docs/server.md +285 -0
- package/docs/statecharts.md +641 -0
- package/llms-full.txt +3506 -0
- package/llms.txt +47 -0
- package/package.json +33 -5
- package/packages/core/README.md +2 -1
- package/packages/reports/README.md +69 -2
- package/packages/reports/STORAGE.md +5 -1
- package/packages/schema/README.md +14 -0
- package/packages/schema/schemas/check-detail.schema.json +1032 -0
- package/packages/schema/schemas/checks-result.schema.json +26 -0
- package/packages/schema/schemas/command-error.schema.json +35 -0
- package/packages/schema/schemas/doctor-result.schema.json +60 -0
- package/packages/schema/schemas/effective-delta.schema.json +490 -0
- package/packages/schema/schemas/effective-evidence.schema.json +471 -0
- package/packages/schema/schemas/evidence-page.schema.json +271 -0
- package/packages/schema/schemas/hello-event.schema.json +22 -0
- package/packages/schema/schemas/init-plan.schema.json +347 -0
- package/packages/schema/schemas/layer-rows.schema.json +199 -0
- package/packages/schema/schemas/layer.schema.json +2888 -0
- package/packages/schema/schemas/merge-result.schema.json +40 -0
- package/packages/schema/schemas/outline.schema.json +62 -0
- package/packages/schema/schemas/problem.schema.json +37 -0
- package/packages/schema/schemas/publish-checks.schema.json +1413 -0
- package/packages/schema/schemas/publish-result.schema.json +168 -0
- package/packages/schema/schemas/ready.schema.json +68 -0
- package/packages/schema/schemas/report-event.schema.json +100 -0
- package/packages/schema/schemas/report-status.schema.json +370 -0
- package/packages/schema/schemas/server-config.schema.json +194 -0
- package/packages/schema/schemas/server-info.schema.json +114 -0
- package/packages/schema/schemas/spec-set.schema.json +103 -0
- package/packages/schema/schemas/store-listing.schema.json +29 -0
- package/packages/schema/schemas/store-write-result.schema.json +28 -0
- package/packages/schema/schemas/stream-result.schema.json +59 -0
- package/packages/server/README.md +43 -0
- package/packages/server/openapi.json +6699 -0
- package/packages/visualizer/README.md +27 -5
- package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/scripts/make-fixtures.ts +2 -0
- package/packages/visualizer/scripts/publish-data.ts +1 -2
- package/packages/visualizer/src/app/AppShell.tsx +9 -2
- package/packages/visualizer/src/app/report-sources.tsx +6 -4
- package/packages/visualizer/src/app/router.tsx +3 -3
- package/packages/visualizer/src/components/Header.module.css +35 -0
- package/packages/visualizer/src/components/Header.tsx +41 -4
- package/packages/visualizer/src/data/adapters/adapter.ts +5 -17
- package/packages/visualizer/src/data/adapters/index.ts +10 -1
- package/packages/visualizer/src/evidence/api.ts +14 -2
- package/packages/visualizer/src/evidence/artifacts.module.css +213 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +272 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +38 -4
- package/packages/visualizer/src/evidence/evidence-context.tsx +74 -12
- package/packages/visualizer/src/evidence/evidence.module.css +20 -0
- package/packages/visualizer/src/evidence/marks.tsx +2 -0
- package/packages/visualizer/src/evidence/overview.tsx +3 -3
- package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
- package/packages/visualizer/src/evidence/spec-page.tsx +23 -16
- package/packages/visualizer/src/find/Finder.tsx +1 -7
- package/packages/visualizer/src/find/open.ts +4 -0
- package/packages/visualizer/src/live/live-stream.ts +204 -0
- package/packages/visualizer/src/runtime.ts +19 -0
- package/packages/visualizer/src/state/ui-store.ts +6 -2
- package/packages/visualizer/vite/atlas-server-proxy.ts +36 -0
- package/packages/visualizer/vite/example-reports.ts +475 -0
- package/packages/visualizer/vite.config.ts +18 -2
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module The Atlas server application
|
|
3
|
+
*
|
|
4
|
+
* `createAtlasServer` is the whole of a project's Atlas as one `fetch` handler: the API results are uploaded to, the live
|
|
5
|
+
* report every viewer is shown, the gate, the server-sent event stream, the specs and runs the viewer reads, the viewer
|
|
6
|
+
* itself, and the server's own description (`/api/openapi.json`, `/llms.txt`). What it knows lives in its backends (by
|
|
7
|
+
* default a folder on its own disk), so replacing it loses nothing. Every error leaves as an RFC 9457 problem document; every
|
|
8
|
+
* route is described in `openapi.ts` (a test holds the two together).
|
|
9
|
+
*
|
|
10
|
+
* @see docs/server.md
|
|
11
|
+
*/
|
|
12
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
import { brotliCompress, constants as zlibConstants, gzip } from 'node:zlib';
|
|
16
|
+
import { Hono } from 'hono';
|
|
17
|
+
import { UNTRUSTED_HEADERS } from '../../reports/src/attachment-response.js';
|
|
18
|
+
import { combineBackends, summarize } from '../../reports/src/compose.js';
|
|
19
|
+
import { combineEvidence } from '../../reports/src/layer-merge.js';
|
|
20
|
+
import { layerCatalogKey } from '../../reports/src/layer-store.js';
|
|
21
|
+
import { createLayersApp } from '../../reports/src/layers-route.js';
|
|
22
|
+
import { publishChecks } from '../../reports/src/publish-checks.js';
|
|
23
|
+
import { reportStatus } from '../../reports/src/report-status.js';
|
|
24
|
+
import { ReportIndex } from '../../reports/src/report-index.js';
|
|
25
|
+
import { listRunSummaries, readRunFile } from '../../reports/src/run-bundle.js';
|
|
26
|
+
import { latestSpecSet, readSpecBody } from '../../reports/src/spec-set.js';
|
|
27
|
+
import { llmsTxt } from './llms.js';
|
|
28
|
+
import { openApiDocument } from './openapi.js';
|
|
29
|
+
import { asProblem, problemResponse } from './problem.js';
|
|
30
|
+
import { Refusal } from './validate.js';
|
|
31
|
+
import { createAuth } from './auth.js';
|
|
32
|
+
import { EventHub } from './events.js';
|
|
33
|
+
import { readLimited, storeApi } from './store-api.js';
|
|
34
|
+
const gzipAsync = promisify(gzip);
|
|
35
|
+
const brotliAsync = promisify(brotliCompress);
|
|
36
|
+
const TYPES = {
|
|
37
|
+
html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8', json: 'application/json', map: 'application/json',
|
|
38
|
+
svg: 'image/svg+xml', png: 'image/png', webp: 'image/webp', ico: 'image/x-icon', woff2: 'font/woff2', woff: 'font/woff', txt: 'text/plain; charset=utf-8', wasm: 'application/wasm'
|
|
39
|
+
};
|
|
40
|
+
const typeOf = (file) => TYPES[file.split('.').at(-1)?.toLowerCase() ?? ''] ?? 'application/octet-stream';
|
|
41
|
+
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/;
|
|
42
|
+
const MiB = 1024 * 1024;
|
|
43
|
+
/**
|
|
44
|
+
* The whole of a project's Atlas on one process: the API that results are uploaded to, the live report every
|
|
45
|
+
* viewer is shown, and the viewer itself. All of it is under one host, `/api` for the API and `/` for the page.
|
|
46
|
+
* What it knows lives in its backends (by default a folder on its own disk), so replacing it loses nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function createAtlasServer(options) {
|
|
49
|
+
const now = options.now ?? Date.now;
|
|
50
|
+
const backends = options.backends;
|
|
51
|
+
const writeBackend = backends.find(backend => backend.id === options.write);
|
|
52
|
+
if (!writeBackend)
|
|
53
|
+
throw new Error(`The write backend "${options.write}" is not among the backends (${backends.map(backend => backend.id).join(', ')})`);
|
|
54
|
+
const stores = backends.map(backend => backend.store);
|
|
55
|
+
const auth = createAuth(options.tokens ?? [], options.anonymousRead ?? false);
|
|
56
|
+
const limits = { object: (options.limits?.maxObjectMiB ?? 256) * MiB, json: (options.limits?.maxJsonMiB ?? 32) * MiB };
|
|
57
|
+
const index = new ReportIndex(backends, { now, ...(options.include ? { include: options.include } : {}) });
|
|
58
|
+
const events = new EventHub({ maxSubscribers: options.limits?.maxStreams ?? 500, heartbeatMs: options.heartbeatMs ?? 15_000 });
|
|
59
|
+
const compressed = new Map();
|
|
60
|
+
const assets = new Map();
|
|
61
|
+
// Spec sets and runs change rarely: answer from memory, look again in the background, and look at once when
|
|
62
|
+
// something that could change them was uploaded. `invalidate()` resolves when the fresh answer is in place, so
|
|
63
|
+
// viewers are told to read again only when there is something new to read.
|
|
64
|
+
function cached(load, ttlMs = 10_000) {
|
|
65
|
+
let value;
|
|
66
|
+
let at = 0;
|
|
67
|
+
let started = 0;
|
|
68
|
+
let applied = 0;
|
|
69
|
+
const reload = async () => {
|
|
70
|
+
const mine = ++started;
|
|
71
|
+
at = now();
|
|
72
|
+
try {
|
|
73
|
+
const result = await load();
|
|
74
|
+
// Loads can finish out of order: only the newest one started may replace what is held.
|
|
75
|
+
if (mine > applied) {
|
|
76
|
+
applied = mine;
|
|
77
|
+
value = Promise.resolve(result);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { /* what is held stays */ }
|
|
81
|
+
};
|
|
82
|
+
const read = (() => {
|
|
83
|
+
if (!value) {
|
|
84
|
+
const mine = ++started;
|
|
85
|
+
at = now();
|
|
86
|
+
value = load();
|
|
87
|
+
value.then(() => { applied = Math.max(applied, mine); }, () => { value = undefined; });
|
|
88
|
+
}
|
|
89
|
+
else if (now() - at > ttlMs) {
|
|
90
|
+
at = now();
|
|
91
|
+
void reload();
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
});
|
|
95
|
+
read.invalidate = reload;
|
|
96
|
+
return read;
|
|
97
|
+
}
|
|
98
|
+
const specSets = new Map();
|
|
99
|
+
const specs = cached(() => latestSpecSet(stores, options.outlineBranch, specSets));
|
|
100
|
+
const lastRuns = new Map();
|
|
101
|
+
const runCatalog = stores.map(() => new Map());
|
|
102
|
+
const runs = cached(async () => {
|
|
103
|
+
const lists = await Promise.all(stores.map(async (store, i) => {
|
|
104
|
+
try {
|
|
105
|
+
const rows = await listRunSummaries(store, 100, runCatalog[i]);
|
|
106
|
+
lastRuns.set(i, rows);
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return lastRuns.get(i) ?? [];
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
const byId = new Map(lists.flat().map(run => [run.id, run]));
|
|
114
|
+
return [...byId.values()].sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt) || (a.id < b.id ? -1 : 1));
|
|
115
|
+
});
|
|
116
|
+
const outline = async () => {
|
|
117
|
+
const found = await specs();
|
|
118
|
+
if (!found)
|
|
119
|
+
return { specs: [], sha: null, changedFiles: [], readSpec: async () => null };
|
|
120
|
+
const entries = found.set.files.map(({ path: file, domain, kind, title, fingerprint }) => ({ path: file, domain, kind, title, fingerprint }));
|
|
121
|
+
return { specs: entries, sha: found.set.sha, changedFiles: [], readSpec: async (file) => {
|
|
122
|
+
const entry = found.set.files.find(item => item.path === file);
|
|
123
|
+
return entry ? readSpecBody(found.store, entry.digest) : null;
|
|
124
|
+
} };
|
|
125
|
+
};
|
|
126
|
+
const layers = createLayersApp({
|
|
127
|
+
store: null, backends: backends.map(backend => ({ id: backend.id, label: backend.label ?? backend.id, store: backend.store })),
|
|
128
|
+
clock: { now }, timers: { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle), setInterval: (fn, ms) => setInterval(fn, ms), clearInterval: handle => clearInterval(handle) },
|
|
129
|
+
outline,
|
|
130
|
+
...(options.include ? { include: options.include } : {})
|
|
131
|
+
});
|
|
132
|
+
// What every viewer is told when the report changes: small, and enough to say what arrived.
|
|
133
|
+
const summaryOf = (change) => ({
|
|
134
|
+
version: change.version,
|
|
135
|
+
totalLayers: change.totalLayers,
|
|
136
|
+
added: change.added.slice(0, 20).map(layer => ({ id: layer.id, producer: layer.producer, environment: layer.environment, branch: layer.branch, sha: layer.sha, recordedAtMs: layer.recordedAtMs, counts: layer.counts, ...(layer.freshness ? { freshness: layer.freshness } : {}) })),
|
|
137
|
+
addedTotal: change.added.length
|
|
138
|
+
});
|
|
139
|
+
const stopListening = index.onChange(change => events.publish('report', summaryOf(change)));
|
|
140
|
+
/**
|
|
141
|
+
* A response, compressed when the client accepts it and it is worth it. What is the same for everyone (a page,
|
|
142
|
+
* a script, one version of the report) is compressed once, however many ask at the same moment, and kept; `slow`
|
|
143
|
+
* asks for the smallest form, which is worth waiting for only when it is made once and sent to every visitor:
|
|
144
|
+
* the viewer's own files, which are kept for as long as the server runs.
|
|
145
|
+
*/
|
|
146
|
+
async function body(c, data, contentType, headers, cacheKey, slow = false) {
|
|
147
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
148
|
+
const accepts = c.req.header('accept-encoding') ?? '';
|
|
149
|
+
const encoding = COMPRESSIBLE.test(contentType) && bytes.length > 1024 ? (slow && /\bbr\b/.test(accepts) ? 'br' : /\bgzip\b/.test(accepts) ? 'gzip' : null) : null;
|
|
150
|
+
let out = bytes;
|
|
151
|
+
if (encoding) {
|
|
152
|
+
const key = cacheKey ? `${encoding}|${cacheKey}|${bytes.length}` : null;
|
|
153
|
+
const kept = slow ? assets : compressed;
|
|
154
|
+
let packing = key ? kept.get(key) : undefined;
|
|
155
|
+
if (!packing) {
|
|
156
|
+
packing = (encoding === 'br'
|
|
157
|
+
? brotliAsync(bytes, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: bytes.length } })
|
|
158
|
+
: gzipAsync(bytes)).then(packed => new Uint8Array(packed));
|
|
159
|
+
if (key) {
|
|
160
|
+
if (kept.size >= (slow ? 512 : 64))
|
|
161
|
+
kept.delete(kept.keys().next().value);
|
|
162
|
+
kept.set(key, packing);
|
|
163
|
+
packing.catch(() => kept.delete(key));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
out = await packing;
|
|
167
|
+
}
|
|
168
|
+
return new Response(out, { headers: { 'content-type': contentType, vary: 'accept-encoding', ...(encoding ? { 'content-encoding': encoding } : {}), ...headers } });
|
|
169
|
+
}
|
|
170
|
+
const app = new Hono();
|
|
171
|
+
const unauthorized = (verdict) => problemResponse(verdict.status, verdict.message, verdict.status === 401 ? { 'www-authenticate': 'Basic realm="Atlas", charset="UTF-8"' } : {});
|
|
172
|
+
// Everything but the probes and the store protocol (which checks for itself) needs at least a reader.
|
|
173
|
+
app.use('*', async (c, next) => {
|
|
174
|
+
const started = now();
|
|
175
|
+
const pathname = new URL(c.req.url).pathname;
|
|
176
|
+
const open = pathname === '/healthz' || pathname === '/readyz' || pathname === '/llms.txt' || pathname === '/api/openapi.json' || pathname.startsWith('/api/store');
|
|
177
|
+
if (!open) {
|
|
178
|
+
const verdict = auth.check(c.req.raw, 'read');
|
|
179
|
+
if (!verdict.ok) {
|
|
180
|
+
c.res = unauthorized(verdict);
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
await next();
|
|
184
|
+
}
|
|
185
|
+
else
|
|
186
|
+
await next();
|
|
187
|
+
// Every error, whichever handler made it, leaves as a problem document.
|
|
188
|
+
if (c.res.status >= 400)
|
|
189
|
+
c.res = await asProblem(c.res);
|
|
190
|
+
if (pathname !== '/healthz' && pathname !== '/readyz')
|
|
191
|
+
options.log?.({ at: new Date(started).toISOString(), method: c.req.method, path: pathname, status: c.res.status, ms: now() - started });
|
|
192
|
+
});
|
|
193
|
+
app.get('/healthz', c => c.json({ ok: true }));
|
|
194
|
+
// What an agent handed only this server's address reads first: how to use it, and the machine-readable API.
|
|
195
|
+
app.get('/llms.txt', c => {
|
|
196
|
+
const forwarded = c.req.header('x-forwarded-host');
|
|
197
|
+
const origin = forwarded ? `${c.req.header('x-forwarded-proto') ?? 'https'}://${forwarded}` : new URL(c.req.url).origin;
|
|
198
|
+
return c.text(llmsTxt({ url: origin, open: auth.open, write: options.write }), 200, { 'cache-control': 'no-cache' });
|
|
199
|
+
});
|
|
200
|
+
app.get('/api/openapi.json', c => {
|
|
201
|
+
const forwarded = c.req.header('x-forwarded-host');
|
|
202
|
+
const serverUrl = forwarded ? `${c.req.header('x-forwarded-proto') ?? 'https'}://${forwarded}` : undefined;
|
|
203
|
+
return c.json(openApiDocument(serverUrl ? { serverUrl } : {}), 200, { 'cache-control': 'no-cache' });
|
|
204
|
+
});
|
|
205
|
+
app.get('/readyz', c => {
|
|
206
|
+
const snapshot = index.snapshot();
|
|
207
|
+
const up = snapshot.backends.some(backend => backend.available);
|
|
208
|
+
return c.json({ ready: index.ready && up, ...index.stats(), backends: snapshot.backends.map(({ id, available }) => ({ id, available })) }, index.ready && up ? 200 : 503);
|
|
209
|
+
});
|
|
210
|
+
// Who is asking, and what this server is: for the viewer's header, and for `atlas report doctor`.
|
|
211
|
+
app.get('/api/server', c => {
|
|
212
|
+
const identity = auth.identify(c.req.raw);
|
|
213
|
+
const snapshot = index.snapshot();
|
|
214
|
+
return c.json({
|
|
215
|
+
name: 'atlas', version: options.version ?? null, role: identity?.role ?? 'read', open: auth.open,
|
|
216
|
+
write: options.write, ready: index.ready, viewers: events.count, time: now(),
|
|
217
|
+
backends: snapshot.backends.map(({ id, label, available }) => ({ id, label, available, write: id === options.write })),
|
|
218
|
+
...(({ backends: _unused, ...rest }) => rest)(index.stats()), capabilities: { events: true, ingest: true, store: true, checks: true, status: true, effective: true }
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
// Live updates: told when the report changes, and when specs or runs do.
|
|
222
|
+
app.get('/api/events', c => events.stream(c.req.raw, () => ({ version: index.snapshot().version, ready: index.ready, time: now() })));
|
|
223
|
+
// The report as it stands: one request, only what wins. Everything else about a layer is history.
|
|
224
|
+
app.get('/api/reports/evidence/effective', async (c) => {
|
|
225
|
+
if (!index.ready)
|
|
226
|
+
return c.json({ error: 'Atlas is still reading its backends; try again in a moment.' }, 503, { 'retry-after': '1' });
|
|
227
|
+
const { version, text } = index.json();
|
|
228
|
+
const etag = `"${version}"`;
|
|
229
|
+
const headers = { etag, 'cache-control': 'no-cache' };
|
|
230
|
+
if (c.req.header('if-none-match') === etag)
|
|
231
|
+
return new Response(null, { status: 304, headers });
|
|
232
|
+
// A viewer that holds a recent report is sent what changed since, not all of it again.
|
|
233
|
+
const since = c.req.query('since');
|
|
234
|
+
const delta = since ? index.delta(since) : null;
|
|
235
|
+
if (delta)
|
|
236
|
+
return body(c, delta.text, 'application/json', headers);
|
|
237
|
+
return body(c, text, 'application/json', headers, `effective|${version}`);
|
|
238
|
+
});
|
|
239
|
+
app.route('/api/reports', layers);
|
|
240
|
+
// Upload: the store protocol, and a simple JSON way in for anything that can make a request.
|
|
241
|
+
app.route('/api/store', storeApi({
|
|
242
|
+
store: writeBackend.store, auth, maxObjectBytes: limits.object,
|
|
243
|
+
onStored: async (key, kind, layer) => {
|
|
244
|
+
if (kind === 'layer-catalog')
|
|
245
|
+
await index.accept(writeBackend.id, key, layer);
|
|
246
|
+
else if (kind === 'spec-catalog') {
|
|
247
|
+
await specs.invalidate();
|
|
248
|
+
events.publish('atlas:change', { scope: 'specs', files: [], runIds: [] });
|
|
249
|
+
}
|
|
250
|
+
else if (kind === 'run-catalog') {
|
|
251
|
+
await runs.invalidate();
|
|
252
|
+
events.publish('atlas:change', { scope: 'runs', files: [], runIds: [/-([\w.-]+)\.json$/.exec(key)?.[1] ?? ''].filter(Boolean) });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}));
|
|
256
|
+
app.post('/api/checks', async (c) => {
|
|
257
|
+
const verdict = auth.check(c.req.raw, 'publish');
|
|
258
|
+
if (!verdict.ok)
|
|
259
|
+
return unauthorized(verdict);
|
|
260
|
+
try {
|
|
261
|
+
const bytes = await readLimited(c.req.raw, limits.json);
|
|
262
|
+
let input;
|
|
263
|
+
try {
|
|
264
|
+
input = JSON.parse(new TextDecoder().decode(bytes));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new Refusal(422, 'The body is not JSON');
|
|
268
|
+
}
|
|
269
|
+
// Anything that can make a request can report: the time defaults to now, and the rest is the layer format.
|
|
270
|
+
const layer = await publishChecks(writeBackend.store, { recordedAtMs: now(), ...input });
|
|
271
|
+
await index.accept(writeBackend.id, layerCatalogKey(layer.recordedAtMs, layer.id), layer);
|
|
272
|
+
return c.json({ ok: true, id: layer.id, checks: layer.parts[0]?.checks.length ?? 0 }, 201);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error instanceof Refusal)
|
|
276
|
+
return c.json({ error: error.message }, error.status);
|
|
277
|
+
if (error instanceof Error && /^publishChecks:/.test(error.message))
|
|
278
|
+
return c.json({ error: error.message }, 422);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
// The gate: does the report pass? Computed here, from what the server already holds.
|
|
283
|
+
const filtered = new Map();
|
|
284
|
+
const verdicts = new Map();
|
|
285
|
+
app.get('/api/status', async (c) => {
|
|
286
|
+
if (!index.ready)
|
|
287
|
+
return c.json({ error: 'Atlas is still reading its backends; try again in a moment.' }, 503, { 'retry-after': '1' });
|
|
288
|
+
const branches = c.req.queries('branch') ?? [];
|
|
289
|
+
const ignoreModified = c.req.query('ignoreModified') === '1';
|
|
290
|
+
const required = (c.req.query('requireEnvironments') ?? '').split(',').map(name => name.trim()).filter(Boolean);
|
|
291
|
+
const requireAllSpecs = c.req.query('requireAllSpecs') === '1';
|
|
292
|
+
const snapshot = index.snapshot();
|
|
293
|
+
const found = await outline();
|
|
294
|
+
// The gate is a function of the report and the specs, so it is worked out once for each pair, however often CI asks.
|
|
295
|
+
const asked = `${snapshot.version}|${(await specs())?.set.id ?? ''}|${[...branches].sort().join(',')}|${ignoreModified}|${required.join(',')}|${requireAllSpecs}`;
|
|
296
|
+
if (!verdicts.has(asked)) {
|
|
297
|
+
if (verdicts.size >= 16)
|
|
298
|
+
verdicts.delete(verdicts.keys().next().value);
|
|
299
|
+
verdicts.set(asked, (async () => {
|
|
300
|
+
let report;
|
|
301
|
+
if (!branches.length && !ignoreModified) {
|
|
302
|
+
const combined = combineEvidence(found, snapshot.layers);
|
|
303
|
+
report = { combined, layers: snapshot.layers.map(entry => entry.layer), backends: snapshot.backends, notices: snapshot.notices, summary: summarize(combined) };
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
// Narrowing needs the history the live report has dropped: read it, once per version and filter.
|
|
307
|
+
const key = `${snapshot.version}|${[...branches].sort().join(',')}|${ignoreModified}`;
|
|
308
|
+
if (!filtered.has(key)) {
|
|
309
|
+
if (filtered.size >= 8)
|
|
310
|
+
filtered.delete(filtered.keys().next().value);
|
|
311
|
+
filtered.set(key, combineBackends(backends, { outline: found, include: layer => (options.include?.(layer) ?? true) && (!branches.length || (layer.branch !== null && branches.includes(layer.branch))) && (!ignoreModified || layer.freshness === undefined) }));
|
|
312
|
+
}
|
|
313
|
+
report = await filtered.get(key);
|
|
314
|
+
}
|
|
315
|
+
const status = reportStatus(report, { requireEnvironments: required, requireAllSpecs });
|
|
316
|
+
return { text: JSON.stringify({ ...status, layers: snapshot.totalLayers }), ok: status.ok };
|
|
317
|
+
})());
|
|
318
|
+
verdicts.get(asked).catch(() => verdicts.delete(asked));
|
|
319
|
+
}
|
|
320
|
+
const { text, ok } = await verdicts.get(asked);
|
|
321
|
+
return body(c, text, 'application/json', { 'cache-control': 'no-cache', 'x-atlas-ok': String(ok) }, `status|${asked}`);
|
|
322
|
+
});
|
|
323
|
+
// The viewer's data directory: specs and runs, read from the backends.
|
|
324
|
+
app.get('/api/data/sources.json', c => c.json({ schemaVersion: 1, sources: [{ id: 'workspace', label: 'Atlas', type: 'http', baseUrl: './', eventsUrl: '../events', live: true }] }, 200, { 'cache-control': 'no-cache' }));
|
|
325
|
+
app.get('/api/data/specs/index.json', async (c) => {
|
|
326
|
+
const found = await specs();
|
|
327
|
+
return c.json({ schemaVersion: 1, generatedAt: new Date(now()).toISOString(), ref: found?.set.sha ?? null, files: (found?.set.files ?? []).map(file => file.path.replace(/^specs\//, '')) }, 200, { 'cache-control': 'no-cache' });
|
|
328
|
+
});
|
|
329
|
+
app.get('/api/data/specs/*', async (c) => {
|
|
330
|
+
let file;
|
|
331
|
+
try {
|
|
332
|
+
file = decodeURIComponent(new URL(c.req.url).pathname.slice('/api/data/specs/'.length));
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return c.notFound();
|
|
336
|
+
}
|
|
337
|
+
const found = await specs();
|
|
338
|
+
const entry = found?.set.files.find(item => item.path === `specs/${file}`);
|
|
339
|
+
const text = found && entry ? await readSpecBody(found.store, entry.digest) : null;
|
|
340
|
+
if (text === null)
|
|
341
|
+
return c.notFound();
|
|
342
|
+
// The address is the spec's path, which a later set can fill with other text: revalidate, by content.
|
|
343
|
+
const etag = `"${entry.digest}"`;
|
|
344
|
+
if (c.req.header('if-none-match') === etag)
|
|
345
|
+
return new Response(null, { status: 304, headers: { etag, 'cache-control': 'no-cache' } });
|
|
346
|
+
return body(c, text, 'text/plain; charset=utf-8', { 'cache-control': 'no-cache', etag }, entry.digest);
|
|
347
|
+
});
|
|
348
|
+
app.get('/api/data/runs/index.json', async (c) => c.json({ generatedAt: new Date(now()).toISOString(), runs: await runs() }, 200, { 'cache-control': 'no-cache' }));
|
|
349
|
+
app.get('/api/data/runs/:id/*', async (c) => {
|
|
350
|
+
const id = c.req.param('id');
|
|
351
|
+
let rel;
|
|
352
|
+
try {
|
|
353
|
+
rel = decodeURIComponent(new URL(c.req.url).pathname.slice(`/api/data/runs/${id}/`.length));
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return c.notFound();
|
|
357
|
+
}
|
|
358
|
+
for (const store of stores) {
|
|
359
|
+
const stored = await readRunFile(store, id, rel).catch(() => null);
|
|
360
|
+
if (stored)
|
|
361
|
+
return new Response(stored.body, { headers: { 'content-type': stored.contentType, ...UNTRUSTED_HEADERS, 'cache-control': 'public, max-age=31536000, immutable' } });
|
|
362
|
+
}
|
|
363
|
+
return c.notFound();
|
|
364
|
+
});
|
|
365
|
+
// Everything else under /api is for reading: a write to it is a wrong method, not a missing page.
|
|
366
|
+
app.on(['POST', 'PUT', 'PATCH', 'DELETE'], '/api/*', c => c.json({ error: 'Method not allowed' }, 405, { allow: 'GET, HEAD' }));
|
|
367
|
+
// The viewer, with this server's address book written into its page.
|
|
368
|
+
if (options.site) {
|
|
369
|
+
const root = path.resolve(options.site);
|
|
370
|
+
const config = JSON.stringify({ api: './api', reportsUrl: './api/reports', dataUrl: './api/data/', eventsUrl: './api/events', effective: true, ...(options.page ?? {}) });
|
|
371
|
+
app.get('*', async (c) => {
|
|
372
|
+
const pathname = new URL(c.req.url).pathname;
|
|
373
|
+
// An API path that nothing answered is an error, not a page.
|
|
374
|
+
if (pathname.startsWith('/api/'))
|
|
375
|
+
return c.json({ error: 'Not found' }, 404);
|
|
376
|
+
let relative;
|
|
377
|
+
try {
|
|
378
|
+
relative = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return c.notFound();
|
|
382
|
+
}
|
|
383
|
+
if (relative.split('/').some(part => part === '..' || part.includes('\\')))
|
|
384
|
+
return c.notFound();
|
|
385
|
+
let file = path.join(root, relative || 'index.html');
|
|
386
|
+
let info = await stat(file).catch(() => null);
|
|
387
|
+
if (info?.isDirectory()) {
|
|
388
|
+
file = path.join(file, 'index.html');
|
|
389
|
+
info = await stat(file).catch(() => null);
|
|
390
|
+
}
|
|
391
|
+
if (!info && !path.extname(relative)) {
|
|
392
|
+
file = path.join(root, 'index.html');
|
|
393
|
+
info = await stat(file).catch(() => null);
|
|
394
|
+
}
|
|
395
|
+
if (!info?.isFile())
|
|
396
|
+
return c.notFound();
|
|
397
|
+
const isIndex = path.basename(file) === 'index.html';
|
|
398
|
+
let data = new Uint8Array(await readFile(file));
|
|
399
|
+
if (isIndex)
|
|
400
|
+
data = new TextDecoder().decode(data).replace('</head>', `<script>window.__ATLAS_CONFIG__=${config}</script></head>`);
|
|
401
|
+
const hashed = /(^|\/)assets\//.test(relative);
|
|
402
|
+
return body(c, data, typeOf(file), { 'cache-control': hashed ? 'public, max-age=31536000, immutable' : 'no-cache' }, `${file}|${info.mtimeMs}`, true);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// An API path that nothing answered is an error a client can read, whether or not a viewer is served.
|
|
406
|
+
app.notFound(c => (new URL(c.req.url).pathname.startsWith('/api/') ? c.json({ error: 'Not found' }, 404) : c.text('404 Not Found', 404)));
|
|
407
|
+
app.onError((error, c) => {
|
|
408
|
+
options.log?.({ at: new Date(now()).toISOString(), level: 'error', path: new URL(c.req.url).pathname, error: error instanceof Error ? error.message : String(error) });
|
|
409
|
+
return c.json({ error: 'Something went wrong on the server. It has been logged.' }, 500);
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
fetch: async (request) => app.fetch(request),
|
|
413
|
+
routes: app.routes.map(({ method, path }) => ({ method, path })),
|
|
414
|
+
index,
|
|
415
|
+
events,
|
|
416
|
+
ready: index.start(options.refreshMs ?? 10_000),
|
|
417
|
+
close: () => { stopListening(); index.stop(); events.close(); }
|
|
418
|
+
};
|
|
419
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Authentication and roles
|
|
3
|
+
*
|
|
4
|
+
* Who is asking, and may they. Tokens have a role (`read` < `publish` < `admin`) and are compared in constant time. Reading
|
|
5
|
+
* accepts a Bearer token or the browser's sign-in box (any user name, the token as the password); changing anything needs
|
|
6
|
+
* the Bearer header, because a browser sends the sign-in box's credentials to any page that asks, which would let another
|
|
7
|
+
* site upload with them. A server with no tokens is open, and refuses to listen beyond this machine unless told it may.
|
|
8
|
+
*/
|
|
9
|
+
import type { ServerRole } from '../../schema/src/server-config.js';
|
|
10
|
+
import type { ResolvedToken } from './config.js';
|
|
11
|
+
/** Who a request is: the token's `name` (never the token), its `role`, and how it was presented (`bearer`, `basic`, or `none` for an open server or an anonymous reader). */
|
|
12
|
+
export type Identity = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly role: ServerRole;
|
|
15
|
+
/** `bearer` for a token in a header, `basic` for the browser's sign-in box, `none` for a server with no tokens. */
|
|
16
|
+
readonly via: 'bearer' | 'basic' | 'none';
|
|
17
|
+
};
|
|
18
|
+
/** The answer to "may this request do that": allowed with the identity, or refused with the status to answer (`401` no or unknown token, `403` a known token that may not) and a message that says what to do. */
|
|
19
|
+
export type Verdict = {
|
|
20
|
+
readonly ok: true;
|
|
21
|
+
readonly identity: Identity;
|
|
22
|
+
} | {
|
|
23
|
+
readonly ok: false;
|
|
24
|
+
readonly status: 401 | 403;
|
|
25
|
+
readonly message: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Who is asking, and may they. A server with no tokens is open (and refuses to listen beyond this machine
|
|
29
|
+
* unless told it may). With tokens, every request must carry one, except where `anonymousRead` lets anyone look.
|
|
30
|
+
*
|
|
31
|
+
* Changing anything needs a token in an `Authorization: Bearer` header, never the browser's sign-in box: the
|
|
32
|
+
* browser sends that box's credentials on its own to any page that asks, which would let another site upload
|
|
33
|
+
* with them.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createAuth(tokens: readonly ResolvedToken[], anonymousRead: boolean): {
|
|
36
|
+
identify: (request: Request) => Identity | null;
|
|
37
|
+
check: (request: Request, need: ServerRole) => Verdict;
|
|
38
|
+
open: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** The authentication of one server: `identify` (who is this), `check` (may they, for a needed role) and `open` (the server has no tokens). */
|
|
41
|
+
export type Auth = ReturnType<typeof createAuth>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Authentication and roles
|
|
3
|
+
*
|
|
4
|
+
* Who is asking, and may they. Tokens have a role (`read` < `publish` < `admin`) and are compared in constant time. Reading
|
|
5
|
+
* accepts a Bearer token or the browser's sign-in box (any user name, the token as the password); changing anything needs
|
|
6
|
+
* the Bearer header, because a browser sends the sign-in box's credentials to any page that asks, which would let another
|
|
7
|
+
* site upload with them. A server with no tokens is open, and refuses to listen beyond this machine unless told it may.
|
|
8
|
+
*/
|
|
9
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
10
|
+
const RANK = { read: 1, publish: 2, admin: 3 };
|
|
11
|
+
function same(a, b) {
|
|
12
|
+
const left = Buffer.from(a), right = Buffer.from(b);
|
|
13
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Who is asking, and may they. A server with no tokens is open (and refuses to listen beyond this machine
|
|
17
|
+
* unless told it may). With tokens, every request must carry one, except where `anonymousRead` lets anyone look.
|
|
18
|
+
*
|
|
19
|
+
* Changing anything needs a token in an `Authorization: Bearer` header, never the browser's sign-in box: the
|
|
20
|
+
* browser sends that box's credentials on its own to any page that asks, which would let another site upload
|
|
21
|
+
* with them.
|
|
22
|
+
*/
|
|
23
|
+
export function createAuth(tokens, anonymousRead) {
|
|
24
|
+
function identify(request) {
|
|
25
|
+
if (!tokens.length)
|
|
26
|
+
return { name: 'open', role: 'admin', via: 'none' };
|
|
27
|
+
const header = request.headers.get('authorization') ?? '';
|
|
28
|
+
const lower = header.toLowerCase();
|
|
29
|
+
let presented = null;
|
|
30
|
+
let via = 'bearer';
|
|
31
|
+
if (lower.startsWith('bearer '))
|
|
32
|
+
presented = header.slice(7).trim();
|
|
33
|
+
else if (lower.startsWith('basic ')) {
|
|
34
|
+
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
|
35
|
+
presented = decoded.slice(decoded.indexOf(':') + 1);
|
|
36
|
+
via = 'basic';
|
|
37
|
+
}
|
|
38
|
+
if (presented) {
|
|
39
|
+
const found = tokens.find(token => same(token.token, presented));
|
|
40
|
+
if (found)
|
|
41
|
+
return { name: found.name, role: found.role, via };
|
|
42
|
+
}
|
|
43
|
+
return anonymousRead ? { name: 'anonymous', role: 'read', via: 'none' } : null;
|
|
44
|
+
}
|
|
45
|
+
function check(request, need) {
|
|
46
|
+
const identity = identify(request);
|
|
47
|
+
if (!identity)
|
|
48
|
+
return { ok: false, status: 401, message: 'A token is needed. Send it as Authorization: Bearer <token>, or sign in with it as the password.' };
|
|
49
|
+
// Someone who showed no token is asked for one; someone whose token is too small is told so.
|
|
50
|
+
if (identity.name === 'anonymous' && need !== 'read')
|
|
51
|
+
return { ok: false, status: 401, message: 'A token is needed to upload. Send it as Authorization: Bearer <token>.' };
|
|
52
|
+
if (RANK[identity.role] < RANK[need])
|
|
53
|
+
return { ok: false, status: 403, message: `This token is "${identity.role}" and this needs "${need}".` };
|
|
54
|
+
if (need !== 'read' && identity.via === 'basic')
|
|
55
|
+
return { ok: false, status: 403, message: 'Uploading needs the token in an Authorization: Bearer header.' };
|
|
56
|
+
return { ok: true, identity };
|
|
57
|
+
}
|
|
58
|
+
return { identify, check, open: tokens.length === 0 };
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Server configuration
|
|
3
|
+
*
|
|
4
|
+
* Loads how the server runs: `atlas.server.json`, then environment variables, then flags, each overriding the last, checked
|
|
5
|
+
* against `ServerConfigSchema` and resolved (paths made absolute, backends named, tokens read). Every mistake is a
|
|
6
|
+
* `ConfigError` that says what to change, and a server that would listen beyond this machine with no tokens refuses to
|
|
7
|
+
* start. `SERVER_SETTINGS` documents every setting once; the table in docs/server.md is generated from it and a test proves
|
|
8
|
+
* each row.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/server.md#configuration
|
|
11
|
+
*/
|
|
12
|
+
import { type ServerConfig, type ServerRole } from '../../schema/src/server-config.js';
|
|
13
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
14
|
+
/** A token as the server holds it: the `name` it is shown under in logs, its `role`, and the secret `token`. */
|
|
15
|
+
export type ResolvedToken = {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly role: ServerRole;
|
|
18
|
+
readonly token: string;
|
|
19
|
+
};
|
|
20
|
+
/** A backend as configured: its `id`, the `label` people see, and the `url` (a folder, `s3://…`, or another server) to open. */
|
|
21
|
+
export type BackendSpec = {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly label: string;
|
|
24
|
+
readonly url: string;
|
|
25
|
+
};
|
|
26
|
+
/** The configuration the server runs with: the file, then the environment, then the flags, checked and resolved. */
|
|
27
|
+
export type ResolvedServerConfig = Omit<ServerConfig, 'backends' | 'auth' | 'write'> & {
|
|
28
|
+
/** The folder that is the default backend, absolute. */
|
|
29
|
+
readonly data: string;
|
|
30
|
+
/** Every backend, the data folder first. All are read; uploads go to `writeId`. */
|
|
31
|
+
readonly backends: readonly BackendSpec[];
|
|
32
|
+
readonly writeId: string;
|
|
33
|
+
readonly tokens: readonly ResolvedToken[];
|
|
34
|
+
readonly anonymousRead: boolean;
|
|
35
|
+
readonly insecure: boolean;
|
|
36
|
+
/** Where the config came from, for the first line of the log. */
|
|
37
|
+
readonly configFile: string | null;
|
|
38
|
+
};
|
|
39
|
+
/** A configuration that cannot be used. The message says what to change; `atlas serve` prints it and exits 2. */
|
|
40
|
+
export declare class ConfigError extends Error {
|
|
41
|
+
}
|
|
42
|
+
/** Every flag `atlas serve` accepts, with its `--`. */
|
|
43
|
+
export declare const SERVER_FLAGS: readonly string[];
|
|
44
|
+
/** One setting of the server as an operator meets it: the config key, the flag, the environment variables, and what it does. */
|
|
45
|
+
export type ServerSetting = {
|
|
46
|
+
/** What it sets, in words. */
|
|
47
|
+
readonly summary: string;
|
|
48
|
+
/** The key in `atlas.server.json` (dotted for nested keys). */
|
|
49
|
+
readonly key: string;
|
|
50
|
+
/** The command-line flag, with its value placeholder, or null when there is none. */
|
|
51
|
+
readonly flag: string | null;
|
|
52
|
+
/** The environment variables, the first that is set wins; empty when there are none. */
|
|
53
|
+
readonly env: readonly string[];
|
|
54
|
+
/** What it is when nothing sets it. */
|
|
55
|
+
readonly default: string;
|
|
56
|
+
/**
|
|
57
|
+
* How the documentation is held to the code: giving `value` through the environment variable (or the flag) must make
|
|
58
|
+
* `read` of the resolved configuration equal `expected`. A test runs every setting's example both ways.
|
|
59
|
+
*/
|
|
60
|
+
readonly example: {
|
|
61
|
+
readonly value: string;
|
|
62
|
+
readonly read: (config: ResolvedServerConfig) => unknown;
|
|
63
|
+
readonly expected: unknown;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Every setting that can be given by flag or environment, documented once. `docs/server.md` renders its table from
|
|
68
|
+
* this list, and a test proves each row: its example, through the flag and through the environment, changes the
|
|
69
|
+
* configuration as the row says. A setting added to the loader without a row here is therefore not documented in the
|
|
70
|
+
* table; one documented here that the loader ignores fails the test.
|
|
71
|
+
*/
|
|
72
|
+
export declare const SERVER_SETTINGS: readonly ServerSetting[];
|
|
73
|
+
/** The text `atlas serve --help` prints. Also the source of the `atlas serve` section of `docs/reference/cli.md`. */
|
|
74
|
+
export declare const SERVER_USAGE = "atlas serve: the Atlas server\n\n atlas serve [--config atlas.server.json] [--data <dir>] [--port 8080] [--host 127.0.0.1]\n [--store <url|id=url>]... [--write <id>] [--site <dir>]\n [--outline-branch <b>] [--branch <name>]... [--ignore-modified] [--refresh <seconds>]\n [--anonymous-read] [--insecure] [--no-log]\n\n One process serves the viewer and the API. Test results are uploaded to it from anywhere (laptops, CI) and it\n shows everyone the combined report, live. It keeps its data in --data (default ./atlas-data) and can read more\n backends (--store): S3, other servers, folders. Uploads go to the data folder unless --write names another.\n\n Tokens (ATLAS_TOKEN admin, ATLAS_PUBLISH_TOKEN, ATLAS_READ_TOKEN, or \"auth.tokens\" in the config) are required\n to listen beyond this machine unless --insecure. Every option has an environment variable (docs/deploying.md).\n";
|
|
75
|
+
/**
|
|
76
|
+
* Read the file named by `--config`/`ATLAS_CONFIG`, or `atlas.server.json` in the folder if there is one, lay the
|
|
77
|
+
* environment and then the flags over it, and check the result. Every problem is a {@link ConfigError} that says
|
|
78
|
+
* what to change.
|
|
79
|
+
*/
|
|
80
|
+
export declare function loadServerConfig(argv: readonly string[], env: Env, cwd: string, read?: (file: string) => string): ResolvedServerConfig;
|
|
81
|
+
/** A server that anyone on the network can upload to must say who may. */
|
|
82
|
+
export declare function assertSafeToListen(config: Pick<ResolvedServerConfig, 'host' | 'tokens' | 'insecure'>): void;
|
|
83
|
+
export {};
|