diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 2a400375c..fa80f379a 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -32,6 +32,8 @@ jobs: uses: actions/setup-node@v7 with: node-version: "24" + cache: npm + cache-dependency-path: infra/package-lock.json - name: Setup Java 17 uses: actions/setup-java@v6 @@ -54,7 +56,7 @@ jobs: /o:"thomasluizon" \ /d:sonar.token="${SONAR_TOKEN}" \ /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" \ - /d:sonar.javascript.lcov.reportPaths="coverage/web-plan.lcov,coverage/staging-pinger.lcov" \ + /d:sonar.javascript.lcov.reportPaths="coverage/web-plan.lcov,coverage/staging-pinger.lcov,coverage/web-routing.lcov" \ /d:sonar.exclusions="tools/**,**/Migrations/**,**/bin/**,**/obj/**,**/OAuth/OAuthLoginPage.cs,**/.claude/**,**/Email/Templates/**,**/load-tests/**,**/bench/**" \ /d:sonar.cpd.exclusions="**/Chat/Tools/Implementations/CreateHabitTool.cs,**/Chat/Tools/Implementations/UpdateHabitTool.cs,**/Services/AgentCatalogService.UserDataCatalog.cs,**/Services/SlipAlertSchedulerService.cs,**/Services/ProactiveCheckinSchedulerService.cs,**/Accountability/Commands/AcceptAccountabilityPairCommand.cs,**/Social/Commands/AcceptFriendRequestCommand.cs" \ /d:sonar.coverage.exclusions="**/Program.cs,**/Extensions/ServiceCollectionExtensions*.cs,**/Extensions/WebApplicationExtensions.cs,**/Middleware/**,**/OpenApi/**,**/OAuth/**,**/Persistence/OrbitDbContext.cs,**/Persistence/OrbitDbContextFactory.cs,**/Persistence/GenericRepository.cs,**/Persistence/UnitOfWork.cs,**/Persistence/AccountResetRepository.cs,**/Configuration/**,**/Services/ReminderSchedulerService.cs,**/Services/SlipAlertSchedulerService.cs,**/Services/GoalDeadlineNotificationService.cs,**/Services/HabitDueDateAdvancementService.cs,**/Services/DataEncryptionMigrationService.cs,**/Services/PushNotificationService.cs,**/Services/AccountDeletionService.cs,**/Services/GoogleTokenService.cs,**/Services/BackgroundServiceHealthCheck.cs,**/Services/StripeCouponRewardService.cs,**/Services/StripeBillingService.cs,**/Services/GooglePlayBillingService.cs,**/Services/Calendar/**,**/AI/AiCompletionClient.cs,**/AI/AiBatchClient.cs,**/AI/AudioTranscriptionService.cs,**/Services/GeoLocationService.cs,**/BackgroundJobs/HangfireRecurringJobRegistrar.cs,**/Orbit.Analyzers/**,**/Orbit.Analyzers.CodeFixes/**,**/.github/**,**/bench/**" @@ -70,9 +72,11 @@ jobs: - name: Test infra scripts with coverage run: | + npm ci --prefix infra --ignore-scripts mkdir -p coverage node --experimental-test-coverage --test --test-coverage-include=infra/check-web-plan.mjs --test-reporter=lcov --test-reporter-destination=coverage/web-plan.lcov infra/check-web-plan.test.mjs node --experimental-test-coverage --test --test-coverage-include=infra/workers/staging-pinger.mjs --test-reporter=lcov --test-reporter-destination=coverage/staging-pinger.lcov infra/staging-pinger.test.mjs + node --experimental-test-coverage --test --test-coverage-include=infra/workers/web-routing.mjs --test-reporter=lcov --test-reporter-destination=coverage/web-routing.lcov infra/web-routing.test.mjs - name: SonarScanner End env: diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 3edf5b761..9d2fd03ec 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -16,10 +16,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: infra/package-lock.json - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e with: terraform_version: 1.16.4 - run: terraform fmt -check -recursive infra - run: terraform -chdir=infra init -backend=false -input=false - run: terraform -chdir=infra validate - - run: node --test infra/check-web-plan.test.mjs infra/ses-isolation.test.mjs infra/google-play-isolation.test.mjs infra/staging-pinger.test.mjs + - run: npm ci --prefix infra --ignore-scripts + - run: npm test --prefix infra diff --git a/infra/.gitignore b/infra/.gitignore index 2c6a66cc2..4f7d90a8e 100644 --- a/infra/.gitignore +++ b/infra/.gitignore @@ -3,3 +3,4 @@ *.tfplan *.tfvars !example.tfvars +node_modules/ diff --git a/infra/README.md b/infra/README.md index 3f9bdb026..b71a2bf45 100644 --- a/infra/README.md +++ b/infra/README.md @@ -186,7 +186,7 @@ Terraform creates `/orbit/production/api/BotProtection__SecretKey` and `/orbit/s ## DNS cutover -The Cloudflare zone uses full DNS setup on the Free plan. The existing records have automatic TTL and remain unproxied. At cutover, use these values in `infra/local.tfvars`: +The Cloudflare zone uses full DNS setup on the Free plan. The existing records have automatic TTL. Only `app.useorbit.org` is proxied for production web design routing; the other existing records remain unproxied. At cutover, use these values in `infra/local.tfvars`: ```hcl dns_apex_target = "orbit-landing-aaa7.onrender.com" @@ -241,3 +241,30 @@ The initial plan must keep `srv-d6tc2isr85hc739bf75g`, its URL, and `api.useorbi For the production database cutover, first confirm the production data is present in the Render database shown on the Render Postgres Connections screen. Set `api_database = "render"` in `infra/local.tfvars` and run a fresh plan. Confirm both `ConnectionStrings__DefaultConnection` and `ConnectionStrings__SessionConnection` in the production API environment group name `orbit_production_9g8l`, as derived from that Render connection URL. Confirm `render_postgres.production` is unchanged and neither web service nor the imported API service changes in place. Apply the plan only after those checks. Verify the linked environment group and production API health, then remove the production API service's duplicated direct variables in the Render service Environment screen, leaving `ORBIT_TERRAFORM_ENV_GROUP`. Confirm the service still uses the Render database after the direct variables are removed. Staging reads Stripe test-mode product and price IDs from SSM and uses staging return URLs. `Supabase__Url` points at an invalid host so staging cannot write to production storage. The web image must exist at both selected digests before the first apply. Set production web and landing custom domains during cutover. DNS and certificate verification follow the domain changes. + +## Production web design routing + +`web-routing.tf` attaches `orbit-web-routing` to `app.useorbit.org/*`. Only the `app_cname` record is proxied. Its DNS target and the current service's `app.useorbit.org` custom domain remain the fallback: removing the Worker route sends every request to the current build, including requests carrying the design cookie. Keep `dns_app_target = "orbit-web-3qmv.onrender.com"` and `web_custom_domains = ["app.useorbit.org"]` in the existing ignored `local.tfvars`. + +An exact `orbit_design=redesign` cookie selects `orbit-web-redesign`; every other value selects the current service. The current route forwards the original request to the DNS origin without changing its public Host. The redesign route uses the redesign Render hostname for its URL and Host, sets `X-Forwarded-Host: app.useorbit.org` and `X-Forwarded-Proto: https`, and preserves the path, query, method, streaming body, and other headers. Responses pass through with their status, body, cookies, and redirect headers. Each origin has its own fetch URL and cache key; the Worker adds no shared response cache. The cookie selects a UI build and grants no API permissions. + +Both production web services link to `orbit-production-web`, including `NEXT_PUBLIC_SITE_URL=https://app.useorbit.org`. The new service uses the current service's image repository, plan, region, health path, and digest lifecycle policy. Image-backed services do not support automatic Git deploys; the Render provider's image schema has no `auto_deploy` field. Terraform creates the initial service from `production_redesign_web_digest`, which must name an already published production-configured image built from `orbit-ui-mobile`'s `redesign/main`. Later releases use that repository's manual production `release.yml` with the branch and service selection supplied by thomasluizon/orbit-tickets#1499. Terraform ignores subsequent digest changes. The web plan guard also covers the redesign service. + +Before applying, the orchestrator must: + +1. In Cloudflare > My Profile > API Tokens, confirm the token used for `CLOUDFLARE_API_TOKEN` has account-level **Workers Scripts Write** for the Orbit account and zone-level **Workers Routes Write** and **DNS Write** for `useorbit.org`. Refresh also needs access to read those resources; the provider lists **Workers Scripts Read**, **Workers Routes Read**, and **DNS Read** as accepted read permissions. If an operation is denied, report the exact missing permission, especially **Workers Routes Write**, which is distinct from script access. A token's successful script upload does not prove route access. +2. In the existing Terraform execution environment, set `production_redesign_web_digest` in ignored `infra/local.tfvars` to the approved published redesign image digest. Check the production public host settings above. Review a saved plan and run `check-web-plan.mjs` against it. Expected scope is creation of `render_web_service.production_redesign_web`, `cloudflare_workers_script.web_routing`, and `cloudflare_workers_route.web_routing`, an update of `cloudflare_dns_record.existing["app_cname"]` to proxied, and the necessary addition to `render_env_group_link.production_web.service_ids`. Neither existing web service should change. This is an expected scope, not a recorded live plan result. +3. Apply through the existing infrastructure process. In Render > orbit-web-redesign > Environment, confirm the linked `orbit-production-web` group and public site URL. In Settings > Deploy, confirm the approved redesign image digest. Read Terraform's `production_redesign_web_service_id` and `production_redesign_web_host` outputs for #1499; that ticket owns the exact repository variable key and release workflow changes in `orbit-ui-mobile`. +4. With the UI design-cookie ticket and #1499 deployed, request `https://app.useorbit.org/api/health` with no cookie and with `Cookie: orbit_design=redesign`. Compare each actual health response with the approved `main` and `redesign/main` release commits. Remove the cookie and reload to verify the current build returns. In the public app, test email sign-in, Google sign-in, `/auth-callback`, all other configured callbacks, session renewal, and a Server Action submission on both routes. Confirm the browser stays on `app.useorbit.org` and sessions survive reloads. Local unit tests cover forwarding, not the live auth providers or Next.js runtime. +5. Measure added latency after deployment at the same Cloudflare location. Compare the routed public host with the same origin and public TLS/Host using an origin-resolved request that bypasses Cloudflare. Take interleaved repeated samples and report median time to first byte for each, then subtract direct-origin from routed latency. Use a public immutable asset after warming it for the cached case and confirm cache hits from the actual responses; use `/api/health` with a unique query for the uncached case and confirm it is not cached. Record sample count, cache evidence, and both added latency numbers. Miniflare timings do not measure deployed edge overhead. +6. In Cloudflare > Workers & Pages > orbit-web-routing > Settings > Domains & Routes, delete only the `app.useorbit.org/*` route to roll back immediately. Verify both cookie variants now return the current build's health commit. Preserve the proxied DNS record and current service's custom domain. Remove the route from Terraform configuration before a subsequent apply that should retain the rollback. + +The DNS cutover script checks unproxied CNAME answers and is intended for the registrar cutover before this routing change. A proxied `app` returns Cloudflare addresses; use the health and fallback checks above after enabling the proxy. + +### Interface evidence + +The pinned Cloudflare provider's live [Worker script schema](https://github.com/cloudflare/terraform-provider-cloudflare/blob/v5.26.0/docs/resources/workers_script.md), [Worker route schema](https://github.com/cloudflare/terraform-provider-cloudflare/blob/v5.26.0/docs/resources/workers_route.md), and [DNS record schema](https://github.com/cloudflare/terraform-provider-cloudflare/blob/v5.26.0/docs/resources/dns_record.md) confirm the resource names, plain-text bindings, route `script` and `pattern`, and `proxied` fields. Cloudflare's [route documentation](https://developers.cloudflare.com/workers/configuration/routing/routes/) requires a proxied DNS record; its [Request contract](https://developers.cloudflare.com/workers/runtime-apis/request/) describes cloning a request while changing the URL and headers. Render's [image deployment documentation](https://render.com/docs/deploying-an-image) confirms image-backed services have no automatic image redeploys. + +A real `terraform providers schema -json` invocation against the installed pinned providers confirmed that `render_web_service.id` and `.url` are computed strings and `runtime_source.image` accepts `image_url`, `digest`, `tag`, and `registry_credential_id`, with no auto-deploy field. Reproduce in a backend-disabled scratch root containing `versions.tf`'s provider declarations without its S3 backend, or against the initialized backend. No Render API response fields are read by this change. + +Run `npm ci --prefix infra` and `npm test --prefix infra` for the infrastructure tests. The routing tests execute the deployed module in Miniflare with outbound requests intercepted and Cloudflare metadata fetching disabled. The [Miniflare testing documentation](https://developers.cloudflare.com/workers/testing/miniflare/) confirms the simulator's test API. Installed `miniflare/dist/src/index.d.ts` confirms `outboundService` accepts a Request callback returning a Response, `bindings` supplies environment values, and `dispatchFetch` invokes the Worker. Its implementation in `dist/src/index.js` dispatches outbound requests to that callback. These requests, rather than invented API fixtures, supply the assertions. The locked test dependencies override `undici` and `sharp` to patched versions; no testing dependency is bundled into the Worker uploaded by Terraform. diff --git a/infra/check-web-plan.mjs b/infra/check-web-plan.mjs index bc0f33dcd..3cec10198 100644 --- a/infra/check-web-plan.mjs +++ b/infra/check-web-plan.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url" const guardedAddresses = new Set([ "render_web_service.production_web", + "render_web_service.production_redesign_web", "render_web_service.staging_web", ]) diff --git a/infra/check-web-plan.test.mjs b/infra/check-web-plan.test.mjs index 9f52019db..54a8fd874 100644 --- a/infra/check-web-plan.test.mjs +++ b/infra/check-web-plan.test.mjs @@ -25,15 +25,17 @@ test("allows an unrelated service update", () => { assert.deepEqual(checkPlan(fixture("unrelated-update.json")), []) }) -test("guards the production address and rejects replacement", () => { - const update = fixture("web-update.json") - update.resource_changes[0].address = "render_web_service.production_web" - assert.match(checkPlan(update)[0], /render_web_service\.production_web/) - update.resource_changes[0].change.actions = ["delete", "create"] - assert.match(checkPlan(update)[0], /replacement/) - update.resource_changes[0].change.actions = ["create", "delete"] - assert.match(checkPlan(update)[0], /replacement/) -}) +for (const address of ["render_web_service.production_web", "render_web_service.production_redesign_web"]) { + test(`guards ${address} and rejects replacement`, () => { + const update = fixture("web-update.json") + update.resource_changes[0].address = address + assert.ok(checkPlan(update)[0].includes(address)) + update.resource_changes[0].change.actions = ["delete", "create"] + assert.match(checkPlan(update)[0], /replacement/) + update.resource_changes[0].change.actions = ["create", "delete"] + assert.match(checkPlan(update)[0], /replacement/) + }) +} test("rejects deletion of a web service", () => { const deletion = fixture("web-update.json") diff --git a/infra/cloudflare.tf b/infra/cloudflare.tf index 6b5e33f3b..b5d2badbf 100644 --- a/infra/cloudflare.tf +++ b/infra/cloudflare.tf @@ -94,7 +94,7 @@ resource "cloudflare_dns_record" "existing" { content = each.value.content priority = try(each.value.priority, null) ttl = 1 - proxied = false + proxied = each.key == "app_cname" } resource "cloudflare_dns_record" "staging" { diff --git a/infra/example.tfvars b/infra/example.tfvars index c5cca50fe..126ab02ac 100644 --- a/infra/example.tfvars +++ b/infra/example.tfvars @@ -1,4 +1,5 @@ production_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" +production_redesign_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" staging_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" production_ses_dlq_alert_email = "" staging_ses_dlq_alert_email = "" diff --git a/infra/package-lock.json b/infra/package-lock.json new file mode 100644 index 000000000..b82b33a8a --- /dev/null +++ b/infra/package-lock.json @@ -0,0 +1,1001 @@ +{ + "name": "orbit-infra-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "orbit-infra-tests", + "devDependencies": { + "miniflare": "4.20260730.0" + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz", + "integrity": "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260730.1.tgz", + "integrity": "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz", + "integrity": "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz", + "integrity": "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz", + "integrity": "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260730.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260730.0.tgz", + "integrity": "sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260730.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/workerd": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260730.1.tgz", + "integrity": "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260730.1", + "@cloudflare/workerd-darwin-arm64": "1.20260730.1", + "@cloudflare/workerd-linux-64": "1.20260730.1", + "@cloudflare/workerd-linux-arm64": "1.20260730.1", + "@cloudflare/workerd-windows-64": "1.20260730.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 000000000..cee109143 --- /dev/null +++ b/infra/package.json @@ -0,0 +1,15 @@ +{ + "name": "orbit-infra-tests", + "private": true, + "type": "module", + "scripts": { + "test": "node --test *.test.mjs" + }, + "devDependencies": { + "miniflare": "4.20260730.0" + }, + "overrides": { + "undici": "7.29.1", + "sharp": "0.35.5" + } +} diff --git a/infra/production.tf b/infra/production.tf index 91c1a51d9..a464e96e4 100644 --- a/infra/production.tf +++ b/infra/production.tf @@ -100,5 +100,34 @@ resource "render_env_group_link" "production_api" { resource "render_env_group_link" "production_web" { env_group_id = render_env_group.production_web.id - service_ids = [render_web_service.production_web.id] + service_ids = [render_web_service.production_web.id, render_web_service.production_redesign_web.id] +} + +resource "render_web_service" "production_redesign_web" { + environment_id = render_project.orbit.environments["Production"].id + name = "orbit-web-redesign" + plan = "0.5c-512mb" + region = "ohio" + health_check_path = var.web_health_check_path + + lifecycle { + ignore_changes = [runtime_source.image.digest] + } + + runtime_source = { + image = { + image_url = "ghcr.io/thomasluizon/orbit-web" + digest = var.production_redesign_web_digest + } + } +} + +output "production_redesign_web_service_id" { + description = "Render service ID for the manual production redesign release." + value = render_web_service.production_redesign_web.id +} + +output "production_redesign_web_host" { + description = "Render origin hostname for the production redesign web service." + value = split("/", render_web_service.production_redesign_web.url)[2] } diff --git a/infra/variables.tf b/infra/variables.tf index 8941e0603..e9f4fef2c 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -68,6 +68,16 @@ variable "production_web_digest" { } } +variable "production_redesign_web_digest" { + description = "Published GHCR digest built from orbit-ui-mobile redesign/main for initial production redesign creation." + type = string + + validation { + condition = can(regex("^sha256:[0-9a-f]{64}$", var.production_redesign_web_digest)) + error_message = "production_redesign_web_digest must be a sha256 image digest." + } +} + variable "staging_web_digest" { description = "Published GHCR digest for the staging web image." type = string diff --git a/infra/web-routing.test.mjs b/infra/web-routing.test.mjs new file mode 100644 index 000000000..fa5d19645 --- /dev/null +++ b/infra/web-routing.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict" +import { after, test } from "node:test" +import { fileURLToPath } from "node:url" +import { Miniflare, Response } from "miniflare" +import handler from "./workers/web-routing.mjs" + +const publicOrigin = "https://app.useorbit.org" +const redesignHost = "orbit-web-redesign.onrender.com" +let upstream +const worker = new Miniflare({ + modules: true, + cf: false, + scriptPath: fileURLToPath(new URL("./workers/web-routing.mjs", import.meta.url)), + compatibilityDate: "2026-07-30", + bindings: { REDESIGN_HOST: redesignHost }, + outboundService: request => upstream(request), +}) +after(() => worker.dispose()) + +for (const runtime of ["Node", "workerd"]) { + function dispatchFetch(t, url, options) { + if (runtime === "workerd") return worker.dispatchFetch(url, options) + + t.mock.method(globalThis, "fetch", request => upstream(request)) + const request = new Request(url, options) + request.headers.set("Host", new URL(url).host) + return handler.fetch(request, { REDESIGN_HOST: redesignHost }) + } + + for (const [cookie, redesign] of [ + [undefined, false], + ["orbit_design=redesign", true], + ["session=opaque; orbit_design=redesign; theme=dark", true], + ["orbit_design=current", false], + ["orbit_design=", false], + ["other_orbit_design=redesign", false], + ["orbit_design=redesign-extra", false], + ["orbit_design=Redesign", false], + ]) { + test(`${runtime}: routes cookie ${cookie ?? "absent"} to ${redesign ? "redesign" : "current"}`, async t => { + const origin = redesign ? `https://${redesignHost}` : publicOrigin + let calls = 0 + upstream = request => { + calls++ + assert.equal(request.url, `${origin}/api/health?probe=routing`) + assert.equal(request.method, "GET") + return new Response(origin) + } + const response = await dispatchFetch(t, `${publicOrigin}/api/health?probe=routing`, { + headers: cookie ? { Cookie: cookie } : {}, + }) + assert.equal(await response.text(), origin) + assert.equal(calls, 1) + }) + } + + for (const redesign of [false, true]) { + test(`${runtime}: preserves a Server Action on ${redesign ? "redesign" : "current"} with the required Host`, async t => { + const origin = redesign ? `https://${redesignHost}` : publicOrigin + const path = "/actions/save?return=%2Fprofile&tag=a&tag=b" + const headers = { + Cookie: `session=opaque; orbit_design=${redesign ? "redesign" : "current"}`, + Authorization: "Bearer test-token", + Origin: publicOrigin, + "Content-Type": "text/plain;charset=UTF-8", + "Next-Action": "action-id", + "Next-Router-State-Tree": "%5B%22%22%5D", + "X-Custom-Header": "retained", + "X-Forwarded-Host": "incoming.example", + "X-Forwarded-Proto": "http", + } + const responseHeaders = { + Location: `${publicOrigin}/profile`, + "Set-Cookie": "session=renewed; Path=/; Secure; HttpOnly; SameSite=Lax", + "Cache-Control": "private, no-store", + "X-Action-Redirect": "/profile;push", + } + let calls = 0 + upstream = async request => { + calls++ + assert.equal(request.url, `${origin}${path}`) + assert.equal(request.method, "POST") + assert.equal(await request.text(), '["payload"]') + for (const [key, value] of Object.entries(headers)) { + if (!key.startsWith("X-Forwarded-")) assert.equal(request.headers.get(key), value) + } + assert.equal(request.headers.get("Host"), redesign ? redesignHost : "app.useorbit.org") + assert.equal(request.headers.get("X-Forwarded-Host"), redesign ? "app.useorbit.org" : "incoming.example") + assert.equal(request.headers.get("X-Forwarded-Proto"), redesign ? "https" : "http") + return new Response("saved", { status: 303, headers: responseHeaders }) + } + const response = await dispatchFetch(t, `${publicOrigin}${path}`, { + method: "POST", headers, body: '["payload"]', redirect: "manual", + }) + assert.equal(response.status, 303, await response.clone().text()) + assert.equal(await response.text(), "saved") + for (const [key, value] of Object.entries(responseHeaders)) assert.equal(response.headers.get(key), value) + assert.equal(calls, 1) + }) + + test(`${runtime}: passes the auth callback query through ${redesign ? "redesign" : "current"}`, async t => { + const origin = redesign ? `https://${redesignHost}` : publicOrigin + const path = "/auth-callback?code=opaque%2Bcode&state=opaque%2Fstate" + let calls = 0 + upstream = request => { + calls++ + assert.equal(request.url, `${origin}${path}`) + assert.equal(request.method, "GET") + return new Response(null, { status: 302, headers: { Location: `${publicOrigin}/today` } }) + } + const response = await dispatchFetch(t, `${publicOrigin}${path}`, { + headers: { Cookie: redesign ? "orbit_design=redesign" : "session=opaque" }, + redirect: "manual", + }) + assert.equal(response.status, 302, await response.clone().text()) + assert.equal(response.headers.get("Location"), `${publicOrigin}/today`) + assert.equal(calls, 1) + }) + } + + test(`${runtime}: removing the design cookie returns the same path to the current build`, async t => { + for (const redesign of [true, false]) { + const origin = redesign ? `https://${redesignHost}` : publicOrigin + upstream = request => { + assert.equal(request.url, `${origin}/today`) + return new Response(origin) + } + const response = await dispatchFetch(t, `${publicOrigin}/today`, { + headers: redesign ? { Cookie: "orbit_design=redesign" } : {}, + }) + assert.equal(await response.text(), origin) + } + }) +} diff --git a/infra/web-routing.tf b/infra/web-routing.tf new file mode 100644 index 000000000..6938f7ca6 --- /dev/null +++ b/infra/web-routing.tf @@ -0,0 +1,21 @@ +resource "cloudflare_workers_script" "web_routing" { + account_id = cloudflare_zone.orbit.account.id + script_name = "orbit-web-routing" + main_module = "web-routing.mjs" + content = file("${path.module}/workers/web-routing.mjs") + compatibility_date = "2026-07-30" + + bindings = [{ + name = "REDESIGN_HOST" + type = "plain_text" + text = split("/", render_web_service.production_redesign_web.url)[2] + }] +} + +resource "cloudflare_workers_route" "web_routing" { + zone_id = cloudflare_zone.orbit.id + pattern = "app.useorbit.org/*" + script = cloudflare_workers_script.web_routing.script_name + + depends_on = [cloudflare_dns_record.existing, render_env_group_link.production_web] +} diff --git a/infra/workers/web-routing.mjs b/infra/workers/web-routing.mjs new file mode 100644 index 000000000..5897adedb --- /dev/null +++ b/infra/workers/web-routing.mjs @@ -0,0 +1,18 @@ +export default { + fetch(request, env) { + const redesign = (request.headers.get("Cookie") ?? "") + .split(";") + .some(cookie => cookie.trim() === "orbit_design=redesign") + + if (!redesign) return fetch(request) + + const url = new URL(request.url) + url.protocol = "https:" + url.host = env.REDESIGN_HOST + const upstream = new Request(url, request) + upstream.headers.set("Host", env.REDESIGN_HOST) + upstream.headers.set("X-Forwarded-Host", "app.useorbit.org") + upstream.headers.set("X-Forwarded-Proto", "https") + return fetch(upstream) + }, +} diff --git a/src/Orbit.Api/openapi_v2.json b/src/Orbit.Api/openapi_v2.json index 690a2fa1b..846906647 100644 --- a/src/Orbit.Api/openapi_v2.json +++ b/src/Orbit.Api/openapi_v2.json @@ -4619,7 +4619,24 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/AppConfigResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppConfigResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AppConfigResponse" + } + } + } } } } @@ -16006,6 +16023,35 @@ "AiActionType": { "type": "integer" }, + "AppConfigResponse": { + "required": [ + "limits", + "features", + "settings", + "minVersion" + ], + "type": "object", + "properties": { + "limits": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "features": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/FeatureFlagStateResponse" + } + }, + "settings": { + "$ref": "#/components/schemas/SyncSettingsResponse" + }, + "minVersion": { + "type": "string" + } + } + }, "ApplyGoalInput": { "required": [ "title", @@ -17718,6 +17764,24 @@ } } }, + "FeatureFlagStateResponse": { + "required": [ + "enabled", + "planRequirement" + ], + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "planRequirement": { + "type": [ + "null", + "string" + ] + } + } + }, "FrequencyUnit": { "type": "integer" }, @@ -20298,6 +20362,31 @@ } } }, + "SyncSettingsResponse": { + "required": [ + "syncIntervalSeconds", + "syncMaxBatchSize" + ], + "type": "object", + "properties": { + "syncIntervalSeconds": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "syncMaxBatchSize": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, "UnsubscribeRequest": { "required": [ "endpoint"