@crvouga/atlas 0.4.0 → 0.6.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 +13 -1
- package/README.md +191 -611
- 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 +35 -1
- package/dist/core/src/config.d.ts +77 -4
- package/dist/core/src/config.js +110 -17
- 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 +75 -16
- package/dist/core/src/run/execute.js +71 -18
- 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 +85 -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 +35 -5
- package/dist/reports/src/evidence-live.js +18 -2
- package/dist/reports/src/evidence-types.d.ts +68 -1
- package/dist/reports/src/evidence-types.js +5 -1
- package/dist/reports/src/evidence-view.d.ts +43 -4
- package/dist/reports/src/evidence-view.js +13 -2
- 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 +56 -0
- package/dist/reports/src/http-store.js +148 -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 +369 -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 +35 -5
- package/dist/reports/src/layer-axes.js +25 -5
- package/dist/reports/src/layer-catalog.d.ts +28 -1
- package/dist/reports/src/layer-catalog.js +18 -1
- package/dist/reports/src/layer-merge.d.ts +98 -9
- package/dist/reports/src/layer-merge.js +147 -31
- package/dist/reports/src/layer-source.d.ts +18 -0
- package/dist/reports/src/layer-source.js +11 -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 +32 -1
- package/dist/reports/src/layers-route.d.ts +47 -4
- package/dist/reports/src/layers-route.js +86 -7
- package/dist/reports/src/live-reporter.d.ts +34 -0
- package/dist/reports/src/live-reporter.js +122 -0
- 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 +26 -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 +42 -9
- package/dist/reports/src/publish.js +36 -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 +486 -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 +37 -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/runners/src/codecept.d.ts +14 -0
- package/dist/runners/src/codecept.js +16 -0
- package/dist/runners/src/jest.d.ts +50 -0
- package/dist/runners/src/jest.js +52 -0
- package/dist/runners/src/mocha.d.ts +40 -0
- package/dist/runners/src/mocha.js +39 -0
- package/dist/runners/src/node-test.d.ts +28 -0
- package/dist/runners/src/node-test.js +44 -0
- package/dist/runners/src/publish.d.ts +30 -0
- package/dist/runners/src/publish.js +68 -0
- package/dist/schema/src/api.d.ts +800 -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 +16 -0
- package/dist/schema/src/index.js +16 -0
- package/dist/schema/src/layer.d.ts +2273 -0
- package/dist/schema/src/layer.js +462 -0
- package/dist/schema/src/live-run.d.ts +123 -0
- package/dist/schema/src/live-run.js +30 -0
- package/dist/schema/src/manifest.d.ts +97 -2
- package/dist/schema/src/manifest.js +84 -0
- package/dist/schema/src/project.d.ts +20 -0
- package/dist/schema/src/project.js +10 -0
- package/dist/schema/src/report-sources.d.ts +29 -0
- package/dist/schema/src/report-sources.js +27 -1
- package/dist/schema/src/runs-index.d.ts +17 -1
- package/dist/schema/src/runs-index.js +15 -0
- package/dist/schema/src/server-config.d.ts +177 -0
- package/dist/schema/src/server-config.js +85 -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 +72 -0
- package/dist/server/src/app.js +557 -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 +204 -0
- package/dist/server/src/dev.d.ts +12 -0
- package/dist/server/src/dev.js +89 -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 +18 -0
- package/dist/server/src/index.js +18 -0
- package/dist/server/src/live-runs.d.ts +52 -0
- package/dist/server/src/live-runs.js +229 -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 +114 -0
- package/dist/server/src/openapi.d.ts +56 -0
- package/dist/server/src/openapi.js +438 -0
- package/dist/server/src/problem.d.ts +8 -0
- package/dist/server/src/problem.js +39 -0
- package/dist/server/src/projects.d.ts +3 -0
- package/dist/server/src/projects.js +154 -0
- package/dist/server/src/settings-page.d.ts +9 -0
- package/dist/server/src/settings-page.js +93 -0
- package/dist/server/src/settings.d.ts +23 -0
- package/dist/server/src/settings.js +72 -0
- package/dist/server/src/site.d.ts +15 -0
- package/dist/server/src/site.js +78 -0
- package/dist/server/src/store-api.d.ts +28 -0
- package/dist/server/src/store-api.js +180 -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 +148 -0
- package/docs/reference/core.md +2432 -0
- package/docs/reference/detox.md +150 -0
- package/docs/reference/http-api.md +799 -0
- package/docs/reference/playwright.md +303 -0
- package/docs/reference/reports.md +7152 -0
- package/docs/reference/runners.md +169 -0
- package/docs/reference/schema.md +1762 -0
- package/docs/reference/server.md +825 -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/runners.md +69 -0
- package/docs/server.md +321 -0
- package/docs/statecharts.md +643 -0
- package/llms-full.txt +4114 -0
- package/llms.txt +49 -0
- package/package.json +61 -11
- package/packages/core/README.md +2 -1
- package/packages/reports/README.md +65 -0
- package/packages/reports/STORAGE.md +4 -0
- package/packages/runners/README.md +6 -0
- package/packages/runners/compat/codecept.cjs +2 -0
- package/packages/runners/compat/jest.cjs +2 -0
- package/packages/runners/compat/mocha.cjs +2 -0
- package/packages/schema/README.md +14 -0
- package/packages/schema/schemas/atlas-change.schema.json +4 -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 +26 -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/live-run-start.schema.json +42 -0
- package/packages/schema/schemas/live-run-update.schema.json +93 -0
- package/packages/schema/schemas/live-run.schema.json +225 -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/project-slug.schema.json +8 -0
- package/packages/schema/schemas/project.schema.json +22 -0
- package/packages/schema/schemas/projects.schema.json +33 -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 +208 -0
- package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
- package/packages/schema/schemas/server-info.schema.json +114 -0
- package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -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 +11021 -0
- package/packages/visualizer/README.md +32 -11
- package/packages/visualizer/bin/atlas-client.mjs +176 -0
- package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
- package/packages/visualizer/src/app/AppShell.tsx +32 -16
- package/packages/visualizer/src/app/report-sources.tsx +7 -4
- package/packages/visualizer/src/app/router.tsx +3 -3
- package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
- package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
- package/packages/visualizer/src/components/Header.module.css +101 -192
- package/packages/visualizer/src/components/Header.tsx +88 -13
- package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
- package/packages/visualizer/src/data/adapters/adapter.ts +5 -17
- package/packages/visualizer/src/data/adapters/index.ts +10 -1
- package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
- package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
- package/packages/visualizer/src/data/queries.ts +14 -7
- package/packages/visualizer/src/evidence/api.ts +16 -3
- 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 +21 -4
- package/packages/visualizer/src/evidence/document-page.tsx +6 -2
- package/packages/visualizer/src/evidence/evidence-context.tsx +79 -12
- package/packages/visualizer/src/evidence/evidence.module.css +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
- package/packages/visualizer/src/evidence/model-page.tsx +6 -2
- package/packages/visualizer/src/evidence/project-home.tsx +45 -0
- package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
- package/packages/visualizer/src/evidence/spec-page.tsx +23 -16
- package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
- package/packages/visualizer/src/find/Finder.tsx +1 -7
- package/packages/visualizer/src/find/open.ts +4 -0
- package/packages/visualizer/src/live/live-query.ts +21 -0
- package/packages/visualizer/src/live/live-runs.tsx +69 -0
- package/packages/visualizer/src/live/live-stream.ts +225 -0
- package/packages/visualizer/src/live/report-progress.ts +62 -0
- package/packages/visualizer/src/live/test-progress.tsx +34 -0
- package/packages/visualizer/src/runtime.ts +35 -0
- package/packages/visualizer/src/state/ui-store.ts +3 -2
- package/packages/visualizer/src/views/ChartPage.tsx +2 -0
- package/packages/visualizer/src/views/ChartTests.tsx +43 -0
- package/packages/visualizer/src/views/SidePanel.tsx +2 -0
- package/packages/visualizer/vite/atlas-files.test.ts +4 -4
- package/packages/visualizer/vite/atlas-files.ts +1 -5
- package/packages/visualizer/vite/atlas-server-proxy.ts +37 -0
- package/packages/visualizer/vite.config.ts +11 -3
|
@@ -0,0 +1,557 @@
|
|
|
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 { createHash } from 'node:crypto';
|
|
13
|
+
import { readFile, stat, realpath } from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
import { brotliCompress, constants as zlibConstants, gzip } from 'node:zlib';
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
import { UNTRUSTED_HEADERS } from '../../reports/src/attachment-response.js';
|
|
19
|
+
import { combineBackends, summarize } from '../../reports/src/compose.js';
|
|
20
|
+
import { combineEvidence } from '../../reports/src/layer-merge.js';
|
|
21
|
+
import { layerCatalogKey } from '../../reports/src/layer-store.js';
|
|
22
|
+
import { createLayersApp } from '../../reports/src/layers-route.js';
|
|
23
|
+
import { publishChecks } from '../../reports/src/publish-checks.js';
|
|
24
|
+
import { reportStatus } from '../../reports/src/report-status.js';
|
|
25
|
+
import { ReportIndex } from '../../reports/src/report-index.js';
|
|
26
|
+
import { listRunSummaries, readRunFile } from '../../reports/src/run-bundle.js';
|
|
27
|
+
import { latestSpecSet, readSpecBody } from '../../reports/src/spec-set.js';
|
|
28
|
+
import { llmsTxt } from './llms.js';
|
|
29
|
+
import { openApiDocument } from './openapi.js';
|
|
30
|
+
import { asProblem, problemResponse } from './problem.js';
|
|
31
|
+
import { LiveRunStartSchema, LiveRunUpdateSchema } from '../../schema/src/live-run.js';
|
|
32
|
+
import { LiveRuns, LiveRunError } from './live-runs.js';
|
|
33
|
+
import { ConfigError } from './config.js';
|
|
34
|
+
import { renderSettingsPage } from './settings.js';
|
|
35
|
+
import { Refusal } from './validate.js';
|
|
36
|
+
import { createAuth } from './auth.js';
|
|
37
|
+
import { EventHub } from './events.js';
|
|
38
|
+
import { projectServer } from './projects.js';
|
|
39
|
+
import { readLimited, storeApi } from './store-api.js';
|
|
40
|
+
const gzipAsync = promisify(gzip);
|
|
41
|
+
const brotliAsync = promisify(brotliCompress);
|
|
42
|
+
const TYPES = {
|
|
43
|
+
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',
|
|
44
|
+
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'
|
|
45
|
+
};
|
|
46
|
+
const typeOf = (file) => TYPES[file.split('.').at(-1)?.toLowerCase() ?? ''] ?? 'application/octet-stream';
|
|
47
|
+
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/;
|
|
48
|
+
const MiB = 1024 * 1024;
|
|
49
|
+
/**
|
|
50
|
+
* The whole of a project's Atlas on one process: the API that results are uploaded to, the live report every
|
|
51
|
+
* viewer is shown, and the viewer itself. All of it is under one host, `/api` for the API and `/` for the page.
|
|
52
|
+
* What it knows lives in its backends (by default a folder on its own disk), so replacing it loses nothing.
|
|
53
|
+
*/
|
|
54
|
+
export function createAtlasServer(options) {
|
|
55
|
+
return projectServer(options, createProjectApplication);
|
|
56
|
+
}
|
|
57
|
+
function createProjectApplication(options) {
|
|
58
|
+
const now = options.now ?? Date.now;
|
|
59
|
+
const backends = options.backends;
|
|
60
|
+
const writeBackend = backends.find(backend => backend.id === options.write);
|
|
61
|
+
if (!writeBackend)
|
|
62
|
+
throw new Error(`The write backend "${options.write}" is not among the backends (${backends.map(backend => backend.id).join(', ')})`);
|
|
63
|
+
const stores = backends.map(backend => backend.store);
|
|
64
|
+
const auth = createAuth(options.tokens ?? [], options.anonymousRead ?? false);
|
|
65
|
+
const limits = { object: (options.limits?.maxObjectMiB ?? 256) * MiB, json: (options.limits?.maxJsonMiB ?? 32) * MiB };
|
|
66
|
+
const index = new ReportIndex(backends, { now, ...(options.include ? { include: options.include } : {}) });
|
|
67
|
+
const events = new EventHub({ maxSubscribers: options.limits?.maxStreams ?? 500, heartbeatMs: options.heartbeatMs ?? 15_000 });
|
|
68
|
+
const live = new LiveRuns({ store: options.liveStore ?? writeBackend.store, now, timeoutMs: options.runTimeoutMs ?? 30_000, stalledMs: options.runStallMs ?? 900_000,
|
|
69
|
+
onChange: run => {
|
|
70
|
+
events.publish('run', run);
|
|
71
|
+
events.publish('atlas:change', { scope: 'runs', files: [], runIds: [], liveRunId: run.id });
|
|
72
|
+
},
|
|
73
|
+
onLayer: (key, layer) => index.accept(writeBackend.id, key, layer) });
|
|
74
|
+
const leaseTimer = setInterval(() => { void live.sweep().catch(error => options.log?.({ level: 'error', error: String(error) })); }, Math.min(5000, options.runTimeoutMs ?? 30_000));
|
|
75
|
+
leaseTimer.unref?.();
|
|
76
|
+
const compressed = new Map();
|
|
77
|
+
const assets = new Map();
|
|
78
|
+
// Spec sets and runs change rarely: answer from memory, look again in the background, and look at once when
|
|
79
|
+
// something that could change them was uploaded. `invalidate()` resolves when the fresh answer is in place, so
|
|
80
|
+
// viewers are told to read again only when there is something new to read.
|
|
81
|
+
function cached(load, ttlMs = 10_000) {
|
|
82
|
+
let value;
|
|
83
|
+
let at = 0;
|
|
84
|
+
let started = 0;
|
|
85
|
+
let applied = 0;
|
|
86
|
+
const reload = async () => {
|
|
87
|
+
const mine = ++started;
|
|
88
|
+
at = now();
|
|
89
|
+
try {
|
|
90
|
+
const result = await load();
|
|
91
|
+
// Loads can finish out of order: only the newest one started may replace what is held.
|
|
92
|
+
if (mine > applied) {
|
|
93
|
+
applied = mine;
|
|
94
|
+
value = Promise.resolve(result);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* what is held stays */ }
|
|
98
|
+
};
|
|
99
|
+
const read = (() => {
|
|
100
|
+
if (!value) {
|
|
101
|
+
const mine = ++started;
|
|
102
|
+
at = now();
|
|
103
|
+
value = load();
|
|
104
|
+
value.then(() => { applied = Math.max(applied, mine); }, () => { value = undefined; });
|
|
105
|
+
}
|
|
106
|
+
else if (now() - at > ttlMs) {
|
|
107
|
+
at = now();
|
|
108
|
+
void reload();
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
});
|
|
112
|
+
read.invalidate = reload;
|
|
113
|
+
return read;
|
|
114
|
+
}
|
|
115
|
+
const specSets = new Map();
|
|
116
|
+
const specs = cached(() => latestSpecSet(stores, options.outlineBranch, specSets));
|
|
117
|
+
const lastRuns = new Map();
|
|
118
|
+
const runCatalog = stores.map(() => new Map());
|
|
119
|
+
const runs = cached(async () => {
|
|
120
|
+
const lists = await Promise.all(stores.map(async (store, i) => {
|
|
121
|
+
try {
|
|
122
|
+
const rows = await listRunSummaries(store, 100, runCatalog[i]);
|
|
123
|
+
lastRuns.set(i, rows);
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return lastRuns.get(i) ?? [];
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
const byId = new Map(lists.flat().map(run => [run.id, run]));
|
|
131
|
+
return [...byId.values()].sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt) || (a.id < b.id ? -1 : 1));
|
|
132
|
+
});
|
|
133
|
+
const outline = async () => {
|
|
134
|
+
const found = await specs();
|
|
135
|
+
if (!found)
|
|
136
|
+
return { specs: [], sha: null, changedFiles: [], readSpec: async () => null };
|
|
137
|
+
const entries = found.set.files.map(({ path: file, domain, kind, title, fingerprint }) => ({ path: file, domain, kind, title, fingerprint }));
|
|
138
|
+
return { specs: entries, sha: found.set.sha, changedFiles: [], readSpec: async (file) => {
|
|
139
|
+
const entry = found.set.files.find(item => item.path === file);
|
|
140
|
+
return entry ? readSpecBody(found.store, entry.digest) : null;
|
|
141
|
+
} };
|
|
142
|
+
};
|
|
143
|
+
const layers = createLayersApp({
|
|
144
|
+
store: null, backends: backends.map(backend => ({ id: backend.id, label: backend.label ?? backend.id, store: backend.store })),
|
|
145
|
+
clock: { now }, timers: { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle), setInterval: (fn, ms) => setInterval(fn, ms), clearInterval: handle => clearInterval(handle) },
|
|
146
|
+
outline,
|
|
147
|
+
...(options.include ? { include: options.include } : {})
|
|
148
|
+
});
|
|
149
|
+
// What every viewer is told when the report changes: small, and enough to say what arrived.
|
|
150
|
+
const summaryOf = (change) => ({
|
|
151
|
+
version: change.version,
|
|
152
|
+
totalLayers: change.totalLayers,
|
|
153
|
+
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 } : {}) })),
|
|
154
|
+
addedTotal: change.added.length
|
|
155
|
+
});
|
|
156
|
+
const stopListening = index.onChange(change => events.publish('report', summaryOf(change)));
|
|
157
|
+
/**
|
|
158
|
+
* A response, compressed when the client accepts it and it is worth it. What is the same for everyone (a page,
|
|
159
|
+
* a script, one version of the report) is compressed once, however many ask at the same moment, and kept; `slow`
|
|
160
|
+
* asks for the smallest form, which is worth waiting for only when it is made once and sent to every visitor:
|
|
161
|
+
* the viewer's own files, which are kept for as long as the server runs.
|
|
162
|
+
*/
|
|
163
|
+
async function body(c, data, contentType, headers, cacheKey, slow = false) {
|
|
164
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
165
|
+
const accepts = c.req.header('accept-encoding') ?? '';
|
|
166
|
+
const qualities = new Map(accepts.toLowerCase().split(',').map(part => {
|
|
167
|
+
const [name, ...parameters] = part.trim().split(';');
|
|
168
|
+
const q = parameters.map(parameter => /^\s*q\s*=\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*$/.exec(parameter)).find(Boolean);
|
|
169
|
+
return [name, q ? Number(q[1]) : parameters.some(parameter => /^\s*q\s*=/.test(parameter)) ? 0 : 1];
|
|
170
|
+
}));
|
|
171
|
+
const quality = (name) => qualities.get(name) ?? qualities.get('*') ?? 0;
|
|
172
|
+
const encoding = COMPRESSIBLE.test(contentType) && bytes.length > 1024
|
|
173
|
+
? slow && quality('br') > 0 && quality('br') >= quality('gzip') ? 'br' : quality('gzip') > 0 ? 'gzip' : null
|
|
174
|
+
: null;
|
|
175
|
+
let out = bytes;
|
|
176
|
+
if (encoding) {
|
|
177
|
+
const key = cacheKey ? `${encoding}|${cacheKey}|${bytes.length}` : null;
|
|
178
|
+
const kept = slow ? assets : compressed;
|
|
179
|
+
let packing = key ? kept.get(key) : undefined;
|
|
180
|
+
if (!packing) {
|
|
181
|
+
packing = (encoding === 'br'
|
|
182
|
+
? brotliAsync(bytes, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: bytes.length } })
|
|
183
|
+
: gzipAsync(bytes)).then(packed => new Uint8Array(packed));
|
|
184
|
+
if (key) {
|
|
185
|
+
if (kept.size >= (slow ? 512 : 64))
|
|
186
|
+
kept.delete(kept.keys().next().value);
|
|
187
|
+
kept.set(key, packing);
|
|
188
|
+
packing.catch(() => kept.delete(key));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
out = await packing;
|
|
192
|
+
}
|
|
193
|
+
return new Response(out, { headers: { 'content-type': contentType, vary: 'accept-encoding', ...(encoding ? { 'content-encoding': encoding } : {}), ...headers } });
|
|
194
|
+
}
|
|
195
|
+
const app = new Hono();
|
|
196
|
+
const unauthorized = (verdict) => problemResponse(verdict.status, verdict.message, verdict.status === 401 ? { 'www-authenticate': 'Basic realm="Atlas", charset="UTF-8"' } : {});
|
|
197
|
+
// Everything but the probes and the store protocol (which checks for itself) needs at least a reader.
|
|
198
|
+
app.use('*', async (c, next) => {
|
|
199
|
+
const started = now();
|
|
200
|
+
const pathname = new URL(c.req.url).pathname;
|
|
201
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method)) {
|
|
202
|
+
const origin = c.req.header('origin');
|
|
203
|
+
const site = c.req.header('sec-fetch-site');
|
|
204
|
+
// Browsers identify same-origin requests even when a TLS proxy forwards HTTP to Atlas.
|
|
205
|
+
if (site === 'cross-site' || (origin && origin !== new URL(c.req.url).origin && site !== 'same-origin'))
|
|
206
|
+
return problemResponse(403, 'Changes must come from this server origin.');
|
|
207
|
+
}
|
|
208
|
+
const open = pathname === '/healthz' || pathname === '/readyz' || pathname === '/llms.txt' || pathname === '/api/openapi.json' || pathname.startsWith('/api/store');
|
|
209
|
+
if (!open) {
|
|
210
|
+
const verdict = auth.check(c.req.raw, 'read');
|
|
211
|
+
if (!verdict.ok) {
|
|
212
|
+
c.res = unauthorized(verdict);
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
await next();
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
await next();
|
|
219
|
+
// Every error, whichever handler made it, leaves as a problem document.
|
|
220
|
+
if (c.res.status >= 400) {
|
|
221
|
+
c.res = await asProblem(c.res);
|
|
222
|
+
c.res.headers.set('cache-control', 'no-store');
|
|
223
|
+
}
|
|
224
|
+
if (!auth.open && c.res.headers.get('cache-control')?.startsWith('public'))
|
|
225
|
+
c.res.headers.set('cache-control', c.res.headers.get('cache-control').replace(/^public/, 'private'));
|
|
226
|
+
if (pathname !== '/healthz' && pathname !== '/readyz')
|
|
227
|
+
options.log?.({ at: new Date(started).toISOString(), method: c.req.method, path: pathname, status: c.res.status, ms: now() - started });
|
|
228
|
+
});
|
|
229
|
+
app.get('/healthz', c => c.json({ ok: true }));
|
|
230
|
+
// What an agent handed only this server's address reads first: how to use it, and the machine-readable API.
|
|
231
|
+
app.get('/llms.txt', c => {
|
|
232
|
+
const forwarded = c.req.header('x-forwarded-host');
|
|
233
|
+
const origin = forwarded ? `${c.req.header('x-forwarded-proto') ?? 'https'}://${forwarded}` : new URL(c.req.url).origin;
|
|
234
|
+
return c.text(llmsTxt({ url: origin, open: auth.open, write: options.write }), 200, { 'cache-control': 'no-cache' });
|
|
235
|
+
});
|
|
236
|
+
app.get('/api/openapi.json', c => {
|
|
237
|
+
const forwarded = c.req.header('x-forwarded-host');
|
|
238
|
+
const serverUrl = forwarded ? `${c.req.header('x-forwarded-proto') ?? 'https'}://${forwarded}` : undefined;
|
|
239
|
+
return c.json(openApiDocument(serverUrl ? { serverUrl } : {}), 200, { 'cache-control': 'no-cache' });
|
|
240
|
+
});
|
|
241
|
+
app.get('/readyz', c => {
|
|
242
|
+
const snapshot = index.snapshot();
|
|
243
|
+
const up = snapshot.backends.some(backend => backend.available);
|
|
244
|
+
return c.json({ ready: index.ready && up, ...index.stats(), backends: snapshot.backends.map(({ id, available }) => ({ id, available })) }, index.ready && up ? 200 : 503);
|
|
245
|
+
});
|
|
246
|
+
// Who is asking, and what this server is: for the viewer's header, and for `atlas report doctor`.
|
|
247
|
+
app.get('/api/server', c => {
|
|
248
|
+
const identity = auth.identify(c.req.raw);
|
|
249
|
+
const snapshot = index.snapshot();
|
|
250
|
+
return c.json({
|
|
251
|
+
name: 'atlas', version: options.version ?? null, role: identity?.role ?? 'read', open: auth.open,
|
|
252
|
+
write: options.write, ready: index.ready, viewers: events.count, time: now(),
|
|
253
|
+
backends: snapshot.backends.map(({ id, label, available }) => ({ id, label, available, write: id === options.write })),
|
|
254
|
+
...(({ backends: _unused, ...rest }) => rest)(index.stats()), capabilities: { events: true, ingest: true, store: true, checks: true, status: true, effective: true, live: true }
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
app.get('/api/live', async (c) => c.json({ runs: await live.list() }, 200, { 'cache-control': 'no-store' }));
|
|
258
|
+
const writeLive = async (c, action) => {
|
|
259
|
+
const verdict = auth.check(c.req.raw, 'publish');
|
|
260
|
+
if (!verdict.ok)
|
|
261
|
+
return unauthorized(verdict);
|
|
262
|
+
const owner = createHash('sha256').update(c.req.header('authorization') ?? 'open').digest('hex');
|
|
263
|
+
const id = c.req.param('id');
|
|
264
|
+
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
265
|
+
return problemResponse(422, 'Invalid live run id.');
|
|
266
|
+
try {
|
|
267
|
+
if (action === 'heartbeat') {
|
|
268
|
+
await live.heartbeat(id, owner);
|
|
269
|
+
return c.json({ ok: true });
|
|
270
|
+
}
|
|
271
|
+
const bytes = await readLimited(c.req.raw, Math.min(limits.json, 2 * MiB));
|
|
272
|
+
let input;
|
|
273
|
+
try {
|
|
274
|
+
input = JSON.parse(new TextDecoder().decode(bytes));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return problemResponse(422, 'The body is not JSON.');
|
|
278
|
+
}
|
|
279
|
+
const schema = action === 'start' ? LiveRunStartSchema : LiveRunUpdateSchema;
|
|
280
|
+
const parsed = schema.safeParse(input);
|
|
281
|
+
if (!parsed.success)
|
|
282
|
+
return problemResponse(422, parsed.error.message);
|
|
283
|
+
if (action === 'start') {
|
|
284
|
+
const start = LiveRunStartSchema.parse(parsed.data);
|
|
285
|
+
if (start.id !== id)
|
|
286
|
+
return problemResponse(422, 'Run ids must match.');
|
|
287
|
+
return c.json(await live.start(start, owner), 201);
|
|
288
|
+
}
|
|
289
|
+
return c.json(await live.update(id, LiveRunUpdateSchema.parse(parsed.data), owner));
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (error instanceof LiveRunError)
|
|
293
|
+
return problemResponse(error.status, error.message);
|
|
294
|
+
if (error instanceof Refusal)
|
|
295
|
+
return problemResponse(error.status, error.message);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
app.post('/api/live/:id', c => writeLive(c, 'start'));
|
|
300
|
+
app.patch('/api/live/:id', c => writeLive(c, 'update'));
|
|
301
|
+
app.post('/api/live/:id/heartbeat', c => writeLive(c, 'heartbeat'));
|
|
302
|
+
app.get('/admin', async (c) => c.html(await renderSettingsPage(), 200, { 'cache-control': 'no-store', 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff' }));
|
|
303
|
+
app.get('/api/settings', async (c) => {
|
|
304
|
+
const verdict = auth.check(c.req.raw, 'admin');
|
|
305
|
+
if (!verdict.ok)
|
|
306
|
+
return unauthorized(verdict);
|
|
307
|
+
if (!options.settings)
|
|
308
|
+
return problemResponse(503, 'Settings are managed by the embedding application.');
|
|
309
|
+
return c.json(await options.settings.read(), 200, { 'cache-control': 'no-store' });
|
|
310
|
+
});
|
|
311
|
+
app.put('/api/settings', async (c) => {
|
|
312
|
+
const verdict = auth.check(c.req.raw, 'admin');
|
|
313
|
+
if (!verdict.ok)
|
|
314
|
+
return unauthorized(verdict);
|
|
315
|
+
if (!options.settings)
|
|
316
|
+
return problemResponse(503, 'Settings are managed by the embedding application.');
|
|
317
|
+
const revision = /^"([a-f0-9]{64})"$/.exec(c.req.header('if-match') ?? '')?.[1];
|
|
318
|
+
if (!revision)
|
|
319
|
+
return problemResponse(428, 'Load settings first and send their revision in If-Match.');
|
|
320
|
+
try {
|
|
321
|
+
const bytes = await readLimited(c.req.raw, Math.min(limits.json, 64 * 1024));
|
|
322
|
+
let input;
|
|
323
|
+
try {
|
|
324
|
+
input = JSON.parse(new TextDecoder().decode(bytes));
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return problemResponse(422, 'The body is not JSON.');
|
|
328
|
+
}
|
|
329
|
+
return c.json(await options.settings.save(input, revision), 200, { 'cache-control': 'no-store' });
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (error instanceof ConfigError)
|
|
333
|
+
return problemResponse(error.message.startsWith('Settings changed') ? 409 : 422, error.message);
|
|
334
|
+
if (error instanceof Refusal)
|
|
335
|
+
return problemResponse(error.status, error.message);
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
// Live updates: told when the report changes, and when specs or runs do.
|
|
340
|
+
app.get('/api/events', c => events.stream(c.req.raw, () => ({ version: index.snapshot().version, ready: index.ready, time: now(), heartbeatMs: options.heartbeatMs ?? 15_000 })));
|
|
341
|
+
// The report as it stands: one request, only what wins. Everything else about a layer is history.
|
|
342
|
+
app.get('/api/reports/evidence/effective', async (c) => {
|
|
343
|
+
if (!index.ready)
|
|
344
|
+
return c.json({ error: 'Atlas is still reading its backends; try again in a moment.' }, 503, { 'retry-after': '1' });
|
|
345
|
+
const { version, text } = index.json();
|
|
346
|
+
const etag = `"${version}"`;
|
|
347
|
+
const headers = { etag, 'cache-control': 'no-cache' };
|
|
348
|
+
if (c.req.header('if-none-match') === etag)
|
|
349
|
+
return new Response(null, { status: 304, headers });
|
|
350
|
+
// A viewer that holds a recent report is sent what changed since, not all of it again.
|
|
351
|
+
const since = c.req.query('since');
|
|
352
|
+
const delta = since ? index.delta(since) : null;
|
|
353
|
+
if (delta)
|
|
354
|
+
return body(c, delta.text, 'application/json', headers);
|
|
355
|
+
return body(c, text, 'application/json', headers, `effective|${version}`);
|
|
356
|
+
});
|
|
357
|
+
app.route('/api/reports', layers);
|
|
358
|
+
// Upload: the store protocol, and a simple JSON way in for anything that can make a request.
|
|
359
|
+
app.route('/api/store', storeApi({
|
|
360
|
+
store: writeBackend.store, auth, maxObjectBytes: limits.object,
|
|
361
|
+
onStored: async (key, kind, layer) => {
|
|
362
|
+
if (kind === 'layer-catalog')
|
|
363
|
+
await index.accept(writeBackend.id, key, layer);
|
|
364
|
+
else if (kind === 'spec-catalog') {
|
|
365
|
+
await specs.invalidate();
|
|
366
|
+
events.publish('atlas:change', { scope: 'specs', files: [], runIds: [] });
|
|
367
|
+
}
|
|
368
|
+
else if (kind === 'run-catalog') {
|
|
369
|
+
await runs.invalidate();
|
|
370
|
+
events.publish('atlas:change', { scope: 'runs', files: [], runIds: [/-([\w.-]+)\.json$/.exec(key)?.[1] ?? ''].filter(Boolean) });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}));
|
|
374
|
+
app.post('/api/checks', async (c) => {
|
|
375
|
+
const verdict = auth.check(c.req.raw, 'publish');
|
|
376
|
+
if (!verdict.ok)
|
|
377
|
+
return unauthorized(verdict);
|
|
378
|
+
try {
|
|
379
|
+
const bytes = await readLimited(c.req.raw, limits.json);
|
|
380
|
+
let input;
|
|
381
|
+
try {
|
|
382
|
+
input = JSON.parse(new TextDecoder().decode(bytes));
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
throw new Refusal(422, 'The body is not JSON');
|
|
386
|
+
}
|
|
387
|
+
// Anything that can make a request can report: the time defaults to now, and the rest is the layer format.
|
|
388
|
+
const layer = await publishChecks(writeBackend.store, { recordedAtMs: now(), ...input });
|
|
389
|
+
await index.accept(writeBackend.id, layerCatalogKey(layer.recordedAtMs, layer.id), layer);
|
|
390
|
+
return c.json({ ok: true, id: layer.id, checks: layer.parts[0]?.checks.length ?? 0 }, 201);
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (error instanceof Refusal)
|
|
394
|
+
return c.json({ error: error.message }, error.status);
|
|
395
|
+
if (error instanceof Error && /^publishChecks:/.test(error.message))
|
|
396
|
+
return c.json({ error: error.message }, 422);
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
// The gate: does the report pass? Computed here, from what the server already holds.
|
|
401
|
+
const filtered = new Map();
|
|
402
|
+
const verdicts = new Map();
|
|
403
|
+
app.get('/api/status', async (c) => {
|
|
404
|
+
if (!index.ready)
|
|
405
|
+
return c.json({ error: 'Atlas is still reading its backends; try again in a moment.' }, 503, { 'retry-after': '1' });
|
|
406
|
+
const branches = c.req.queries('branch') ?? [];
|
|
407
|
+
const ignoreModified = c.req.query('ignoreModified') === '1';
|
|
408
|
+
const required = (c.req.query('requireEnvironments') ?? '').split(',').map(name => name.trim()).filter(Boolean);
|
|
409
|
+
const requireAllSpecs = c.req.query('requireAllSpecs') === '1';
|
|
410
|
+
const snapshot = index.snapshot();
|
|
411
|
+
const found = await outline();
|
|
412
|
+
// The gate is a function of the report and the specs, so it is worked out once for each pair, however often CI asks.
|
|
413
|
+
const asked = `${snapshot.version}|${(await specs())?.set.id ?? ''}|${[...branches].sort().join(',')}|${ignoreModified}|${required.join(',')}|${requireAllSpecs}`;
|
|
414
|
+
if (!verdicts.has(asked)) {
|
|
415
|
+
if (verdicts.size >= 16)
|
|
416
|
+
verdicts.delete(verdicts.keys().next().value);
|
|
417
|
+
verdicts.set(asked, (async () => {
|
|
418
|
+
let report;
|
|
419
|
+
if (!branches.length && !ignoreModified) {
|
|
420
|
+
const combined = combineEvidence(found, snapshot.layers);
|
|
421
|
+
report = { combined, layers: snapshot.layers.map(entry => entry.layer), backends: snapshot.backends, notices: snapshot.notices, summary: summarize(combined) };
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
// Narrowing needs the history the live report has dropped: read it, once per version and filter.
|
|
425
|
+
const key = `${snapshot.version}|${[...branches].sort().join(',')}|${ignoreModified}`;
|
|
426
|
+
if (!filtered.has(key)) {
|
|
427
|
+
if (filtered.size >= 8)
|
|
428
|
+
filtered.delete(filtered.keys().next().value);
|
|
429
|
+
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) }));
|
|
430
|
+
}
|
|
431
|
+
report = await filtered.get(key);
|
|
432
|
+
}
|
|
433
|
+
const status = reportStatus(report, { requireEnvironments: required, requireAllSpecs });
|
|
434
|
+
return { text: JSON.stringify({ ...status, layers: snapshot.totalLayers }), ok: status.ok };
|
|
435
|
+
})());
|
|
436
|
+
verdicts.get(asked).catch(() => verdicts.delete(asked));
|
|
437
|
+
}
|
|
438
|
+
const { text, ok } = await verdicts.get(asked);
|
|
439
|
+
return body(c, text, 'application/json', { 'cache-control': 'no-cache', 'x-atlas-ok': String(ok) }, `status|${asked}`);
|
|
440
|
+
});
|
|
441
|
+
// The viewer's data directory: specs and runs, read from the backends.
|
|
442
|
+
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' }));
|
|
443
|
+
app.get('/api/data/specs/index.json', async (c) => {
|
|
444
|
+
const found = await specs();
|
|
445
|
+
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' });
|
|
446
|
+
});
|
|
447
|
+
app.get('/api/data/specs/*', async (c) => {
|
|
448
|
+
let file;
|
|
449
|
+
try {
|
|
450
|
+
file = decodeURIComponent(new URL(c.req.url).pathname.slice('/api/data/specs/'.length));
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return c.notFound();
|
|
454
|
+
}
|
|
455
|
+
const found = await specs();
|
|
456
|
+
const entry = found?.set.files.find(item => item.path === `specs/${file}`);
|
|
457
|
+
const text = found && entry ? await readSpecBody(found.store, entry.digest) : null;
|
|
458
|
+
if (text === null)
|
|
459
|
+
return c.notFound();
|
|
460
|
+
// The address is the spec's path, which a later set can fill with other text: revalidate, by content.
|
|
461
|
+
const etag = `"${entry.digest}"`;
|
|
462
|
+
if (c.req.header('if-none-match') === etag)
|
|
463
|
+
return new Response(null, { status: 304, headers: { etag, 'cache-control': 'no-cache' } });
|
|
464
|
+
return body(c, text, 'text/plain; charset=utf-8', { 'cache-control': 'no-cache', etag }, entry.digest);
|
|
465
|
+
});
|
|
466
|
+
app.get('/api/data/runs/index.json', async (c) => c.json({ generatedAt: new Date(now()).toISOString(), runs: await runs() }, 200, { 'cache-control': 'no-cache' }));
|
|
467
|
+
app.get('/api/data/runs/:id/*', async (c) => {
|
|
468
|
+
const id = c.req.param('id');
|
|
469
|
+
let rel;
|
|
470
|
+
try {
|
|
471
|
+
rel = decodeURIComponent(new URL(c.req.url).pathname.slice(`/api/data/runs/${id}/`.length));
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return c.notFound();
|
|
475
|
+
}
|
|
476
|
+
for (const store of stores) {
|
|
477
|
+
const stored = await readRunFile(store, id, rel).catch(() => null);
|
|
478
|
+
if (stored)
|
|
479
|
+
return new Response(stored.body, { headers: { 'content-type': stored.contentType, ...UNTRUSTED_HEADERS, 'cache-control': 'public, max-age=31536000, immutable' } });
|
|
480
|
+
}
|
|
481
|
+
return c.notFound();
|
|
482
|
+
});
|
|
483
|
+
// Everything else under /api is for reading: a write to it is a wrong method, not a missing page.
|
|
484
|
+
app.on(['POST', 'PUT', 'PATCH', 'DELETE'], '/api/*', c => c.json({ error: 'Method not allowed' }, 405, { allow: 'GET, HEAD' }));
|
|
485
|
+
// The viewer, with this server's address book written into its page.
|
|
486
|
+
if (options.site) {
|
|
487
|
+
const root = path.resolve(options.site);
|
|
488
|
+
const config = JSON.stringify({ api: './api', reportsUrl: './api/reports', dataUrl: './api/data/', eventsUrl: './api/events', projectsUrl: './api/projects', effective: true, ...(options.page ?? {}) }).replaceAll('<', '\\u003c');
|
|
489
|
+
const pageDigest = createHash('sha256').update(config).digest('hex').slice(0, 16);
|
|
490
|
+
const files = new Map();
|
|
491
|
+
app.get('*', async (c) => {
|
|
492
|
+
const pathname = new URL(c.req.url).pathname;
|
|
493
|
+
// An API path that nothing answered is an error, not a page.
|
|
494
|
+
if (pathname.startsWith('/api/'))
|
|
495
|
+
return c.json({ error: 'Not found' }, 404);
|
|
496
|
+
let relative;
|
|
497
|
+
try {
|
|
498
|
+
relative = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return c.notFound();
|
|
502
|
+
}
|
|
503
|
+
if (relative.split('/').some(part => part === '..' || part.includes('\\')))
|
|
504
|
+
return c.notFound();
|
|
505
|
+
let file = path.join(root, relative || 'index.html');
|
|
506
|
+
let info = await stat(file).catch(() => null);
|
|
507
|
+
if (info?.isDirectory()) {
|
|
508
|
+
file = path.join(file, 'index.html');
|
|
509
|
+
info = await stat(file).catch(() => null);
|
|
510
|
+
}
|
|
511
|
+
if (!info && !path.extname(relative)) {
|
|
512
|
+
file = path.join(root, 'index.html');
|
|
513
|
+
info = await stat(file).catch(() => null);
|
|
514
|
+
}
|
|
515
|
+
if (!info?.isFile())
|
|
516
|
+
return c.notFound();
|
|
517
|
+
const resolved = await realpath(file);
|
|
518
|
+
const realRoot = await realpath(root);
|
|
519
|
+
if (!resolved.startsWith(`${realRoot}${path.sep}`))
|
|
520
|
+
return c.notFound();
|
|
521
|
+
const isIndex = path.basename(file) === 'index.html';
|
|
522
|
+
const stamp = `${info.mtimeMs}|${info.size}`;
|
|
523
|
+
const etag = `"${info.mtimeMs}-${info.size}${isIndex ? `-${pageDigest}` : ''}"`;
|
|
524
|
+
const hashed = /(^|\/)assets\//.test(relative);
|
|
525
|
+
const headers = { etag, 'x-content-type-options': 'nosniff', 'cache-control': hashed ? 'public, max-age=31536000, immutable' : 'no-cache' };
|
|
526
|
+
if (c.req.header('if-none-match') === etag)
|
|
527
|
+
return new Response(null, { status: 304, headers });
|
|
528
|
+
let kept = files.get(file);
|
|
529
|
+
if (!kept || kept.stamp !== stamp) {
|
|
530
|
+
kept = { stamp, bytes: new Uint8Array(await readFile(file)) };
|
|
531
|
+
if (files.size >= 128)
|
|
532
|
+
files.delete(files.keys().next().value);
|
|
533
|
+
// Keep only small assets; large downloads do not occupy the viewer cache.
|
|
534
|
+
if (kept.bytes.length <= 2 * MiB)
|
|
535
|
+
files.set(file, kept);
|
|
536
|
+
}
|
|
537
|
+
let data = kept.bytes;
|
|
538
|
+
if (isIndex)
|
|
539
|
+
data = new TextDecoder().decode(data).replace('</head>', `<script>window.__ATLAS_CONFIG__=${config}</script></head>`);
|
|
540
|
+
return body(c, data, typeOf(file), headers, `${file}|${stamp}`, true);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
// An API path that nothing answered is an error a client can read, whether or not a viewer is served.
|
|
544
|
+
app.notFound(c => (new URL(c.req.url).pathname.startsWith('/api/') ? c.json({ error: 'Not found' }, 404) : c.text('404 Not Found', 404)));
|
|
545
|
+
app.onError((error, c) => {
|
|
546
|
+
options.log?.({ at: new Date(now()).toISOString(), level: 'error', path: new URL(c.req.url).pathname, error: error instanceof Error ? error.message : String(error) });
|
|
547
|
+
return c.json({ error: 'Something went wrong on the server. It has been logged.' }, 500);
|
|
548
|
+
});
|
|
549
|
+
return {
|
|
550
|
+
fetch: async (request) => app.fetch(request),
|
|
551
|
+
routes: app.routes.map(({ method, path }) => ({ method, path })),
|
|
552
|
+
index,
|
|
553
|
+
events,
|
|
554
|
+
ready: index.start(options.refreshMs ?? 10_000),
|
|
555
|
+
close: () => { clearInterval(leaseTimer); stopListening(); index.stop(); events.close(); }
|
|
556
|
+
};
|
|
557
|
+
}
|
|
@@ -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
|
+
}
|