@runeditrun/rer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -2
- package/bin/rer.mjs +3 -0
- package/cli/README.md +243 -0
- package/cli/adapter.mjs +10 -0
- package/cli/ai-local.mjs +65 -0
- package/cli/archive-path.mjs +52 -0
- package/cli/archive-vite.mjs +55 -0
- package/cli/archive.mjs +8 -0
- package/cli/auth-copy.mjs +80 -0
- package/cli/bounded.mjs +70 -0
- package/cli/cache-doctor.mjs +63 -0
- package/cli/container-transfer.mjs +35 -0
- package/cli/container.mjs +62 -0
- package/cli/contract.mjs +25 -0
- package/cli/dependencies.mjs +45 -0
- package/cli/dependency-approval.mjs +33 -0
- package/cli/dependency-doctor.mjs +34 -0
- package/cli/dependency-input.mjs +43 -0
- package/cli/deploy-config.mjs +118 -0
- package/cli/deploy.mjs +37 -0
- package/cli/dev-identity.mjs +80 -0
- package/cli/errors.mjs +14 -0
- package/cli/export.mjs +42 -0
- package/cli/font-cache.mjs +29 -0
- package/cli/hosted-output.mjs +110 -0
- package/cli/independent.mjs +29 -0
- package/cli/inspect.mjs +174 -0
- package/cli/jsonc.mjs +111 -0
- package/cli/lifecycle.mjs +148 -0
- package/cli/local.mjs +100 -0
- package/cli/main.mjs +57 -0
- package/cli/managed-server.mjs +143 -0
- package/cli/node-runtime-cache.mjs +68 -0
- package/cli/node-runtime-discovery.mjs +56 -0
- package/cli/node-runtime-download.mjs +59 -0
- package/cli/node-runtime.mjs +50 -0
- package/cli/output.mjs +22 -0
- package/cli/parse.mjs +77 -0
- package/cli/port.mjs +52 -0
- package/cli/portability-worker.mjs +56 -0
- package/cli/portability.mjs +66 -0
- package/cli/preview-cache.mjs +99 -0
- package/cli/project.mjs +48 -0
- package/cli/release.mjs +24 -0
- package/cli/runtime-front-door.mjs +46 -0
- package/cli/scheduled.mjs +81 -0
- package/cli/server-activity.mjs +12 -0
- package/cli/server-proxy.mjs +93 -0
- package/cli/server-registry.mjs +177 -0
- package/cli/server-resources.mjs +22 -0
- package/cli/settings.mjs +15 -0
- package/cli/setup-answers.mjs +29 -0
- package/cli/setup-input.mjs +113 -0
- package/cli/shared.mjs +7 -0
- package/cli/transfers.mjs +33 -0
- package/cli/version.mjs +36 -0
- package/composition/git-source.mjs +111 -0
- package/edition-authoring/files.mjs +81 -0
- package/edition-authoring/manifest.mjs +65 -0
- package/global/README.md +155 -0
- package/global/checkout.mjs +72 -0
- package/global/cli/command.mjs +13 -0
- package/global/cli/install.mjs +75 -0
- package/global/cli/layout.mjs +45 -0
- package/global/cli/status.mjs +54 -0
- package/global/cli/uninstall.mjs +36 -0
- package/global/delegate.mjs +13 -0
- package/global/launch/capacity.mjs +72 -0
- package/global/launch/dev.mjs +129 -0
- package/global/launch/facts.mjs +93 -0
- package/global/launch/failure.mjs +5 -0
- package/global/launch/local-defaults.mjs +4 -0
- package/global/launch/prepare.mjs +61 -0
- package/global/launch/render.mjs +44 -0
- package/global/launch/state.mjs +97 -0
- package/global/launch/steps.mjs +81 -0
- package/global/launch/worker.mjs +43 -0
- package/global/list.mjs +59 -0
- package/global/main.mjs +53 -0
- package/global/package-info.mjs +38 -0
- package/global/registry.mjs +24 -0
- package/global/rer.mjs +29 -0
- package/global/update/archive.mjs +89 -0
- package/global/update/command.mjs +90 -0
- package/global/update/latest.mjs +6 -0
- package/global/update/notice.mjs +58 -0
- package/global/update/registry.mjs +47 -0
- package/global/version.mjs +29 -0
- package/install.sh +144 -0
- package/modules/ai/LICENSE +21 -0
- package/modules/ai/README.md +90 -0
- package/modules/ai/client.mjs +71 -0
- package/modules/ai/config.mjs +39 -0
- package/modules/ai/dev/index.d.mts +6 -0
- package/modules/ai/dev/index.mjs +64 -0
- package/modules/ai/dev/outbox.mjs +33 -0
- package/modules/ai/errors.mjs +28 -0
- package/modules/ai/fake/index.d.mts +3 -0
- package/modules/ai/fake/index.mjs +82 -0
- package/modules/ai/index.d.mts +10 -0
- package/modules/ai/index.mjs +3 -0
- package/modules/ai/package.json +6 -0
- package/modules/container/README.md +156 -0
- package/modules/container/adapters/d1.mjs +34 -0
- package/modules/container/adapters/egress.mjs +40 -0
- package/modules/container/adapters/jobs.mjs +47 -0
- package/modules/container/adapters/objects.mjs +84 -0
- package/modules/container/adapters/retained.mjs +14 -0
- package/modules/container/adapters/sqlite-error.mjs +7 -0
- package/modules/container/adapters/sqlite-worker.mjs +26 -0
- package/modules/container/assets.mjs +14 -0
- package/modules/container/bindings.mjs +1 -0
- package/modules/container/boundary.mjs +38 -0
- package/modules/container/build.mjs +65 -0
- package/modules/container/deploy-input.mjs +17 -0
- package/modules/container/entrypoint.sh +5 -0
- package/modules/container/host.mjs +43 -0
- package/modules/container/ingress.mjs +23 -0
- package/modules/container/lock.mjs +11 -0
- package/modules/container/migrations.mjs +16 -0
- package/modules/container/node-engine.mjs +39 -0
- package/modules/container/portability-schema.mjs +14 -0
- package/modules/container/remote/deploy.py +76 -0
- package/modules/container/remote/files.py +275 -0
- package/modules/container/remote/resources.py +157 -0
- package/modules/container/runner.mjs +10 -0
- package/modules/container/ssh-plan.mjs +44 -0
- package/modules/container/ssh.mjs +63 -0
- package/modules/container/transfer.mjs +27 -0
- package/modules/container/worker-engine.mjs +32 -0
- package/modules/dependencies/README.md +211 -0
- package/modules/dependencies/approval.mjs +51 -0
- package/modules/dependencies/auth.mjs +40 -0
- package/modules/dependencies/authority.mjs +14 -0
- package/modules/dependencies/crypto.mjs +13 -0
- package/modules/dependencies/index.d.mts +23 -0
- package/modules/dependencies/index.mjs +113 -0
- package/modules/dependencies/logout.mjs +42 -0
- package/modules/dependencies/native.mjs +9 -0
- package/modules/dependencies/protocol.mjs +23 -0
- package/modules/dependencies/schema/approval-attempt.sql +3 -0
- package/modules/dependencies/schema/dependencies.sql +15 -0
- package/modules/dependencies/store.mjs +19 -0
- package/modules/deploy-kit/README.md +308 -0
- package/modules/deploy-kit/artifact.mjs +47 -0
- package/modules/deploy-kit/build-modules.mjs +21 -0
- package/modules/deploy-kit/check.mjs +13 -0
- package/modules/deploy-kit/cli.mjs +50 -0
- package/modules/deploy-kit/container-config.mjs +8 -0
- package/modules/deploy-kit/default-stage.mjs +14 -0
- package/modules/deploy-kit/deploy.mjs +80 -0
- package/modules/deploy-kit/health-request.mjs +86 -0
- package/modules/deploy-kit/health.mjs +99 -0
- package/modules/deploy-kit/index.mjs +12 -0
- package/modules/deploy-kit/local-only.mjs +10 -0
- package/modules/deploy-kit/migrations.mjs +48 -0
- package/modules/deploy-kit/owner-cli.mjs +63 -0
- package/modules/deploy-kit/owner.mjs +297 -0
- package/modules/deploy-kit/prepare.mjs +62 -0
- package/modules/deploy-kit/provider.mjs +99 -0
- package/modules/deploy-kit/queue-consumer.mjs +10 -0
- package/modules/deploy-kit/receipt.mjs +16 -0
- package/modules/deploy-kit/render.mjs +47 -0
- package/modules/deploy-kit/resources.mjs +106 -0
- package/modules/deploy-kit/service-grants.mjs +25 -0
- package/modules/deploy-kit/sql-safety.mjs +94 -0
- package/modules/deploy-kit/sql-tokens.mjs +49 -0
- package/modules/deploy-kit/teardown.mjs +100 -0
- package/modules/deploy-kit/templates.mjs +31 -0
- package/modules/deploy-kit/validation.mjs +58 -0
- package/modules/deploy-kit/worker.mjs +105 -0
- package/modules/erasure/CONTRACT.md +90 -0
- package/modules/erasure/README.md +221 -0
- package/modules/erasure/audit.mjs +23 -0
- package/modules/erasure/blobs.mjs +45 -0
- package/modules/erasure/cascade.mjs +91 -0
- package/modules/erasure/completion.mjs +62 -0
- package/modules/erasure/declaration.mjs +51 -0
- package/modules/erasure/fence.mjs +19 -0
- package/modules/erasure/index.d.mts +62 -0
- package/modules/erasure/index.mjs +85 -0
- package/modules/erasure/order.mjs +56 -0
- package/modules/erasure/plan.mjs +121 -0
- package/modules/erasure/rate.mjs +57 -0
- package/modules/erasure/receipt.mjs +32 -0
- package/modules/erasure/root.mjs +21 -0
- package/modules/erasure/schema/erasure-rates.sql +5 -0
- package/modules/erasure/schema/erasure.sql +27 -0
- package/modules/erasure/scope.mjs +29 -0
- package/modules/erasure/sql.mjs +23 -0
- package/modules/erasure/verification.mjs +69 -0
- package/modules/integrations/core/CONTRACT.md +50 -0
- package/modules/integrations/core/README.md +27 -0
- package/modules/integrations/core/crypto.ts +19 -0
- package/modules/integrations/core/database.ts +21 -0
- package/modules/integrations/core/delivery.ts +97 -0
- package/modules/integrations/core/destination.ts +13 -0
- package/modules/integrations/core/index.ts +7 -0
- package/modules/integrations/core/node-transport.ts +72 -0
- package/modules/integrations/core/public-address.ts +57 -0
- package/modules/integrations/core/repository.ts +74 -0
- package/modules/integrations/core/schema.sql +36 -0
- package/modules/integrations/core/sqlite.ts +20 -0
- package/modules/integrations/core/timestamped-body-signature.ts +38 -0
- package/modules/integrations/core/tokens.ts +71 -0
- package/modules/integrations/core/types.ts +52 -0
- package/modules/integrations/core/upgrade-token-usage-context.sql +5 -0
- package/modules/integrations/core/webhook.ts +73 -0
- package/modules/job-queue/README.md +173 -0
- package/modules/job-queue/cli.mjs +64 -0
- package/modules/job-queue/command.mjs +64 -0
- package/modules/job-queue/config.mjs +48 -0
- package/modules/job-queue/identity.mjs +48 -0
- package/modules/job-queue/index.mjs +135 -0
- package/modules/job-queue/membership.mjs +30 -0
- package/modules/job-queue/module.json +10 -0
- package/modules/job-queue/policy.mjs +29 -0
- package/modules/job-queue/processes.mjs +80 -0
- package/modules/job-queue/resources.mjs +37 -0
- package/modules/job-queue/storage.mjs +64 -0
- package/modules/job-queue/supervisor.mjs +30 -0
- package/modules/job-queue/waiting.mjs +16 -0
- package/modules/mail/LICENSE +21 -0
- package/modules/mail/README.md +336 -0
- package/modules/mail/doctor.mjs +81 -0
- package/modules/mail/index.mjs +8 -0
- package/modules/mail/live-proof/README.md +113 -0
- package/modules/mail/live-proof/run.mjs +55 -0
- package/modules/mail/package.json +19 -0
- package/modules/mail/pnpm-lock.yaml +537 -0
- package/modules/mail/src/adapters/cloudflare/README.md +84 -0
- package/modules/mail/src/adapters/cloudflare/headers.mjs +63 -0
- package/modules/mail/src/adapters/cloudflare/index.mjs +47 -0
- package/modules/mail/src/adapters/cloudflare/response.mjs +99 -0
- package/modules/mail/src/adapters/messaging/README.md +92 -0
- package/modules/mail/src/adapters/messaging/contract.mjs +66 -0
- package/modules/mail/src/adapters/messaging/event-schema.mjs +28 -0
- package/modules/mail/src/adapters/messaging/index.mjs +61 -0
- package/modules/mail/src/adapters/messaging/jws.mjs +68 -0
- package/modules/mail/src/adapters/messaging/request.mjs +52 -0
- package/modules/mail/src/adapters/messaging/serialize.mjs +21 -0
- package/modules/mail/src/adapters/resend/README.md +120 -0
- package/modules/mail/src/adapters/resend/index.mjs +22 -0
- package/modules/mail/src/adapters/resend/response.mjs +54 -0
- package/modules/mail/src/adapters/resend/serialize.mjs +46 -0
- package/modules/mail/src/adapters/resend/size.mjs +99 -0
- package/modules/mail/src/adapters/sendgrid/README.md +75 -0
- package/modules/mail/src/adapters/sendgrid/index.mjs +35 -0
- package/modules/mail/src/adapters/sendgrid/serialize.mjs +104 -0
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/core/transport.mjs +33 -0
- package/modules/mail/src/core/validation.mjs +172 -0
- package/modules/mail/src/development/index.d.mts +37 -0
- package/modules/mail/src/development/index.mjs +35 -0
- package/modules/mail/src/development/outbox.mjs +78 -0
- package/modules/mail/src/development/schema.sql +9 -0
- package/modules/mail/src/development/viewer-html.mjs +42 -0
- package/modules/mail/src/development/viewer.mjs +65 -0
- package/modules/mail/src/doctor/README.md +107 -0
- package/modules/mail/src/doctor/common.mjs +64 -0
- package/modules/mail/src/doctor/dns.mjs +102 -0
- package/modules/mail/src/doctor/index.mjs +28 -0
- package/modules/mail/src/doctor/probe.mjs +66 -0
- package/modules/mail/src/doctor/receive.mjs +81 -0
- package/modules/mail/src/doctor/records.mjs +45 -0
- package/modules/mail/src/events/README.md +268 -0
- package/modules/mail/src/events/d1-schema.sql +25 -0
- package/modules/mail/src/events/d1-storage.mjs +76 -0
- package/modules/mail/src/events/index.mjs +3 -0
- package/modules/mail/src/events/intake.mjs +201 -0
- package/modules/mail/src/events/storage.mjs +52 -0
- package/modules/mail/src/events/verify.mjs +92 -0
- package/modules/mail/src/inbound/README.md +137 -0
- package/modules/mail/src/inbound/bytes.mjs +91 -0
- package/modules/mail/src/inbound/headers.mjs +143 -0
- package/modules/mail/src/inbound/index.mjs +29 -0
- package/modules/mail/src/inbound/mime.mjs +36 -0
- package/modules/mail/src/inbound/multipart.mjs +27 -0
- package/modules/mail/src/inbound/resend-attachment.mjs +43 -0
- package/modules/mail/src/inbound/resend.mjs +54 -0
- package/modules/mail/src/inbound/sendgrid.mjs +60 -0
- package/modules/mail/src/sender.mjs +33 -0
- package/modules/mail/src/sendgrid-email/index.d.mts +33 -0
- package/modules/mail/src/sendgrid-email/index.mjs +86 -0
- package/modules/mail/src/unsubscribe/index.mjs +142 -0
- package/modules/operator-auth/README.md +599 -0
- package/modules/operator-auth/accounts/adapter.mjs +41 -0
- package/modules/operator-auth/accounts/admin.mjs +159 -0
- package/modules/operator-auth/accounts/credentials.mjs +27 -0
- package/modules/operator-auth/accounts/editor-owner.mjs +13 -0
- package/modules/operator-auth/accounts/index.d.mts +9 -0
- package/modules/operator-auth/accounts/index.mjs +9 -0
- package/modules/operator-auth/accounts/native.d.mts +38 -0
- package/modules/operator-auth/accounts/native.mjs +171 -0
- package/modules/operator-auth/accounts/oa.mjs +90 -0
- package/modules/operator-auth/accounts/provider-control.mjs +50 -0
- package/modules/operator-auth/accounts/provider-pin.mjs +14 -0
- package/modules/operator-auth/accounts/roles.mjs +26 -0
- package/modules/operator-auth/accounts/schema/dependency-setup.sql +17 -0
- package/modules/operator-auth/accounts/schema/dev-sessions.sql +22 -0
- package/modules/operator-auth/accounts/schema/domain-rules-step-up.sql +20 -0
- package/modules/operator-auth/accounts/schema/invitation-cancellation.sql +17 -0
- package/modules/operator-auth/accounts/schema/operator-auth.sql +142 -0
- package/modules/operator-auth/accounts/schema/provider-control.sql +30 -0
- package/modules/operator-auth/accounts/schema/provider-pins.sql +6 -0
- package/modules/operator-auth/accounts/settings-page.mjs +4 -0
- package/modules/operator-auth/accounts/store.mjs +246 -0
- package/modules/operator-auth/branding.mjs +66 -0
- package/modules/operator-auth/config.mjs +35 -0
- package/modules/operator-auth/conformance/README.md +81 -0
- package/modules/operator-auth/conformance/index.d.mts +43 -0
- package/modules/operator-auth/conformance/index.mjs +300 -0
- package/modules/operator-auth/conformance/presentation.mjs +57 -0
- package/modules/operator-auth/crypto.mjs +17 -0
- package/modules/operator-auth/dev/badge.mjs +62 -0
- package/modules/operator-auth/dev/index.d.mts +14 -0
- package/modules/operator-auth/dev/index.mjs +137 -0
- package/modules/operator-auth/dev/personas.mjs +17 -0
- package/modules/operator-auth/dev/sign-in-as.d.mts +3 -0
- package/modules/operator-auth/dev/sign-in-as.mjs +25 -0
- package/modules/operator-auth/dev-admission.d.mts +4 -0
- package/modules/operator-auth/dev-admission.mjs +10 -0
- package/modules/operator-auth/dev-gate.mjs +86 -0
- package/modules/operator-auth/docs/README.md +78 -0
- package/modules/operator-auth/docs/native-accounts.md +313 -0
- package/modules/operator-auth/docs/tanstack-start.md +218 -0
- package/modules/operator-auth/docs/vite-spa.md +144 -0
- package/modules/operator-auth/errors.mjs +9 -0
- package/modules/operator-auth/http.mjs +42 -0
- package/modules/operator-auth/identity/admission.mjs +105 -0
- package/modules/operator-auth/identity/after-confirm.mjs +18 -0
- package/modules/operator-auth/identity/email-link/confirmation.mjs +14 -0
- package/modules/operator-auth/identity/email-link/index.d.mts +48 -0
- package/modules/operator-auth/identity/email-link/index.mjs +128 -0
- package/modules/operator-auth/identity/email-link/message.mjs +16 -0
- package/modules/operator-auth/identity/index.d.mts +7 -0
- package/modules/operator-auth/identity/index.mjs +5 -0
- package/modules/operator-auth/identity/oidc/README.md +171 -0
- package/modules/operator-auth/identity/oidc/client-auth.mjs +16 -0
- package/modules/operator-auth/identity/oidc/config.mjs +102 -0
- package/modules/operator-auth/identity/oidc/crypto.mjs +29 -0
- package/modules/operator-auth/identity/oidc/errors.mjs +5 -0
- package/modules/operator-auth/identity/oidc/index.d.mts +33 -0
- package/modules/operator-auth/identity/oidc/index.mjs +287 -0
- package/modules/operator-auth/identity/oidc/jwt.mjs +102 -0
- package/modules/operator-auth/identity/oidc/network.mjs +93 -0
- package/modules/operator-auth/identity/oidc/presets.mjs +26 -0
- package/modules/operator-auth/identity/oidc/schema/oidc.sql +50 -0
- package/modules/operator-auth/identity/oidc/store.mjs +68 -0
- package/modules/operator-auth/identity/principal.mjs +28 -0
- package/modules/operator-auth/index.d.mts +223 -0
- package/modules/operator-auth/index.mjs +11 -0
- package/modules/operator-auth/input.mjs +55 -0
- package/modules/operator-auth/local-cli.mjs +16 -0
- package/modules/operator-auth/page.mjs +34 -0
- package/modules/operator-auth/policy.mjs +20 -0
- package/modules/operator-auth/routes.mjs +91 -0
- package/modules/operator-auth/service.mjs +152 -0
- package/modules/operator-auth/signin/index.mjs +67 -0
- package/modules/owner-claim/README.md +137 -0
- package/modules/owner-claim/idp.mjs +40 -0
- package/modules/owner-claim/index.d.mts +27 -0
- package/modules/owner-claim/index.mjs +39 -0
- package/modules/owner-claim/page.mjs +11 -0
- package/modules/owner-claim/schema/idp.sql +4 -0
- package/modules/owner-claim/schema/owner-claim.sql +18 -0
- package/modules/owner-claim/transaction.mjs +87 -0
- package/modules/owner-claim/verify.mjs +80 -0
- package/modules/portability/README.md +444 -0
- package/modules/portability/bridge.mjs +102 -0
- package/modules/portability/catalogue.mjs +118 -0
- package/modules/portability/cli-options.mjs +38 -0
- package/modules/portability/cli.mjs +56 -0
- package/modules/portability/copies.mjs +68 -0
- package/modules/portability/dev-data.mjs +32 -0
- package/modules/portability/diff.mjs +33 -0
- package/modules/portability/export.mjs +129 -0
- package/modules/portability/files.mjs +65 -0
- package/modules/portability/format.mjs +122 -0
- package/modules/portability/generated.mjs +60 -0
- package/modules/portability/hash.mjs +79 -0
- package/modules/portability/http.mjs +42 -0
- package/modules/portability/import.mjs +185 -0
- package/modules/portability/index.d.mts +58 -0
- package/modules/portability/index.mjs +22 -0
- package/modules/portability/journal.mjs +49 -0
- package/modules/portability/legacy-files.mjs +43 -0
- package/modules/portability/legacy-safety.mjs +27 -0
- package/modules/portability/lines.mjs +36 -0
- package/modules/portability/migration-history.mjs +18 -0
- package/modules/portability/migration-statements.mjs +23 -0
- package/modules/portability/payloads.mjs +52 -0
- package/modules/portability/provider-data.mjs +25 -0
- package/modules/portability/restore-safety.mjs +30 -0
- package/modules/portability/schema/portability.sql +25 -0
- package/modules/portability/storage.mjs +49 -0
- package/modules/release/README.md +212 -0
- package/modules/release/catalogue.mjs +79 -0
- package/modules/release/export.mjs +51 -0
- package/modules/release/produce.mjs +136 -0
- package/modules/release/public-child-transport-state.cjs +29 -0
- package/modules/release/public-child.mjs +71 -0
- package/modules/release/public-git.mjs +21 -0
- package/modules/release/publication-scan.mjs +22 -0
- package/modules/release/publish-handoff.mjs +37 -0
- package/modules/release/source.mjs +71 -0
- package/modules/release/test-summary.mjs +50 -0
- package/onboarding/README.md +163 -0
- package/onboarding/activity.mjs +31 -0
- package/onboarding/adapter-auth.mjs +88 -0
- package/onboarding/adapter-hosting.mjs +35 -0
- package/onboarding/adapter-labels.mjs +13 -0
- package/onboarding/adapter-settings.mjs +129 -0
- package/onboarding/adapter.mjs +238 -0
- package/onboarding/agent-handoff.mjs +9 -0
- package/onboarding/app-processes.mjs +21 -0
- package/onboarding/archive-output.d.mts +3 -0
- package/onboarding/archive-output.mjs +54 -0
- package/onboarding/command-custody.mjs +24 -0
- package/onboarding/command-recovery.mjs +8 -0
- package/onboarding/commands.mjs +166 -0
- package/onboarding/dependency-approval.mjs +36 -0
- package/onboarding/dev-identity.mjs +23 -0
- package/onboarding/email.mjs +24 -0
- package/onboarding/failures.mjs +42 -0
- package/onboarding/input-limits.mjs +7 -0
- package/onboarding/installer-inputs.mjs +24 -0
- package/onboarding/launcher.mjs +34 -0
- package/onboarding/local/build-files.mjs +64 -0
- package/onboarding/local/build.mjs +49 -0
- package/onboarding/local/environment.mjs +30 -0
- package/onboarding/local/files.mjs +35 -0
- package/onboarding/local/operation.mjs +166 -0
- package/onboarding/local/owner.mjs +34 -0
- package/onboarding/local/process-table.mjs +114 -0
- package/onboarding/local/recovery.mjs +79 -0
- package/onboarding/local/state.mjs +31 -0
- package/onboarding/metadata.mjs +7 -0
- package/onboarding/operator-install.mjs +45 -0
- package/onboarding/operator-installer.mjs +39 -0
- package/onboarding/operator-prerequisites.mjs +27 -0
- package/onboarding/package.json +6 -0
- package/onboarding/prefill.mjs +16 -0
- package/onboarding/private-files.mjs +97 -0
- package/onboarding/private.mjs +35 -0
- package/onboarding/process-cleanup.mjs +134 -0
- package/onboarding/process-diagnostics.mjs +91 -0
- package/onboarding/process-scope.mjs +118 -0
- package/onboarding/process-table.mjs +118 -0
- package/onboarding/process-tree.mjs +36 -0
- package/onboarding/production-modules.mjs +22 -0
- package/onboarding/production-options.mjs +40 -0
- package/onboarding/production-plan.mjs +8 -0
- package/onboarding/production-runtime.mjs +30 -0
- package/onboarding/project-vite.mjs +14 -0
- package/onboarding/providers.mjs +289 -0
- package/onboarding/queue.mjs +53 -0
- package/onboarding/queued-entry.mjs +34 -0
- package/onboarding/readiness.mjs +59 -0
- package/onboarding/redaction-jwt.mjs +32 -0
- package/onboarding/redaction.mjs +298 -0
- package/onboarding/schema/SETUP.schema.json +514 -0
- package/onboarding/schema/dependencies.mjs +35 -0
- package/onboarding/schema/json-schema.mjs +202 -0
- package/onboarding/schema/setup-validation.mjs +325 -0
- package/onboarding/secrets.mjs +21 -0
- package/onboarding/server.mjs +188 -0
- package/onboarding/setup-manifest.mjs +116 -0
- package/onboarding/state.mjs +46 -0
- package/onboarding/theme-files.mjs +37 -0
- package/onboarding/theme.mjs +118 -0
- package/onboarding/ui/activity.js +98 -0
- package/onboarding/ui/address-client.mjs +33 -0
- package/onboarding/ui/app.js +377 -0
- package/onboarding/ui/index.html +1 -0
- package/onboarding/ui/model-core.mjs +127 -0
- package/onboarding/ui/model.mjs +5 -0
- package/onboarding/ui/style.css +63 -0
- package/onboarding/ui/theme.css +37 -0
- package/onboarding/ui/workspace-art.js +30 -0
- package/onboarding/wrangler-auth.mjs +43 -0
- package/operations/README.md +246 -0
- package/operations/operations.mjs +168 -0
- package/operations/project-operations.mjs +157 -0
- package/package.json +11 -4
- package/scripts/publish-check/README.md +72 -0
- package/scripts/publish-check/checksum_maps.py +52 -0
- package/scripts/publish-check/cli.py +34 -0
- package/scripts/publish-check/command_arguments.py +51 -0
- package/scripts/publish-check/command_shell_quotes.py +41 -0
- package/scripts/publish-check/command_source_quotes.py +120 -0
- package/scripts/publish-check/content_identities.py +250 -0
- package/scripts/publish-check/copy_identities.py +41 -0
- package/scripts/publish-check/der.py +61 -0
- package/scripts/publish-check/destination.py +117 -0
- package/scripts/publish-check/env_values.py +74 -0
- package/scripts/publish-check/font_licence.py +23 -0
- package/scripts/publish-check/git_history.py +115 -0
- package/scripts/publish-check/github_refs.py +217 -0
- package/scripts/publish-check/gitleaks.py +72 -0
- package/scripts/publish-check/gitleaks.toml +40 -0
- package/scripts/publish-check/history_scan.py +176 -0
- package/scripts/publish-check/hit_location.py +18 -0
- package/scripts/publish-check/hygiene_config.py +42 -0
- package/scripts/publish-check/hygiene_text.py +11 -0
- package/scripts/publish-check/identity-recipe.mjs +54 -0
- package/scripts/publish-check/json_identity.py +79 -0
- package/scripts/publish-check/license-exceptions.json +11 -0
- package/scripts/publish-check/licenses.py +49 -0
- package/scripts/publish-check/policy.py +18 -0
- package/scripts/publish-check/project_identity.py +89 -0
- package/scripts/publish-check/provenance.py +91 -0
- package/scripts/publish-check/provenance_pins.py +33 -0
- package/scripts/publish-check/public_base.py +63 -0
- package/scripts/publish-check/public_hygiene.py +80 -0
- package/scripts/publish-check/publication-config.json +12 -0
- package/scripts/publish-check/publication_config.py +34 -0
- package/scripts/publish-check/report.py +52 -0
- package/scripts/publish-check/repository_commands.py +75 -0
- package/scripts/publish-check/repository_intent.py +52 -0
- package/scripts/publish-check/reviewed.py +41 -0
- package/scripts/publish-check/source_hashes.mjs +6 -0
- package/scripts/publish-check/tree_scan.py +88 -0
- package/scripts/publish-check/trust_bundle.py +87 -0
- package/scripts/publish-check.mjs +58 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { loadInstallerSeed } from './setup-manifest.mjs';
|
|
2
|
+
import { readSettings } from './adapter-settings.mjs';
|
|
3
|
+
import { createAppProcesses } from './app-processes.mjs';
|
|
4
|
+
import { installationFailureMessage } from './failures.mjs';
|
|
5
|
+
import { agentHandoff } from './agent-handoff.mjs';
|
|
6
|
+
import { validateAnswers } from './prefill.mjs';
|
|
7
|
+
import { setupInputLimit } from './input-limits.mjs';
|
|
8
|
+
import { createSecretVault } from './secrets.mjs';
|
|
9
|
+
import { credentialValues } from './redaction.mjs';
|
|
10
|
+
import { discoverProviders, connectProvider } from './providers.mjs';
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
15
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { createState } from './state.mjs';
|
|
17
|
+
import { metadata } from './metadata.mjs';
|
|
18
|
+
import { loadSeedTheme } from './theme-files.mjs';
|
|
19
|
+
// A browser keeps a connection open; close waits this long for in-flight replies.
|
|
20
|
+
const connectionGraceMs=1000;
|
|
21
|
+
export async function startInstaller(root,{seed,port=0,answers,secretInput,noPrefill=false,resume=false,agent=false}={}){
|
|
22
|
+
root=resolve(root);
|
|
23
|
+
seed=await loadInstallerSeed(root,seed);
|
|
24
|
+
if(!seed?.product||typeof seed.install!=='function')throw Error('Provide a seed onboarding contract selecting rer-setup.json.');
|
|
25
|
+
const validate=value=>validateAnswers(value,seed.model);
|
|
26
|
+
const inputLimit=setupInputLimit(seed.model);
|
|
27
|
+
const accent=seed.branding?.accent;
|
|
28
|
+
const accentStyle=/^#[0-9a-f]{6}$/i.test(accent||'')?`<style>:root{--installer-accent:${accent};--installer-accent-text:${[1,3,5].map((start,i)=>parseInt(accent.slice(start,start+2),16)*[.299,.587,.114][i]).reduce((a,b)=>a+b,0)>150?'#173753':'#ffffff'}}</style>`:'';
|
|
29
|
+
const theme=seed.theme?await loadSeedTheme(root,seed.theme):null;
|
|
30
|
+
const uiRoot=fileURLToPath(new URL('./ui/',import.meta.url));
|
|
31
|
+
if(resume&&noPrefill)throw Error('--resume cannot be combined with --no-prefill.');
|
|
32
|
+
const store=await createState(root,seed),token=randomBytes(32).toString('hex'),cookie=randomBytes(32).toString('hex');let origin,work,connection,starting=false,mutations=0;const apps=createAppProcesses();const abort=new AbortController();let attemptAbort,attemptProcesses;
|
|
33
|
+
store.setupManifest=seed.manifest;
|
|
34
|
+
const vault=createSecretVault(seed.model,store.addSecrets);store.addSecrets([token,cookie]);
|
|
35
|
+
const invalidateSecrets=async draft=>{vault.invalidate(draft);await store.invalidateSecrets(vault.invalidated());};
|
|
36
|
+
const saveDraft=async (draft,source,keys)=>{await invalidateSecrets(draft);await store.draft(draft,source,keys);};
|
|
37
|
+
store.state.sessionId=randomBytes(16).toString('hex');store.addSecrets(store.state.sessionId);
|
|
38
|
+
const savedSettings=noPrefill||!seed.settings?{}:(await readSettings(root,seed.settings)).values;
|
|
39
|
+
const configuredEnv=noPrefill?{}:{...process.env,...savedSettings};
|
|
40
|
+
const declaredSecrets=seed.manifest.fields.filter(field=>field.kind==='secret').map(field=>field.name);
|
|
41
|
+
// Seed callbacks may capture commands too. Register declared values before
|
|
42
|
+
// artwork/default hooks, including inherited values when prefill is disabled.
|
|
43
|
+
store.addSecrets([...credentialValues(process.env,declaredSecrets),...credentialValues(savedSettings,declaredSecrets)]);
|
|
44
|
+
const {workspaceArt,artworkBridge,imageLimit}=seed.artwork?await seed.artwork(root,store):{};
|
|
45
|
+
if(agent)store.state.agent.mode='connecting';
|
|
46
|
+
if(noPrefill&&(answers||secretInput||Object.keys(store.state.draft).length))throw Error('--no-prefill requires a fresh checkout with no saved installer draft or supplied answers. Existing drafts are preserved.');
|
|
47
|
+
if(!noPrefill&&!resume){
|
|
48
|
+
const info=await metadata(root);const savedAnswers=Object.fromEntries(seed.model.safeKeys.filter(key=>typeof savedSettings[key]==='string').map(key=>[key,savedSettings[key]]));
|
|
49
|
+
const defaults=validate({...seed.model.initialDraft({}),...await seed.defaults?.(info,{root,env:configuredEnv}),...savedAnswers});store.state.known={...store.state.known,...info,suggestedFields:Object.keys(defaults).filter(key=>!store.state.answered.includes(key))};store.state.draft={...defaults,...store.state.draft};
|
|
50
|
+
}
|
|
51
|
+
// Saved credentials retain the identity from settings, before resumed answers.
|
|
52
|
+
// Durable invalidations prevent a later return to that identity from reviving them.
|
|
53
|
+
if(resume&&!noPrefill)store.state.draft={...seed.model.initialDraft({}),...Object.fromEntries(seed.model.safeKeys.filter(key=>typeof savedSettings[key]==='string').map(key=>[key,savedSettings[key]])),...store.state.draft};
|
|
54
|
+
const savedDraft={...seed.model.initialDraft({}),...Object.fromEntries(seed.model.safeKeys.filter(key=>typeof savedSettings[key]==='string').map(key=>[key,savedSettings[key]]))};
|
|
55
|
+
const savedSecrets=Object.fromEntries(Object.keys(seed.model.secretScopes).filter(key=>!store.isInvalidatedSecret(key,savedSettings[key])&&seed.model.fields[key].valid(savedSettings[key])).map(key=>[key,savedSettings[key]]));
|
|
56
|
+
vault.set(savedSecrets,savedDraft);await invalidateSecrets(store.state.draft);
|
|
57
|
+
if(answers){const validated=validate(answers);await saveDraft({...store.state.draft,...validated},'agent',Object.keys(validated));}
|
|
58
|
+
if(secretInput){vault.set(secretInput,store.state.draft);if(Object.keys(secretInput).length)store.activity.secrets();secretInput=undefined;}
|
|
59
|
+
await workspaceArt?.request();
|
|
60
|
+
store.state.known.providers=noPrefill||(!seed.features?.hosting&&!seed.features?.github)?{}:(await discoverProviders(root,{signal:abort.signal})).providers;
|
|
61
|
+
const assets={'/setup-manifest.mjs':['../setup-manifest.mjs','text/javascript'],'/installer-inputs.mjs':['../installer-inputs.mjs','text/javascript'],'/schema/setup-validation.mjs':['../schema/setup-validation.mjs','text/javascript'],'/schema/dependencies.mjs':['../schema/dependencies.mjs','text/javascript'],'/schema/json-schema.mjs':['../schema/json-schema.mjs','text/javascript'],'/ui/model-core.mjs':['model-core.mjs','text/javascript'],'/seed.mjs':[seed.browserModule,'text/javascript'],'/model-core.mjs':['model-core.mjs','text/javascript'],'/workspace-art.js':['workspace-art.js','text/javascript'],'/activity.js':['activity.js','text/javascript'], '/':['index.html','text/html'], '/app.js':['app.js','text/javascript'], '/address-client.mjs':['address-client.mjs','text/javascript'], '/model.mjs':['model.mjs','text/javascript'], '/style.css':['style.css','text/css'], '/theme.css':['theme.css','text/css'], '/ocean.png':[null,seed.background?.endsWith('.svg')?'image/svg+xml':'image/png']};
|
|
62
|
+
for(const path of theme?.files.keys()??[])if(assets[path])throw Error(`Seed theme file ${path} collides with an installer path; serve it at another path.`);
|
|
63
|
+
const equal=(a,b)=>typeof a==='string'&&a.length===b.length&&timingSafeEqual(Buffer.from(a),Buffer.from(b));
|
|
64
|
+
const server=createServer(async(req,res)=>{
|
|
65
|
+
let mutation=false;
|
|
66
|
+
const json=(status,body)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(body))};
|
|
67
|
+
try{
|
|
68
|
+
if(abort.signal.aborted)return json(503,{error:'The installer is closing.'});
|
|
69
|
+
if(req.headers.host!==new URL(origin).host)return json(403,{error:'Invalid installer host.'});
|
|
70
|
+
const path=new URL(req.url,origin).pathname;
|
|
71
|
+
res.setHeader('X-Content-Type-Options','nosniff');res.setHeader('Referrer-Policy','no-referrer');res.setHeader('Cache-Control','no-store');
|
|
72
|
+
if(path.startsWith('/api/')){
|
|
73
|
+
if(!path.startsWith('/api/workspace-art'))req.setEncoding('utf8');
|
|
74
|
+
if(req.headers.origin&&req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
75
|
+
const session=req.headers.cookie?.split(';').map(s=>s.trim()).find(s=>s.startsWith('rer_installer='))?.slice(14);
|
|
76
|
+
if(!equal(req.headers['x-installer-token'],token)||!equal(session,cookie))return json(403,{error:'Reload the installer page.'});
|
|
77
|
+
if(req.method==='POST'&&['/api/draft','/api/prefill','/api/secrets','/api/activity','/api/address','/api/connect'].includes(path)){
|
|
78
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
79
|
+
if(starting||['running','complete'].includes(store.state.phase))return json(409,{error:'Installation has already started.'});
|
|
80
|
+
mutation=true;mutations++;
|
|
81
|
+
}
|
|
82
|
+
if(req.method==='POST'&&path.startsWith('/api/workspace-art')){
|
|
83
|
+
if(!workspaceArt)return json(404,{error:'This seed does not support workspace artwork.'});
|
|
84
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
85
|
+
const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>(path==='/api/workspace-art/image'?imageLimit:8000))return json(413,{error:'Too much input.'});chunks.push(chunk);}
|
|
86
|
+
try{
|
|
87
|
+
if(path==='/api/workspace-art/image'){
|
|
88
|
+
if(req.headers['content-type']!=='image/png')return json(415,{error:'Supply a PNG image.'});
|
|
89
|
+
return json(200,await workspaceArt?.complete({id:req.headers['x-artwork-job'],revision:req.headers['x-artwork-revision'],sessionId:req.headers['x-installer-session'],kind:req.headers['x-artwork-kind']},Buffer.concat(chunks)));
|
|
90
|
+
}
|
|
91
|
+
const input=JSON.parse(Buffer.concat(chunks).toString());
|
|
92
|
+
if(path==='/api/workspace-art')return json(200,await workspaceArt?.request(input));
|
|
93
|
+
if(path==='/api/workspace-art/job')return json(200,await workspaceArt?.report(input));
|
|
94
|
+
return json(404,{error:'Unknown workspace artwork action.'});
|
|
95
|
+
}catch(error){return json(409,{error:error.message});}
|
|
96
|
+
}
|
|
97
|
+
// The dev key reaches only this authenticated page, never saved state or logs.
|
|
98
|
+
if(req.method==='GET'&&path==='/api/open-app'){
|
|
99
|
+
const key=store.state.phase==='complete'&&store.state.devOpen?/^RER_DEV_KEY=([A-Za-z0-9_-]{43,256})$/m.exec(await readFile(join(root,'.rer/local/dev-key.env'),'utf8').catch(()=>''))?.[1]:undefined;
|
|
100
|
+
if(!key)return json(404,{error:'No dev key for this installation.'});
|
|
101
|
+
const app=new URL(store.state.appUrl);return json(200,{url:`${app.origin}/__dev/key#${new URLSearchParams({'rer-dev':key,next:'/__dev/outbox'})}`});
|
|
102
|
+
}
|
|
103
|
+
if(req.method==='GET'&&path==='/api/agent-handoff')return json(200,(seed.handoff||agentHandoff)(root,origin,store.state.sessionId,seed));
|
|
104
|
+
if(req.method==='GET'&&path==='/api/state'){await workspaceArt?.refresh();store.state.known.secretReadiness=vault.readiness(store.state.draft);return json(200,store.state);}
|
|
105
|
+
if(req.method==='POST'&&path==='/api/activity'){
|
|
106
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
107
|
+
if(starting||['running','complete'].includes(store.state.phase))return json(409,{error:'Installation has already started.'});
|
|
108
|
+
let body='';for await(const chunk of req){body+=chunk;if(body.length>1000)return json(413,{error:'Too much input.'})}
|
|
109
|
+
try{const activity=store.activity.agent(JSON.parse(body));await workspaceArt?.request();return json(200,{activity})}catch{return json(400,{error:'Choose a supported activity phase and topic.'})}
|
|
110
|
+
}
|
|
111
|
+
if(req.method==='POST'&&path==='/api/address'){
|
|
112
|
+
if(!seed.resolveAddress)return json(404,{error:'This seed does not support address discovery.'});
|
|
113
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
114
|
+
if(starting||['running','complete'].includes(store.state.phase))return json(409,{error:'Installation has already started.'});
|
|
115
|
+
let body='';for await(const chunk of req){body+=chunk;if(body.length>8000)return json(413,{error:'Too much input.'})}
|
|
116
|
+
let input;try{input=JSON.parse(body);if(!input||typeof input!=='object'||Array.isArray(input))throw Error()}catch{return json(400,{error:'Provide your workspace name and Cloudflare account.',code:'invalid_input'})}
|
|
117
|
+
const merged=vault.merge(input,store.state.draft);await store.invalidateSecrets(vault.invalidated());store.addSecrets(credentialValues(merged,Object.keys(seed.model.secretScopes||{})));
|
|
118
|
+
try{return json(200,await seed.resolveAddress(root,{name:input.name,accountId:input.accountId,prefix:input.prefix,apiToken:merged.apiToken},{env:configuredEnv}));}
|
|
119
|
+
catch(error){if(typeof error.code==='string')return json(error.code==='invalid_input'?400:422,{error:error.message,code:error.code});return json(502,{error:'Cloudflare could not confirm your app address.',code:'cloudflare_unavailable'});}
|
|
120
|
+
finally{merged.apiToken='';input.apiToken='';}
|
|
121
|
+
}
|
|
122
|
+
if(req.method==='POST'&&['/api/prefill','/api/secrets'].includes(path)){
|
|
123
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
124
|
+
if(starting||['running','complete'].includes(store.state.phase))return json(409,{error:'Installation has already started.'});
|
|
125
|
+
let body='';for await(const chunk of req){body+=chunk;if(Buffer.byteLength(body)>inputLimit)return json(413,{error:'Too much input.'})}
|
|
126
|
+
try{const input=JSON.parse(body);if(path==='/api/prefill'){const answers=Object.fromEntries(Object.entries(validate(input)).filter(([key])=>store.state.sources[key]!=='browser'));await saveDraft({...store.state.draft,...answers},'agent',Object.keys(answers));await workspaceArt?.request();}else {if(input.revision!==store.state.lastUpdate.revision)throw Error('Setup details changed; read them again before supplying credentials.');vault.set(input.secrets,store.state.draft);if(Object.keys(input.secrets).length)store.activity.secrets();}}catch{return json(400,{error:'Invalid setup input. Use supported nonsecret answers or the secure credential endpoint.'})}
|
|
127
|
+
return json(200,{saved:true,revision:store.state.lastUpdate.revision,updatedFields:store.state.lastUpdate.keys,secretReadiness:vault.readiness(store.state.draft)});
|
|
128
|
+
}
|
|
129
|
+
if(req.method==='POST'&&path==='/api/connect'){
|
|
130
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
131
|
+
if(starting||['running','complete'].includes(store.state.phase)||connection)return json(409,{error:'Another operation is running.'});
|
|
132
|
+
let body='';for await(const chunk of req){body+=chunk;if(body.length>1000)return json(413,{error:'Too much input.'})}
|
|
133
|
+
let provider;try{provider=JSON.parse(body).provider;if(!['cloudflare','github'].includes(provider)||(provider==='cloudflare'&&!seed.features?.hosting)||(provider==='github'&&!seed.features?.github))throw Error()}catch{return json(400,{error:'Select Cloudflare or GitHub.'})}
|
|
134
|
+
store.state.connection={provider,state:'starting'};store.activity.provider(provider,'starting');json(202,{started:true});
|
|
135
|
+
connection=connectProvider(root,provider,{signal:abort.signal,onProgress:value=>{store.state.connection=value;store.activity.provider(provider,value.state)}}).then(result=>{store.state.known.providers[provider]=result;store.state.connection={provider,state:result.operation.state};store.activity.provider(provider,result.operation.state);}).catch(()=>{store.state.connection={provider,state:'failed'};store.activity.provider(provider,'failed');}).finally(()=>{connection=null});return;
|
|
136
|
+
}
|
|
137
|
+
if(req.method==='POST'&&path==='/api/draft'){
|
|
138
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
139
|
+
if(starting||['running','complete'].includes(store.state.phase))return json(409,{error:'Installation has already started.'});
|
|
140
|
+
let body='';for await(const chunk of req){body+=chunk;if(Buffer.byteLength(body)>inputLimit)return json(413,{error:'Too much input.'})}
|
|
141
|
+
let draft;try{draft=JSON.parse(body);if(!draft||typeof draft!=='object'||Array.isArray(draft)||Object.values(draft).some(value=>typeof value!=='string'))throw Error()}catch{return json(400,{error:'Provide nonsecret setup details.'})}
|
|
142
|
+
try{draft=validate(draft)}catch{return json(400,{error:'Provide supported nonsecret setup details.'})}
|
|
143
|
+
await saveDraft({...store.state.draft,...draft},'browser',Object.keys(draft));return json(200,{saved:true});
|
|
144
|
+
}
|
|
145
|
+
if(req.method==='POST'&&path==='/api/stop'){
|
|
146
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
147
|
+
if(store.state.phase!=='running'||!attemptAbort)return json(409,{error:'No installation is running.'});
|
|
148
|
+
attemptAbort.abort();void attemptProcesses?.stop();return json(202,{stopping:true});
|
|
149
|
+
}
|
|
150
|
+
if(req.method==='POST'&&path==='/api/start'){
|
|
151
|
+
if(req.headers.origin!==origin)return json(403,{error:'Use the local installer page.'});
|
|
152
|
+
if(starting||mutations||connection||['running','complete'].includes(store.state.phase))return json(409,{error:'Installation is already running.'});
|
|
153
|
+
starting=true;
|
|
154
|
+
try{
|
|
155
|
+
let body='';for await(const chunk of req){body+=chunk;if(Buffer.byteLength(body)>inputLimit)return json(413,{error:'Too much input.'})}
|
|
156
|
+
let input;try{const merged=vault.merge({...store.state.draft,...JSON.parse(body)},store.state.draft);await store.invalidateSecrets(vault.invalidated());input=seed.model.validateInput(merged,{...store.state.known,secretReadiness:vault.readiness(store.state.draft)})}catch(error){return json(400,{error:error.message})}
|
|
157
|
+
store.addSecrets(credentialValues(input,Object.keys(seed.model.secretScopes||{})));
|
|
158
|
+
await store.draft(input);await workspaceArt?.deliverSidebar();delete store.state.error;await store.phase('running');
|
|
159
|
+
if(abort.signal.aborted)return json(503,{error:'The installer is closing.'});
|
|
160
|
+
vault.clear();json(202,{started:true});const attempt=createAppProcesses();attemptProcesses=attempt;attemptAbort=new AbortController();
|
|
161
|
+
const cancelAttempt=()=>attemptAbort.abort();abort.signal.addEventListener('abort',cancelAttempt,{once:true});if(abort.signal.aborted)cancelAttempt();store.setCommandSignal(attemptAbort.signal);
|
|
162
|
+
work=Promise.resolve().then(()=>seed.install(root,input,store,child=>{
|
|
163
|
+
apps.register(child);attempt.register(child);
|
|
164
|
+
},attemptAbort.signal,artworkBridge)).then(async()=>{
|
|
165
|
+
// Only a completed adapter may renew an explicitly supplied credential.
|
|
166
|
+
// Hidden or failed credentials keep their durable invalidation.
|
|
167
|
+
if(store.state.phase==='complete')await store.acceptSecrets(input);
|
|
168
|
+
}).catch(async(error)=>{
|
|
169
|
+
attemptAbort.abort();await Promise.all([attempt.stop(),store.stopCommands(attemptAbort.signal)]);store.state.error=installationFailureMessage(error);await store.phase('failed');
|
|
170
|
+
}).finally(()=>{abort.signal.removeEventListener('abort',cancelAttempt);for(const key of Object.keys(seed.model.secretScopes||{}))input[key]='';});void work;return;
|
|
171
|
+
}finally{starting=false;}
|
|
172
|
+
}
|
|
173
|
+
return json(404,{error:'Unknown installer action.'});
|
|
174
|
+
}
|
|
175
|
+
if(req.method==='GET'&&path==='/rer-setup.json'){res.writeHead(200,{'Content-Type':'application/json'});res.end(seed.manifestBytes);return;}
|
|
176
|
+
if(req.method==='GET'&&path==='/SETUP.schema.json'){res.writeHead(200,{'Content-Type':'application/json'});res.end(seed.schemaBytes);return;}
|
|
177
|
+
if(req.method==='GET'&&path==='/model-options.json'){json(200,seed.modelOptions);return;}
|
|
178
|
+
const themeFile=req.method==='GET'&&theme?.files.get(path);
|
|
179
|
+
if(themeFile){res.writeHead(200,{'Content-Type':themeFile.type});res.end(themeFile.bytes);return;}
|
|
180
|
+
if(req.method!=='GET'||!assets[path]||(path==='/theme.css'&&!theme))return json(404,{error:'Not found.'});
|
|
181
|
+
const [file,type]=assets[path];let content=await readFile(file?(path==='/seed.mjs'?join(root,file):join(uiRoot,file)):join(root,seed.background));
|
|
182
|
+
if(path==='/'){content=content.toString().replaceAll('{{product}}',String(seed.product).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))).replace('</head>',`${accentStyle}<meta name="installer-token" content="${token}"></head>`);if(theme)content=content.replace('<html lang="en">','<html lang="en" class="rer-themed">').replace('<link rel="stylesheet" href="/style.css">','<link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/rer-theme.css"><link rel="stylesheet" href="/theme.css">');res.setHeader('Set-Cookie',`rer_installer=${cookie}; Path=/; HttpOnly; SameSite=Strict`);res.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'")}
|
|
183
|
+
res.writeHead(200,{'Content-Type':type});res.end(content);
|
|
184
|
+
}catch{json(500,{error:'The installer could not complete this request.'})}finally{if(mutation)mutations--;}
|
|
185
|
+
});
|
|
186
|
+
await new Promise(resolve=>server.listen(port,'127.0.0.1',resolve));origin=`http://127.0.0.1:${server.address().port}`;
|
|
187
|
+
return {origin,state:store.state,close:async()=>{artworkBridge?.close();vault.clear();abort.abort();const stopped=apps.stop();await work;await stopped;await apps.stop();await connection;await store.closeCommands();await new Promise(resolve=>{server.close(resolve);server.closeIdleConnections();setTimeout(()=>server.closeAllConnections(),connectionGraceMs).unref();})}};
|
|
188
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {parseSetupManifestBytes, validateSetupManifest, setupConditionMatches, validateSetupValue} from './schema/setup-validation.mjs';
|
|
2
|
+
import {createModel} from './ui/model-core.mjs';
|
|
3
|
+
import {installerInputs} from './installer-inputs.mjs';
|
|
4
|
+
|
|
5
|
+
const legacy=['fields','safeKeys','setupGroups','requiredKeys','secretScopes','summaryDefaults','initialValues','installInput'];
|
|
6
|
+
export function refuseLegacySetup(value) {
|
|
7
|
+
for(const source of [value,value?.model,value?.presentation])
|
|
8
|
+
for(const name of legacy)if(source&&Object.hasOwn(source,name))throw Error(`Declare ${name} in rer-setup.json, not the seed or browser module.`);
|
|
9
|
+
if(value?.settings?.generate!==undefined)throw Error('Declare generated secrets in rer-setup.json.');
|
|
10
|
+
if(value?.features?.email!==undefined)throw Error('Declare email delivery inputs in rer-setup.json; features.email is retired.');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readSetupManifestBytes(bytes,schemaBytes,file='rer-setup.json') {
|
|
14
|
+
try {
|
|
15
|
+
const manifest=parseSetupManifestBytes(bytes),schema=JSON.parse(new TextDecoder().decode(schemaBytes));
|
|
16
|
+
const errors=validateSetupManifest(schema,manifest);
|
|
17
|
+
if(errors.length)throw Error(errors.join('; '));
|
|
18
|
+
return manifest;
|
|
19
|
+
}catch(error){throw Error(`Invalid setup manifest ${file}: ${error.message}`);}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Browser and server call this with the same validated committed bytes.
|
|
23
|
+
export function createInstallerModel(manifest,presentation={},options={}) {
|
|
24
|
+
refuseLegacySetup(presentation);
|
|
25
|
+
const {ownerBootstrap=false,supplied=[]}=options;
|
|
26
|
+
for(const field of manifest.fields)if(field.kind==='secret'&&field.input!=='entered'&&!field.generate&&
|
|
27
|
+
(field.required||field.presentations?.some(item=>item.required))&&!supplied.includes(field.name))throw Error(`rer-setup.json: required managed secret ${field.name} needs adapter supplied declaration.`);
|
|
28
|
+
const own=installerInputs({...presentation.features,ownerBootstrap});
|
|
29
|
+
// Compile browser roles once from the manifest, preserving its input names.
|
|
30
|
+
// The first required text field names the workspace; URL/colour fields provide
|
|
31
|
+
// artwork context. Installer-owned hosting inputs are outside that context.
|
|
32
|
+
const nameKey=manifest.fields.find(field=>field.kind==='text'&&field.required)?.name;
|
|
33
|
+
const fields={},secretScopes={...own.secretScopes};
|
|
34
|
+
function active(field,draft){
|
|
35
|
+
if(field.when&&!setupConditionMatches(field.when,draft))return null;
|
|
36
|
+
if(!field.presentations)return field;
|
|
37
|
+
const matched=field.presentations.find(item=>setupConditionMatches(item.when,draft));
|
|
38
|
+
if(!matched)return null;
|
|
39
|
+
const {when,...metadata}=matched;return {...field,...metadata};
|
|
40
|
+
}
|
|
41
|
+
for(const field of manifest.fields){
|
|
42
|
+
fields[field.name]={
|
|
43
|
+
...field,type:field.kind==='secret'?'password':field.kind==='colour'?'color':field.kind==='email'?'email':field.kind==='url'?'url':'text',
|
|
44
|
+
artwork:field.name===nameKey||['url','colour'].includes(field.kind),
|
|
45
|
+
autocomplete:field.kind==='secret'&&field.input==='entered'?'new-password':'off',
|
|
46
|
+
optional:!field.required,options:field.options?.map(option=>[option.value,option.label]),
|
|
47
|
+
valid(value){if(typeof value!=='string'||!value)return false;try{validateSetupValue(field,value);return true;}catch{return false;}},
|
|
48
|
+
visible:draft=>Boolean(active(field,draft))&& (field.kind!=='secret'||field.input==='entered'),
|
|
49
|
+
definition(draft){const current=active(field,draft)??field;return {...fields[field.name],...current,type:fields[field.name].type,options:fields[field.name].options,optional:!current.required};},
|
|
50
|
+
};
|
|
51
|
+
if(field.kind==='secret'&&field.input==='entered')secretScopes[field.name]=[...new Set([field.when?.field,...(field.presentations??[]).map(item=>item.when.field)].filter(Boolean))];
|
|
52
|
+
}
|
|
53
|
+
for(const key of Object.keys(own.fields))if(Object.hasOwn(fields,key))throw Error(`rer-setup.json: ${key} conflicts with an installer input.`);
|
|
54
|
+
Object.assign(fields,own.fields);
|
|
55
|
+
const groupIds=new Set(manifest.groups.map(group=>group.id));
|
|
56
|
+
const generatedId=preferred=>{let id=preferred,index=1;while(groupIds.has(id))id=preferred+'-'+index++;groupIds.add(id);return id;};
|
|
57
|
+
const groups=[...(manifest.fields.some(field=>!field.group)?[{id:generatedId('ungrouped'),title:manifest.title,keys:manifest.fields.filter(field=>!field.group).map(field=>field.name)}]:[]),
|
|
58
|
+
...manifest.groups.map(group=>({...group,title:group.label,keys:manifest.fields.filter(field=>field.group===group.id).map(field=>field.name)})),...own.groups.map(group=>({...group,id:generatedId(group.id)}))];
|
|
59
|
+
const safeKeys=Object.keys(fields).filter(key=>fields[key].type!=='password');
|
|
60
|
+
const defaults=Object.fromEntries(manifest.fields.filter(field=>field.kind!=='secret'&&field.default!==undefined).map(field=>[field.name,field.default]));
|
|
61
|
+
const internal={...presentation,product:presentation.product??{name:manifest.title},fields,safeKeys,secretScopes,setupGroups:groups,
|
|
62
|
+
summaryDefaults:Object.keys(defaults),initialValues:()=>defaults,nameKey,
|
|
63
|
+
requiredKeys(draft){return [...manifest.fields.filter(field=>{const current=active(field,draft);return current?.required&&(field.kind!=='secret'||field.input==='entered');}).map(field=>field.name),...Object.keys(own.fields).filter(key=>!own.fields[key].optional)];},
|
|
64
|
+
};
|
|
65
|
+
const model=createModel(internal);
|
|
66
|
+
model.secretScopes=secretScopes;
|
|
67
|
+
model.nameKey=internal.nameKey;
|
|
68
|
+
model.fieldVisible=(key,draft)=>fields[key]?.visible?.(draft)??true;
|
|
69
|
+
model.fieldDefinition=(key,draft)=>fields[key]?.definition?.(draft)??fields[key];
|
|
70
|
+
const originalSummaryKeys=model.summaryKeys;
|
|
71
|
+
model.summaryKeys=(draft,state)=>originalSummaryKeys(draft,state).filter(key=>model.fieldVisible(key,draft));
|
|
72
|
+
model.groupDefinition=(group,draft)=>{const variant=group.presentations?.find(item=>setupConditionMatches(item.when,draft));return {...group,...variant,title:variant?.label??group.title};};
|
|
73
|
+
model.groupKeys=(group,draft)=>group.keys.filter(key=>model.fieldVisible(key,draft)).map((key,index)=>({key,index,order:model.fieldDefinition(key,draft).order??index})).sort((a,b)=>a.order-b.order||a.index-b.index).map(item=>item.key);
|
|
74
|
+
model.validateInput=(input,known={})=>{
|
|
75
|
+
if(!input||typeof input!=='object'||Array.isArray(input))throw Error('Provide setup inputs from rer-setup.json.');
|
|
76
|
+
const draft={...defaults,...input};
|
|
77
|
+
for(const [key,value] of Object.entries(input)){
|
|
78
|
+
if(!Object.hasOwn(fields,key))throw Error(`Undeclared setup input ${key} in rer-setup.json.`);
|
|
79
|
+
if(value!==''&&value!==undefined&&!fields[key].valid(value))throw Error(`Invalid setup input ${key}.`);
|
|
80
|
+
}
|
|
81
|
+
const readiness=model.setupModel(draft,known);
|
|
82
|
+
if(!readiness.ready)throw Error(`Provide required setup inputs: ${[...readiness.missing,...readiness.connections].join(', ')}.`);
|
|
83
|
+
return model.installInput(draft);
|
|
84
|
+
};
|
|
85
|
+
return model;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The Node branch is loaded only by the server/CLI, never by the browser.
|
|
89
|
+
export async function loadInstallerSeed(root,seed) {
|
|
90
|
+
refuseLegacySetup(seed);
|
|
91
|
+
const file=seed?.setup?.manifestFile;
|
|
92
|
+
const {readFile,lstat}=await import('node:fs/promises');
|
|
93
|
+
const {resolve,join}=await import('node:path');
|
|
94
|
+
const {pathToFileURL}=await import('node:url');
|
|
95
|
+
const privateName=/^(?:\.env(?:\..*)?|\.dev\.vars(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.?netrc|_netrc|\.pypirc|\.git-credentials|\.gitconfig|\.dockercfg|(?:owner|providers)\.json|credentials(?:\.(?:json|ini|toml|yaml|yml))?|id_(?:rsa|ed25519|ecdsa|dsa)(?:\..*)?|.*\.(?:pem|key|p12|pfx))$/i;
|
|
96
|
+
const privateDirectories=new Set(['.git','.seed','.ssh','.aws','.wrangler','node_modules']);
|
|
97
|
+
const reserved=new Set(['release-provenance.json','rer-broker-entry.js']);
|
|
98
|
+
if(typeof file!=='string'||!/\.json$/i.test(file)||file.length>512||/^[a-z]:/i.test(file)||/[\x00-\x1f\x7f"\\]/.test(file)||file.split('/').some(part=>!part||part==='.'||part==='..'||privateName.test(part)||privateDirectories.has(part.toLowerCase())||reserved.has(part.toLowerCase())))throw Error(`Select a root-relative JSON setup manifest ${file??'rer-setup.json'} without .. or symlinks.`);
|
|
99
|
+
let bytes;
|
|
100
|
+
try{
|
|
101
|
+
let path=resolve(root);const parts=file.split('/');
|
|
102
|
+
for(const [index,part]of parts.entries()){
|
|
103
|
+
path=join(path,part);const stat=await lstat(path);
|
|
104
|
+
if(stat.isSymbolicLink())throw Error('symlink refused');
|
|
105
|
+
if(index<parts.length-1?!stat.isDirectory():!stat.isFile())throw Error('expected a regular manifest file');
|
|
106
|
+
}
|
|
107
|
+
bytes=await readFile(path);
|
|
108
|
+
}catch(error){throw Error(`Cannot read setup manifest ${file}: ${error.message}`);}
|
|
109
|
+
const schemaBytes=await readFile(new URL('./schema/SETUP.schema.json',import.meta.url));
|
|
110
|
+
const manifest=readSetupManifestBytes(bytes,schemaBytes,file);
|
|
111
|
+
let presentation=seed.presentation??{product:{name:seed.product},features:seed.features};
|
|
112
|
+
if(seed.browserModule){presentation=await import(pathToFileURL(join(resolve(root),seed.browserModule)).href+'?installer='+Date.now());refuseLegacySetup(presentation);}
|
|
113
|
+
const options={ownerBootstrap:seed.ownerBootstrap===true,supplied:seed.supplied??[]};
|
|
114
|
+
const model=createInstallerModel(manifest,presentation,options);
|
|
115
|
+
return {...seed,manifest,manifestBytes:bytes,schemaBytes,model,modelOptions:options,features:presentation.features??seed.features??{}};
|
|
116
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { credentialFingerprint } from './secrets.mjs';
|
|
2
|
+
import { createCommands } from './commands.mjs';
|
|
3
|
+
import { createActivity } from './activity.mjs';
|
|
4
|
+
import { rename } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { privateDirectory, readPrivateFile, writePrivateFile } from './private-files.mjs';
|
|
7
|
+
export async function createState(root,seed) {
|
|
8
|
+
const fields=seed.model.safeKeys;
|
|
9
|
+
const directory=await privateDirectory(root,['.seed','installer']);
|
|
10
|
+
const file=join(directory,'state.json'); let saved={};
|
|
11
|
+
try { const value=JSON.parse(await readPrivateFile(file)); if(value&&typeof value==='object') saved=value; } catch(error) { if(error.code!=='ENOENT' && !(error instanceof SyntaxError)) throw error; }
|
|
12
|
+
if(saved.product&&saved.product!==seed.product)throw Error('Saved installer details belong to another seed.');
|
|
13
|
+
saved.draft=Object.fromEntries(Object.entries(saved.draft||{}).filter(([key,value])=>fields.includes(key)&&typeof value==='string'));
|
|
14
|
+
// Older receipts did not distinguish suggestions from answers. Preserve their
|
|
15
|
+
// saved owner details; newly written receipts carry the exact answer set.
|
|
16
|
+
const answered=(Array.isArray(saved.answered)?saved.answered:Object.keys(saved.draft)).filter(key=>typeof key==='string'&&Object.hasOwn(saved.draft,key));
|
|
17
|
+
const state={phase:'ready',step:'Ready to install',logs:[],answered:[...new Set(answered)],sources:Object.fromEntries(Object.entries(saved.sources||{}).filter(([key,value])=>fields.includes(key)&&['agent','browser','saved'].includes(value))),lastUpdate:{revision:Number.isSafeInteger(saved.lastUpdate?.revision)?saved.lastUpdate.revision:0,source:'saved',keys:[]},draft:saved.draft||{},known:{product:seed.product,root,nodeVersion:process.version},...(saved.phase==='running'?{error:'The previous run was interrupted. Review the details before starting again.'}:{})};
|
|
18
|
+
// Remember invalidated credential identities privately, never their bytes.
|
|
19
|
+
// A replacement written to settings has a different identity and can resume.
|
|
20
|
+
const invalidatedSecrets=new Map((Array.isArray(saved.invalidatedSecrets)?saved.invalidatedSecrets:[]).filter(item=>item&&Object.hasOwn(seed.model.secretScopes||{},item.name)&&/^[a-f0-9]{64}$/.test(item.fingerprint)).map(item=>[item.name+':'+item.fingerprint,{name:item.name,fingerprint:item.fingerprint}]));
|
|
21
|
+
const activity=createActivity(state,fields);
|
|
22
|
+
const commands=await createCommands(directory,state);
|
|
23
|
+
let pending=Promise.resolve();
|
|
24
|
+
const persist=()=>{const content=JSON.stringify({product:seed.product,phase:state.phase,draft:state.draft,answered:state.answered,sources:state.sources,lastUpdate:state.lastUpdate,invalidatedSecrets:[...invalidatedSecrets.values()]});pending=pending.then(async()=>{await writePrivateFile(file+'.tmp',content);await rename(file+'.tmp',file)});return pending};
|
|
25
|
+
return {state,activity,...commands,
|
|
26
|
+
isInvalidatedSecret(name,value){return typeof value==='string'&&invalidatedSecrets.has(name+':'+credentialFingerprint(value));},
|
|
27
|
+
async invalidateSecrets(keys){
|
|
28
|
+
let changed=false;for(const item of keys){const key=item.name+':'+item.fingerprint;if(!invalidatedSecrets.has(key)){invalidatedSecrets.set(key,item);changed=true;}}
|
|
29
|
+
if(changed)await persist();else await pending;
|
|
30
|
+
},
|
|
31
|
+
async acceptSecrets(input){
|
|
32
|
+
let changed=false;for(const name of Object.keys(seed.model.secretScopes||{}))if(typeof input[name]==='string')changed=invalidatedSecrets.delete(name+':'+credentialFingerprint(input[name]))||changed;
|
|
33
|
+
if(changed)await persist();else await pending;
|
|
34
|
+
},
|
|
35
|
+
async draft(input,source='browser',updatedKeys=Object.keys(input)){
|
|
36
|
+
const keys=updatedKeys.filter(key=>fields.includes(key)&&typeof input[key]==='string');
|
|
37
|
+
for(const key of keys)if(source==='agent'||input[key]!==state.draft[key])state.sources[key]=source;
|
|
38
|
+
state.lastUpdate={source,keys,revision:state.lastUpdate.revision+1};
|
|
39
|
+
state.answered=[...new Set([...state.answered,...keys])].filter(key=>fields.includes(key)&&typeof input[key]==='string');
|
|
40
|
+
state.draft=Object.fromEntries(fields.filter(key=>typeof input[key]==='string').map(key=>[key,input[key]]));
|
|
41
|
+
await persist();if(source==='agent')activity.filled(keys);
|
|
42
|
+
},
|
|
43
|
+
async step(label){label=commands.redact(label);state.step=label;commands.report(label);activity.step(label);await persist()},
|
|
44
|
+
async phase(phase){state.phase=phase;activity.phase(phase);await persist()}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { readTheme } from './theme.mjs';
|
|
4
|
+
|
|
5
|
+
const types = { '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf', '.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp' };
|
|
6
|
+
const limit = 8 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
async function regularFile(root, relative) {
|
|
9
|
+
let path = resolve(root);
|
|
10
|
+
const parts = relative.split('/');
|
|
11
|
+
for (const [index, part] of parts.entries()) {
|
|
12
|
+
path = join(path, part);
|
|
13
|
+
const stat = await lstat(path);
|
|
14
|
+
if (stat.isSymbolicLink()) throw Error(`${relative} is a symlink.`);
|
|
15
|
+
if (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) throw Error(`${relative} is not a regular file.`);
|
|
16
|
+
}
|
|
17
|
+
return readFile(path);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `file` is root-relative and sits at the root of the directory the app serves
|
|
21
|
+
// as "/", so every url("/path") in the theme names a file beside it there.
|
|
22
|
+
// Returns the served files keyed by installer path, read once at start.
|
|
23
|
+
export async function loadSeedTheme(root, file) {
|
|
24
|
+
if (typeof file !== 'string' || !/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.css$/.test(file) || file.split('/').some(part => part === '..' || part === '.'))
|
|
25
|
+
throw Error(`Declare the seed theme as a root-relative .css path without .. (got ${JSON.stringify(file)}).`);
|
|
26
|
+
const text = (await regularFile(root, file).catch(error => { throw Error(`Cannot read seed theme ${file}: ${error.message}`); })).toString('utf8');
|
|
27
|
+
const theme = readTheme(text, file), served = dirname(file);
|
|
28
|
+
const files = new Map([['/rer-theme.css', { type: 'text/css', bytes: Buffer.from(text) }]]);
|
|
29
|
+
let total = text.length;
|
|
30
|
+
for (const path of theme.assets) {
|
|
31
|
+
const bytes = await regularFile(root, join(served, path.slice(1))).catch(error => { throw Error(`Seed theme ${file} names ${path}, which the app does not serve from ${served}/: ${error.message}`); });
|
|
32
|
+
total += bytes.length;
|
|
33
|
+
if (total > limit) throw Error(`Seed theme ${file} and its files exceed 8 MiB.`);
|
|
34
|
+
files.set(path, { type: types[path.slice(path.lastIndexOf('.'))], bytes });
|
|
35
|
+
}
|
|
36
|
+
return { ...theme, files };
|
|
37
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A seed theme is one plain CSS file that the seed's app already serves:
|
|
2
|
+
// `:root { --rer-*: value }` tokens plus `@font-face` rules for font files the
|
|
3
|
+
// app serves itself. Base frames link that file; this module is the one place
|
|
4
|
+
// that reads it, refuses anything outside the grammar and checks contrast.
|
|
5
|
+
const colour = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
6
|
+
const family = String.raw`(?:"[A-Za-z0-9 _-]+"|'[A-Za-z0-9 _-]+'|[A-Za-z][A-Za-z0-9 _-]*)`;
|
|
7
|
+
const stack = new RegExp(String.raw`^${family}(?:\s*,\s*${family})*$`);
|
|
8
|
+
const assetPath = /^\/(?!.*\/\/)(?!.*\/\.)[A-Za-z0-9._~/-]{1,200}$/;
|
|
9
|
+
const url = /^url\(\s*(["']?)([^"')]+)\1\s*\)$/;
|
|
10
|
+
|
|
11
|
+
export const themeTokens = Object.freeze({
|
|
12
|
+
'--rer-page': 'colour', '--rer-surface': 'colour', '--rer-text': 'colour', '--rer-muted': 'colour',
|
|
13
|
+
'--rer-border': 'colour', '--rer-focus': 'colour', '--rer-accent': 'colour', '--rer-accent-text': 'colour',
|
|
14
|
+
'--rer-link': 'colour', '--rer-font-body': 'stack', '--rer-font-heading': 'stack', '--rer-radius': 'radius', '--rer-logo': 'logo',
|
|
15
|
+
});
|
|
16
|
+
const required = ['--rer-page', '--rer-surface', '--rer-text', '--rer-muted', '--rer-border', '--rer-focus'];
|
|
17
|
+
|
|
18
|
+
// WCAG 2.2: 4.5:1 for text, 3:1 for focus indicators and control boundaries.
|
|
19
|
+
const pairs = [
|
|
20
|
+
['--rer-text', '--rer-page', 4.5], ['--rer-text', '--rer-surface', 4.5], ['--rer-muted', '--rer-surface', 4.5], ['--rer-muted', '--rer-page', 4.5],
|
|
21
|
+
['--rer-link', '--rer-surface', 4.5], ['--rer-accent-text', '--rer-accent', 4.5],
|
|
22
|
+
['--rer-focus', '--rer-page', 3], ['--rer-focus', '--rer-surface', 3], ['--rer-border', '--rer-surface', 3],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const faceDescriptors = {
|
|
26
|
+
'font-family': value => new RegExp(`^${family}$`).test(value),
|
|
27
|
+
'font-style': value => /^(?:normal|italic|oblique)$/.test(value),
|
|
28
|
+
'font-weight': value => /^(?:normal|bold|\d{1,3}(?:\s+\d{1,3})?)$/.test(value),
|
|
29
|
+
'font-display': value => /^(?:auto|block|swap|fallback|optional)$/.test(value),
|
|
30
|
+
'unicode-range': value => /^U\+[0-9A-F?]{1,6}(?:-[0-9A-F]{1,6})?(?:\s*,\s*U\+[0-9A-F?]{1,6}(?:-[0-9A-F]{1,6})?)*$/i.test(value),
|
|
31
|
+
'size-adjust': value => /^\d{1,3}(?:\.\d+)?%$/.test(value),
|
|
32
|
+
'ascent-override': value => /^(?:normal|\d{1,3}(?:\.\d+)?%)$/.test(value),
|
|
33
|
+
'descent-override': value => /^(?:normal|\d{1,3}(?:\.\d+)?%)$/.test(value),
|
|
34
|
+
'line-gap-override': value => /^(?:normal|\d{1,3}(?:\.\d+)?%)$/.test(value),
|
|
35
|
+
src: null,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function asset(value, extensions, what) {
|
|
39
|
+
const path = url.exec(value)?.[2];
|
|
40
|
+
if (!path || !assetPath.test(path) || !extensions.test(path)) throw Error(`${what} must be url("/path") to a file the app serves (${extensions.source.match(/\w+/g).join(', ')}).`);
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function declarations(body, where) {
|
|
45
|
+
const result = [];
|
|
46
|
+
for (const part of body.split(';')) {
|
|
47
|
+
if (!part.trim()) continue;
|
|
48
|
+
const index = part.indexOf(':');
|
|
49
|
+
if (index < 0) throw Error(`${where}: expected "name: value" in "${part.trim().slice(0, 60)}".`);
|
|
50
|
+
const name = part.slice(0, index).trim(), value = part.slice(index + 1).trim().replace(/\s+/g, ' ');
|
|
51
|
+
if (!name || !value) throw Error(`${where}: empty declaration.`);
|
|
52
|
+
if (result.some(entry => entry[0] === name)) throw Error(`${where}: ${name} is declared twice.`);
|
|
53
|
+
result.push([name, value]);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function luminance(hex) {
|
|
59
|
+
const full = hex.length === 4 ? '#' + [...hex.slice(1)].map(c => c + c).join('') : hex;
|
|
60
|
+
const [r, g, b] = [1, 3, 5].map(i => parseInt(full.slice(i, i + 2), 16) / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
61
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function contrast(a, b) {
|
|
65
|
+
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
66
|
+
return (high + 0.05) / (low + 0.05);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Returns {tokens, faces, assets}; throws one error naming every problem.
|
|
70
|
+
export function readTheme(css, file = 'rer-theme.css') {
|
|
71
|
+
if (typeof css !== 'string' || css.length > 64 * 1024) throw Error(`${file}: a theme is a CSS file of at most 64 KiB.`);
|
|
72
|
+
if (/[\\<]/.test(css)) throw Error(`${file}: escapes and "<" are not part of the theme grammar.`);
|
|
73
|
+
let rest = css.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
74
|
+
if (rest.includes('/*')) throw Error(`${file}: unterminated comment.`);
|
|
75
|
+
const tokens = {}, faces = [], assets = new Set();
|
|
76
|
+
while ((rest = rest.trim())) {
|
|
77
|
+
const block = /^(:root|@font-face)\s*\{([^{}]*)\}/.exec(rest);
|
|
78
|
+
if (!block) throw Error(`${file}: only :root { --rer-* } and @font-face { } blocks are allowed, found "${rest.slice(0, 40)}".`);
|
|
79
|
+
rest = rest.slice(block[0].length);
|
|
80
|
+
if (block[1] === ':root') {
|
|
81
|
+
for (const [name, value] of declarations(block[2], ':root')) {
|
|
82
|
+
const kind = themeTokens[name];
|
|
83
|
+
if (!kind) throw Error(`${file}: ${name} is not a theme token. Use ${Object.keys(themeTokens).join(', ')}.`);
|
|
84
|
+
if (Object.hasOwn(tokens, name)) throw Error(`${file}: ${name} is declared twice.`);
|
|
85
|
+
if (kind === 'colour' && !colour.test(value)) throw Error(`${file}: ${name} must be a #rgb or #rrggbb colour.`);
|
|
86
|
+
if (kind === 'stack' && !stack.test(value)) throw Error(`${file}: ${name} must be a font stack of family names.`);
|
|
87
|
+
if (kind === 'radius' && !/^\d{1,2}(?:\.\d+)?(?:px|rem)$/.test(value)) throw Error(`${file}: ${name} must be a length in px or rem.`);
|
|
88
|
+
if (kind === 'logo') assets.add(asset(value, /\.(?:svg|png|webp)$/, name));
|
|
89
|
+
tokens[name] = value;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
const face = Object.fromEntries(declarations(block[2], '@font-face'));
|
|
93
|
+
for (const [name, value] of Object.entries(face)) {
|
|
94
|
+
if (!Object.hasOwn(faceDescriptors, name)) throw Error(`${file}: @font-face ${name} is not supported.`);
|
|
95
|
+
if (name !== 'src' && !faceDescriptors[name](value)) throw Error(`${file}: @font-face ${name} "${value}" is not valid.`);
|
|
96
|
+
}
|
|
97
|
+
if (!face['font-family'] || !face.src) throw Error(`${file}: @font-face needs font-family and src.`);
|
|
98
|
+
for (const source of face.src.split(',').map(value => value.trim())) {
|
|
99
|
+
if (/^local\(\s*(["'])[A-Za-z0-9 _-]+\1\s*\)$/.test(source)) continue;
|
|
100
|
+
const [location, format] = source.split(/\s+(?=format\()/);
|
|
101
|
+
if (format && !/^format\(\s*(["'])(?:woff2|woff|truetype|opentype)\1\s*\)$/.test(format)) throw Error(`${file}: @font-face format ${format} is not supported.`);
|
|
102
|
+
assets.add(asset(location, /\.(?:woff2|woff|ttf|otf)$/, '@font-face src'));
|
|
103
|
+
}
|
|
104
|
+
faces.push(face);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const problems = [];
|
|
108
|
+
for (const name of required) if (!tokens[name]) problems.push(`declare ${name}`);
|
|
109
|
+
if (Boolean(tokens['--rer-accent']) !== Boolean(tokens['--rer-accent-text'])) problems.push('declare --rer-accent and --rer-accent-text together');
|
|
110
|
+
const value = name => tokens[name] ?? (name === '--rer-link' ? tokens['--rer-text'] : undefined);
|
|
111
|
+
for (const [foreground, background, minimum] of pairs) {
|
|
112
|
+
if (!value(foreground) || !value(background)) continue;
|
|
113
|
+
const ratio = contrast(value(foreground), value(background));
|
|
114
|
+
if (ratio < minimum) problems.push(`${foreground} ${value(foreground)} on ${background} ${value(background)} is ${ratio.toFixed(2)}:1, below ${minimum}:1`);
|
|
115
|
+
}
|
|
116
|
+
if (problems.length) throw Error(`${file}: ${problems.join('; ')}.`);
|
|
117
|
+
return Object.freeze({ tokens: Object.freeze(tokens), faces: Object.freeze(faces), assets: Object.freeze([...assets]) });
|
|
118
|
+
}
|