@ultimat3/cli 29.3.2 → 30.0.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/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# PWA and offline
|
|
2
|
+
|
|
3
|
+
> ## ✅ WIRED END TO END, `As of 2026-08`
|
|
4
|
+
>
|
|
5
|
+
> **`x dev`, the container and `x build --target static` all emit `manifest.webmanifest`, `sw.js`
|
|
6
|
+
> and `x-sw-register.js`**, plus the `<head>` that names all three. The manifest reader is
|
|
7
|
+
> [`packages/cli/src/pwa-artifacts.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/pwa-artifacts.ts)
|
|
8
|
+
> and the worker's is
|
|
9
|
+
> [`packages/cli/src/sw-artifacts.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/sw-artifacts.ts).
|
|
10
|
+
>
|
|
11
|
+
> This box said the opposite until 2026-08-27 — `generateServiceWorker`, `buildPrecacheManifest`,
|
|
12
|
+
> `offlineFallbackSource`, `backgroundSyncSource` and `pushSource` had **zero callers** outside
|
|
13
|
+
> `@ultimat3/pwa` itself, so `pwa.offline`, `pwa.backgroundSync`, `pwa.push` and every route's own
|
|
14
|
+
> `offline:` field were declarations with no build behind them ([#390](https://github.com/developerz-ai/ultimate/issues/390),
|
|
15
|
+
> [#362](https://github.com/developerz-ai/ultimate/issues/362)).
|
|
16
|
+
>
|
|
17
|
+
> **What unblocked it was the browser check, not the emitter.** A bad `sw.js` is **sticky** — a
|
|
18
|
+
> manifest a browser dislikes is ignored, a worker that caches wrong keeps serving wrong bytes until
|
|
19
|
+
> the user clears site data — so the rule was: no worker until something can drive a real one.
|
|
20
|
+
> `packages/cli/e2e/service-worker.e2e.test.ts` is that check. It registers the emitted file in a
|
|
21
|
+
> real Chrome, waits for it to take control, takes the network away, and asserts that a runtime
|
|
22
|
+
> route with nothing cached renders the **offline document**. It runs on every push.
|
|
23
|
+
>
|
|
24
|
+
> **`pwa.offline` is now a block, not a string** — `{ fallback, image, font, neverCache }`, and
|
|
25
|
+
> `fallback` is **required** once `enabled` is true. See [Upgrading](Upgrading) for the one-line
|
|
26
|
+
> edit; the old `offline: 'runtime'` was an app-wide default for a field `defineRoute` makes
|
|
27
|
+
> required on every route, so it defaulted nothing and was read by nobody.
|
|
28
|
+
>
|
|
29
|
+
> **`pwa.push` is wired too, `As of 26.1.0`** ([#710](https://github.com/developerz-ai/ultimate/issues/710)):
|
|
30
|
+
> a `pwa.vapid` key, a VAPID pair the boot resolves (or refuses), the worker's push handler, a
|
|
31
|
+
> subscription action pair and a notify channel — [Push](#push).
|
|
32
|
+
|
|
33
|
+
`sw.js` is a build artifact, generated from the route table and written by `x build`. Hand-editing one is **not** caught, and it does not survive either: `As of 2026-08` `sw.js` carries no checksum, so the next build overwrites the edit in silence — no warning, no diff, nothing in `--json`. `X_SW_HAND_EDITED` is a **reserved** name — nothing raises it, and `x errors explain X_SW_HAND_EDITED` refuses it ([Error codes → Not thrown yet](Error-Codes#not-thrown-yet)).
|
|
34
|
+
|
|
35
|
+
`As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)).
|
|
36
|
+
|
|
37
|
+
## Why generated
|
|
38
|
+
|
|
39
|
+
A service worker is a cache-policy compiler whose input is already declared on every route: render mode, offline strategy, asset graph. Hand-writing it duplicates that information, and the duplicate drifts. Every notorious PWA bug — the page serving last month's HTML, the chunk 404 after deploy, the user stuck on a version until they clear site data — is a service worker that disagreed with the app.
|
|
40
|
+
|
|
41
|
+
The edit an agent should make is the route's `offline` field, and `x build` reads it, `As of 2026-08`. Hand-editing the emitted `sw.js` is still not caught, per the reserved-code note above.
|
|
42
|
+
|
|
43
|
+
## Derived from the route
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export const config = defineRoute({
|
|
47
|
+
render: 'isr', // static | isr | ssr | stream
|
|
48
|
+
revalidate: { tags: [tag.post] },
|
|
49
|
+
prerender: () => db.posts.slugs(),
|
|
50
|
+
offline: 'precache', // precache | runtime | network-only
|
|
51
|
+
hydrate: 'visible', // idle | visible | interaction | never
|
|
52
|
+
budget: { js: '40kb' },
|
|
53
|
+
meta: ({ post }) => ({ title: post.title, description: post.excerpt,
|
|
54
|
+
og: { image: post.cover }, ld: ld.Article(post) }),
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Precache set
|
|
59
|
+
|
|
60
|
+
| Included | Source |
|
|
61
|
+
|---|---|
|
|
62
|
+
| Every route with `offline: 'precache'` | route table + its `prerender()` URLs |
|
|
63
|
+
| The island chunks those routes boot, every surface stylesheet, the framework scripts | rendered documents / declared islands, `As of 22.3.3` |
|
|
64
|
+
| Fonts, icons, and `priority` images they reference | asset graph |
|
|
65
|
+
| The offline fallback route | required (see below) |
|
|
66
|
+
|
|
67
|
+
Excluded always: `api/` responses and every personal route (a policy, a `stream`, a `no-store`/`private` cache) — even with `offline: 'precache'`, `As of 22.3.3`.
|
|
68
|
+
|
|
69
|
+
Total precache size is a **warning, not a budget, `As of 2026-08`**: `buildPrecacheManifest` puts `DEFAULT_PRECACHE_WARN_BYTES` overruns on `PrecacheManifest.warnings`, and **nothing reads that field** — no `x verify` step loads a precache manifest, because nothing builds one. Designed as a budget; not one today.
|
|
70
|
+
|
|
71
|
+
### Runtime strategy from render mode
|
|
72
|
+
|
|
73
|
+
`MODE_STRATEGY` in `packages/pwa/src/strategies.ts`, read by `strategyFor()` and keyed on
|
|
74
|
+
`Record<RenderMode, StrategyName>` — a mode with no row and a row for a mode that does not exist
|
|
75
|
+
are both compile errors, `As of 2026-08`.
|
|
76
|
+
|
|
77
|
+
| `render` | Strategy | Rationale |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `static` | `network-first` | the document names this deploy's hashed assets; the precached copy is the offline answer |
|
|
80
|
+
| `isr` | `network-first` | the server's ISR cache is already stale-while-revalidate; a second, older copy in the browser only delays a deploy |
|
|
81
|
+
| `ssr` | `network-first` | caching a per-request render is a correctness bug; the offline fallback answers on failure |
|
|
82
|
+
| `stream` | `network-first` | the shell names this deploy's chunks; the cached copy is the offline answer |
|
|
83
|
+
|
|
84
|
+
**Every document is network-first, `As of 22.3.2`.** Every rule the worker routes is a document —
|
|
85
|
+
content-hashed chunks get none and live in the browser's HTTP cache (`immutable`). Until 22.3.2
|
|
86
|
+
`static` was `cache-first` and `isr`/`stream` `stale-while-revalidate`, so an online navigation got
|
|
87
|
+
the HTML the old worker held, naming the old deploy's CSS and islands, until the visitor pressed
|
|
88
|
+
Shift+F5. `offline: 'precache'` still precaches the document: precache decides what answers
|
|
89
|
+
offline, not what answers while the network does. A per-route `strategy` can still ask for
|
|
90
|
+
`cache-first` or `stale-while-revalidate`.
|
|
91
|
+
|
|
92
|
+
**Navigation preload**, `As of 2026-09`: on `activate` the worker enables
|
|
93
|
+
`registration.navigationPreload`, so the browser sends a navigation's request while the worker is
|
|
94
|
+
still starting, and every strategy answers from `event.preloadResponse` before it would fetch. Worker
|
|
95
|
+
start-up is off the critical path of every navigation; a browser without the API, or a preload that
|
|
96
|
+
resolves empty, is the worker's own fetch as before. The preload is the browser's request and
|
|
97
|
+
carries no `X-Ultimate-Build`, so skew is read off the answer: another build's document posts
|
|
98
|
+
`AppUpdateAvailable` and the worker stops stamping its id — what a `409` did, in one round trip.
|
|
99
|
+
|
|
100
|
+
**The install fill is throttled**, `As of 2026-09`: `PRECACHE_CONCURRENCY` (4) entries in flight,
|
|
101
|
+
never every entry at once — the fill runs on the first visit, on the connection the first click needs.
|
|
102
|
+
An entry whose answer carries another build's `X-Ultimate-Build` is not stored, `As of 2026-10-02`:
|
|
103
|
+
mid-rollout an old pod's document was the new worker's offline copy. An unstamped answer (a static
|
|
104
|
+
host) is stored; a skipped entry is fetched when asked for.
|
|
105
|
+
|
|
106
|
+
**A rule matches the pathname the browser sends**, `As of 2026-10-02`: a literal segment is
|
|
107
|
+
percent-encoded the way `url.pathname` spells it (`/precios-españa`, `/a b` were never handled), and a
|
|
108
|
+
catch-all owns its bare prefix — `/docs/*path` matches `/docs`, which the static export writes.
|
|
109
|
+
|
|
110
|
+
**A new worker takes over at once**, `As of 22.3.2`: the install block calls `self.skipWaiting()`, so
|
|
111
|
+
a deploy's worker activates when its precache is filled rather than when every tab of the origin
|
|
112
|
+
has closed. It never reloads a page — the open tab keeps what it has, gets `AppUpdateAvailable`,
|
|
113
|
+
and its next navigation is answered with the new HTML. `x-sw-register.js` also calls
|
|
114
|
+
`registration.update()` when a hidden tab becomes visible again, at most once per five minutes, so
|
|
115
|
+
a tab left open for days finds the new worker before its next click. A browser still running a
|
|
116
|
+
22.3.1 worker converges with no user action: its next navigation fetches the new `sw.js`, which
|
|
117
|
+
skips waiting and claims the tab — that one navigation is still the old worker's cached copy, and
|
|
118
|
+
the one after it is fresh.
|
|
119
|
+
|
|
120
|
+
**`immutable` is for a content-hashed URL only**, `As of 22.3.2`. `/x-sw-register.js` (was
|
|
121
|
+
`max-age=3600`) and the `/icons/*` matrix (was `immutable`, under names that are sizes, not bytes)
|
|
122
|
+
answer `public, max-age=0, must-revalidate` with a strong ETag, and a 304 when unchanged — so a new
|
|
123
|
+
register script or a new `icon.png` reaches every client on its next load. `sw.js` stays
|
|
124
|
+
`private, max-age=0`; `manifest.webmanifest` and `/favicon.ico` keep their hour.
|
|
125
|
+
|
|
126
|
+
**There is no per-mode `offline` default** — `offline` is required by `defineRoute`'s type and again
|
|
127
|
+
at runtime (`X_ROUTE_OFFLINE_MISSING`). It is read *before* the mode: `offline: 'network-only'` is a
|
|
128
|
+
declaration that this URL is never answered from a cache, and `strategyFor` returns `network-only`
|
|
129
|
+
without consulting the table. A per-route `strategy` overrides both.
|
|
130
|
+
|
|
131
|
+
Overriding `offline` is allowed. Contradictions are **not** rejected `As of 2026-08`: `offline: 'precache'` on a `render: 'ssr'` route is accepted, and `X_SW_UNCACHEABLE` is a reserved name nothing raises ([Error codes → Not thrown yet](Error-Codes#not-thrown-yet)). The scope half *is* enforced — `X_SW_SCOPE_INVALID`, when the service-worker scope cannot serve the routes it precaches. Until the coherence check ships, review the pairing yourself: a per-request render has no cacheable body, so `precache` on `ssr` means the shell is served stale.
|
|
132
|
+
|
|
133
|
+
**A page rendered for someone is never cached**, `As of 22.3.3` (a security fix). A route is
|
|
134
|
+
*personal* when it has a `policy`, is `render: 'stream'`, or declares `cache: 'no-store'` /
|
|
135
|
+
`{ mode: 'no-store' | 'private' }` (`RouteDescriptor.personal`). Its rule is `network-only` in every
|
|
136
|
+
locale and it is never precached, whatever its `offline` says; a per-route `strategy` is still the
|
|
137
|
+
override. And whatever the rule, the worker **stores no response** that is `cache-control: private`
|
|
138
|
+
or `no-store`, or carries `x-ultimate-scope`, in any cache. From 21.0.0 to 22.3.2 such a response
|
|
139
|
+
was kept in a per-member partition for offline, so after sign-out an offline navigation on a shared
|
|
140
|
+
device showed the previous member's data. Offline, a personal page is the offline document.
|
|
141
|
+
|
|
142
|
+
An offline-first app can take 21.0.0's mode back, explicitly: `pwa.offline.personalPages:
|
|
143
|
+
'last-member'` routes a personal page by its render mode again and keeps the most recent member's
|
|
144
|
+
own copy in a per-principal partition that only the offline path reads (never answered online,
|
|
145
|
+
never precached — one declaring `offline: 'precache'` lands in the partition too, `As of 2026-10-02`;
|
|
146
|
+
it got a `precache` rule and was never kept). Declare it only with a sign-out that clears it — `clear-pages` below, or
|
|
147
|
+
`signOutHeaders()`' `Clear-Site-Data`. `examples/dummy` does, for its offline feed.
|
|
148
|
+
|
|
149
|
+
| Message | Sent by | Worker does |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `{ type: 'clear-pages' }` | the page, on sign-out | deletes every build's pages cache (`x-pages-*`, and any 22.3.2 partition), then answers the posting window `{ type: 'pages-cleared' }` |
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// sign-out, before navigating away — CLEAR_PAGES_MESSAGE from @ultimat3/pwa
|
|
155
|
+
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-pages' });
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The precache is untouched: marketing pages still answer offline. `Clear-Site-Data: "storage"` on
|
|
159
|
+
the sign-out response empties Cache Storage too, and is the belt to this message's braces.
|
|
160
|
+
|
|
161
|
+
**The English site is offline too**, `As of 22.3.3`. The server strips a non-default locale's prefix
|
|
162
|
+
before it matches (`/en/precios` is `/precios` in English), and the worker matches pathnames, so it
|
|
163
|
+
now gets every route once per routed locale — same strategy, same cache, its own precache revision.
|
|
164
|
+
An offline navigation under `/en/` gets `/en/offline` when that is precached, else the default one.
|
|
165
|
+
|
|
166
|
+
**A client-router visit is a navigation**, `As of 2026-10-05`. A `navigation.client` link fetches
|
|
167
|
+
its page (`x-ultimate-navigation: soft`), which is no browser navigation, so offline it got an empty
|
|
168
|
+
`503` and the tab landed on a `blob:` URL. The worker now answers it with the offline document; a
|
|
169
|
+
`prefetch` still gets the `503`, so the offline page is never cached as a later click's answer.
|
|
170
|
+
|
|
171
|
+
**Islands are precached only for precached pages**, `As of 22.3.3`. Every island chunk was precached,
|
|
172
|
+
so a first anonymous visit downloaded every island the app has (~1 MB on one adopter). Now a chunk
|
|
173
|
+
is precached only when a precached page (or the offline document) boots it — the static export reads
|
|
174
|
+
the rendered documents, the container each route's declared `island()`s. Every other chunk is cached
|
|
175
|
+
the first time a page asks for it: `/islands/` is a `cache-first` runtime rule.
|
|
176
|
+
|
|
177
|
+
**The installing page is cached too**, `As of 2026-09-22` (21.0.0). The page that registers the worker loaded before the worker controlled it, so no strategy saw it, and an `offline: 'runtime'` route was unavailable offline until a second online visit. On `activate`, the worker now runs each open window's URL through its route's own strategy, the same rule every later visit follows. It is best effort: a failure costs the warm-up only.
|
|
178
|
+
|
|
179
|
+
Mutations are never cached. **Offline writes go through the tier-3 mutator queue ([Realtime](Realtime)), not through Background Sync guesswork** — a durable queue with a declared `conflict` strategy per mutator, replayed on reconnect and rebased against server truth. Background Sync, when enabled, is only a wake-up trigger for that queue; it is never the queue. **One outbox, and it is realtime's**, in the page's IndexedDB, keyed by principal. On a `sync` event the worker posts `OUTBOX_DRAIN_MESSAGE` (`'x-outbox-drain'`, from `@ultimat3/core`) to every open tab and sends nothing itself. With no tab open the queue waits on disk for the next page load. `As of 2026-09-22` (21.0.0) both halves are in the tree: the page's outbox listens for the message. Until 21.0.0 the worker POSTed `/_x/outbox/flush`, a route nothing ever mounted.
|
|
180
|
+
|
|
181
|
+
### Manifest, icons, splash
|
|
182
|
+
|
|
183
|
+
From `app.config.ts` plus **one** source icon (SVG or >=1024px PNG). **Emitted at `/manifest.webmanifest` by `x dev`, by the container and into the static export**, `As of 2026-08` — `packages/cli/src/pwa-artifacts.ts` composes `pwa.name` and `pwa.colors` with `planIcons`' own list, so the manifest names exactly the icons `/icons/*` serves. `defineConfig` refuses `pwa.enabled: true` without a name and both schemes' colours, so `assertValid`'s two refusals now arrive at boot rather than at build:
|
|
184
|
+
|
|
185
|
+
| Generated | Detail |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `manifest.webmanifest` | the DEFAULT locale's: `lang` is `defaultLocale` (was a hardcoded `en` until 22.3.3), `start_url` `/`, `id` `pwa.id` or `/`, theme + background from `pwa.colors` |
|
|
188
|
+
| `/<locale>/manifest.webmanifest` | one per other routed locale, `As of 22.3.3`: its own `lang`, `start_url: '/en/'`, the SAME `id` (one installed app), its text and its shortcut URLs in that locale. Each document links its own locale's manifest |
|
|
189
|
+
| Icons | 192/256/384/512 + maskable variants + `apple-touch-icon` |
|
|
190
|
+
| Favicons | `.ico` + SVG |
|
|
191
|
+
| `description`, `categories`, `shortcuts`, `screenshots` | from `pwa.*`, `As of 22.3.3` — below |
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// app.config.ts — every member optional; text is a string or one per locale
|
|
195
|
+
pwa: {
|
|
196
|
+
enabled: true, name: 'Acme', offline: { fallback: '/offline' }, colors: { … },
|
|
197
|
+
id: '/', // default: '/'
|
|
198
|
+
description: { 'es-co': '…', en: '…' },
|
|
199
|
+
categories: ['business', 'productivity'],
|
|
200
|
+
shortcuts: [{ // url: the DEFAULT locale's path; en gets /en/panel
|
|
201
|
+
name: { 'es-co': 'Panel', en: 'Dashboard' }, shortName?: …, description?: …,
|
|
202
|
+
url: '/panel', icons?: [{ src: '/assets/…png', sizes: '96x96', type: 'image/png' }],
|
|
203
|
+
}],
|
|
204
|
+
screenshots: [{ // src: one path, or one per locale
|
|
205
|
+
src: { 'es-co': '/assets/…-es.png', en: '/assets/…-en.png' },
|
|
206
|
+
sizes: '1280x800', type: 'image/png', formFactor: 'wide', label?: { … },
|
|
207
|
+
}],
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`defineConfig` refuses a relative `id`, shortcut `url` or screenshot `src` — a browser drops those
|
|
212
|
+
in silence. A record missing a locale falls back to the default locale's entry.
|
|
213
|
+
|
|
214
|
+
A screenshot or shortcut-icon `src` under `/assets/` is a file in `apps/web/site/assets/`, and the
|
|
215
|
+
manifest names its **content-hashed** URL (`/assets/pwa/wide-es-co.1a2b3c4d.png`), exactly as
|
|
216
|
+
`asset()` does in a page — the site serves an asset only there (`As of 22.4`). A missing file is
|
|
217
|
+
`X_ASSET_MISSING` at boot; any other `src` is published as written.
|
|
218
|
+
|
|
219
|
+
No icon-generator service, no 30-file `public/` directory to maintain. Theme and background colours come from semantic [theme](Theming) tokens, never a raw hex.
|
|
220
|
+
|
|
221
|
+
### Offline fallback is required in the type
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// app.config.ts — omitting `offline.fallback` is a compile error
|
|
225
|
+
pwa: {
|
|
226
|
+
offline: { fallback: '/offline' }, // required
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`x new` scaffolds `/offline` in `site/` — 0kb JS, works from cache, shows the queued-mutation count. A PWA without a fallback shows the browser's dinosaur, which reads as "the app is broken", not "you are offline". Making it a required field means no app ships without one.
|
|
231
|
+
|
|
232
|
+
## Version skew is what actually breaks PWAs
|
|
233
|
+
|
|
234
|
+
Not caching strategy. **Skew**: a client running build `A` requesting an asset from build `B`.
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
user opens app (build A) → keeps tab open 3 days → you deploy 6 times
|
|
238
|
+
→ user clicks a route → lazy chunk from build A → 404 → white screen
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The client is not broken and the server is not broken; they disagree about which version exists. Mitigations, all mandatory:
|
|
242
|
+
|
|
243
|
+
| # | Mechanism | Detail |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| 1 | **Immutable build ID per deploy** | content-hash of the build, stamped into `sw.js`, the HTML, every asset path, and `x.manifest.json`. Never a timestamp, never `latest` |
|
|
246
|
+
| 2 | **Client sends its build ID on every request** | `X-Ultimate-Build` header on RPC, query, and WS handshake. The server can answer "you are stale" instead of guessing |
|
|
247
|
+
| 3 | **N-deploy asset retention** | `assets: { retain: { disk, builds } }` in `app.config.ts`: every web pod publishes its hashed files to that shared disk before it reports ready, and answers an earlier build's `/islands`, `/styles` and assets from it on a miss. Unset (the default), a pod serves only what it built. A **count of deploys, with no time component**. Proven in a browser `As of 2026-11`: a tab opened on build 0 survives six deploys that each change an island, and its first click boots build 0's chunk with a 200 and no 404 ([`six-deploys.e2e.test.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/e2e/six-deploys.e2e.test.ts)); without `retain` the same click is a 404. `retentionPlan` was removed in 30.0.0: it kept nothing on its own, because no boot called it |
|
|
248
|
+
| 4 | **`AppUpdateAvailable` signal, not a 404** | a Solid signal flips when the server reports a newer build. The app renders its own "Update available — reload" affordance. **No forced navigation, no lost form state, no dinosaur** |
|
|
249
|
+
| 5 | **Forced reload after a grace period** | **Not a capability of this package.** Deleted in 9.0.0 — `updateSignal`/`updatePolicy` computed a deadline nothing acted on, and `pwa` (tier 4) sits above the two runtimes that hold both build ids. The service worker posts `{ type: 'AppUpdateAvailable', to }`; act on it yourself, or read `useConnection().updateAvailable` |
|
|
250
|
+
| 6 | **Skew is observable** | the `/_x` live panel reports the build-ID distribution of connected clients, so "how many users are three deploys behind" has an answer. `x status --json` would report it outside dev and is **planned**, not shipped |
|
|
251
|
+
| 7 | **Build ID scopes the SW cache** | preview/branch builds get their own cache namespace and SW scope, so a preview can never poison prod caches |
|
|
252
|
+
|
|
253
|
+
Server behaviour on a stale build ID:
|
|
254
|
+
|
|
255
|
+
| Request type | Response |
|
|
256
|
+
|---|---|
|
|
257
|
+
| Asset still within retention | serve it |
|
|
258
|
+
| Asset outside retention | `410 Gone` + `X-Ultimate-Build-Current`, SW serves the fallback and flips `AppUpdateAvailable` |
|
|
259
|
+
| Action / query | executed if the contract is compatible; `X_BUILD_SKEW` with a `fix:` line if the input schema changed incompatibly |
|
|
260
|
+
| WS handshake | accepted, then an `update-available` frame carrying the server's `buildId` → signal flips; the socket is **not** killed |
|
|
261
|
+
|
|
262
|
+
`410 Gone` rather than `404`: a 404 is indistinguishable from a typo, and a client cannot act on it. `410` plus `X-Ultimate-Build-Current` is an instruction.
|
|
263
|
+
|
|
264
|
+
## Opt-in capabilities
|
|
265
|
+
|
|
266
|
+
Off by default. Each flag adds permission prompts, review burden, or platform surface, so none is implicit.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
// app.config.ts: the two capabilities an app can switch on, both booleans
|
|
270
|
+
pwa: {
|
|
271
|
+
enabled: true,
|
|
272
|
+
offline: { fallback: '/offline' },
|
|
273
|
+
backgroundSync: true,
|
|
274
|
+
push: true, // Web Push — requires vapid; the key pair is env (`x vapid create`), see below
|
|
275
|
+
vapid: { subject: 'mailto:ops@example.com' },
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`badging`, `shareTarget` and `fileHandlers` are `CapabilityFlags` on `@ultimat3/pwa`'s
|
|
280
|
+
`generateServiceWorker`, **not** `app.config.ts` keys, `As of 2026-09-22`. `x build` passes only
|
|
281
|
+
`backgroundSync` and `push` through (`packages/cli/src/sw-artifacts.ts`). This block showed all five
|
|
282
|
+
as config objects (`{ enabled, queues }`, `{ enabled, vapid }`, …), and no such shape was ever
|
|
283
|
+
accepted.
|
|
284
|
+
|
|
285
|
+
| Flag | Generates | Cost of enabling |
|
|
286
|
+
|---|---|---|
|
|
287
|
+
| `push` | the SW `push` and `notificationclick` handlers, `<meta name="x-push-key">` on every document, and the push runtime every role's boot installs — on a served boot (`x dev`, the container). A static export serves no subscribe action, so it emits no handler and files a `serviceWorkerWarnings` entry (`cli.build.pushUnwired`). A tap opens a same-origin URL only; any other opens the app root | notification permission prompt (from a click); a VAPID key pair (`x vapid create`) |
|
|
288
|
+
| `backgroundSync` | a SW `sync` listener that posts `OUTBOX_DRAIN_MESSAGE` to every open tab, and the page registration with an `online` fallback where the Background Sync API is missing | replay must be idempotent. By design each queued write carries an idempotency key, so `action`'s idempotency store answers a replay rather than applying it twice. The queue is IndexedDB-backed, per principal |
|
|
289
|
+
| `badging` | badge update from a live query — **only alongside `push`** `As of 2026-08-20`: the badge call is emitted inside the push block, so `badging: true` on its own changes nothing while `capabilities.badging` still reports `true` | Chromium-only surface |
|
|
290
|
+
| `shareTarget` | the `share_target` manifest member, from `generateWebManifest`'s `shareTarget` input — nothing else: no route, no policy, no worker code. `x build` passes neither the flag nor the input, so no app's manifest carries it, `As of 2026-10-02` | the app serves the target route itself and must treat its payload as untrusted |
|
|
291
|
+
| `fileHandlers` | manifest entry + route | OS-level file association |
|
|
292
|
+
| ~~`periodicSync`~~ | **not built, and the declarations are deleted** `As of 2026-08-20`. There was never a `periodicsync` listener, never a `periodicSync.register` call, and no `CAPABILITIES` flag to gate one — `PERIODIC_SYNC_TAG` and `periodicMinIntervalMs` described a feature that did not exist | — |
|
|
293
|
+
|
|
294
|
+
All of them are `route` / `action` / `job` primitives underneath ([The eight primitives](The-Eight-Primitives)) — a push send would be a job, a share target is a route the app serves. No PWA-specific concept escapes into the app's mental model.
|
|
295
|
+
|
|
296
|
+
### Push
|
|
297
|
+
|
|
298
|
+
**Web Push, end to end, with no dependency** — RFC 8291 message encryption and RFC 8292 (VAPID,
|
|
299
|
+
ES256) signing on WebCrypto. `As of 26.1.0`.
|
|
300
|
+
|
|
301
|
+
| Piece | What | Where |
|
|
302
|
+
|---|---|---|
|
|
303
|
+
| config | `pwa: { push: true, vapid: { subject: 'mailto:ops@example.com' } }` — a `subject` push services accept (`mailto:` or `https:`), required with `push`, refused without it | [Configuration](Configuration) |
|
|
304
|
+
| key pair | `ULTIMATE_VAPID_PUBLIC_KEY` + `ULTIMATE_VAPID_PRIVATE_KEY`, never config. `x vapid create` mints a P-256 pair and seals BOTH into `secrets.enc.json` in one write; `x vapid show` names the pair in force. Locally, neither set signs with a published development pair; a deployed boot refuses it, half a pair, or two halves that do not sign for each other (`X_PWA_VAPID_KEY_MISSING`, `X_PWA_VAPID_KEY_INVALID`) — at boot, before any service starts | [CLI reference](CLI-Reference#x-vapid) |
|
|
305
|
+
| subscriptions | `x_push_subscriptions`, a framework table every boot applies; keyed by endpoint (a browser subscribed by a second person is theirs now), with the locale and zone it subscribed in | `@ultimat3/pwa/schema` |
|
|
306
|
+
| subscribe | `pushSubscribe({ permission })` / `pushUnsubscribe({ permission })` — factories returning real actions an app exports from its own actions module: route, OpenAPI, typed client, contract tests, the one policy path. Stored against `ctx.actor.id`; an agent is refused | `apps/web/app/<slice>/actions.ts` |
|
|
307
|
+
| the button | `subscribeToPush({ save: client.subscribePush })` from `@ultimat3/pwa/client` (an import-free subpath an island can afford), on a click; answers `subscribed`, `denied`, `unsupported` or `unconfigured` | an island |
|
|
308
|
+
| send | `pushChannel({ pusher: webPush(), message })` in a notifier — every device of the recipient, each in its own locale; or `pushToActor(id, notification)` directly | [Notify](Notify#push) |
|
|
309
|
+
| the device | the emitted `push` handler shows `title`/`body`/`tag`/`actions` from the body; `notificationclick` focuses or opens the URL, same origin only | `sw.js` |
|
|
310
|
+
|
|
311
|
+
Push strings are catalog KEYS (`titleKey`, `bodyKey`), rendered server-side per subscription locale —
|
|
312
|
+
the sending server has no request to read a language off. One message is one 4096-byte record
|
|
313
|
+
(`X_PWA_PUSH_PAYLOAD_TOO_LARGE` past 3993 bytes of notification): send a path to the content, never
|
|
314
|
+
the content. `ttlSeconds` (default a day), `urgency` and `topic` (the push service's collapse key)
|
|
315
|
+
travel as RFC 8030 headers.
|
|
316
|
+
|
|
317
|
+
## What is checked, and where
|
|
318
|
+
|
|
319
|
+
`x verify` has **no PWA step** — its 21 steps are typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, exercise, e2e, eval, drift, contract-diff, budgets, seo, i18n, policy, manifest, roadmap. The two PWA checks that ship run in **`x doctor`** ([`packages/cli/src/cmd-doctor.ts:129`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/cmd-doctor.ts)), and report `@ultimat3/pwa`'s own codes rather than CLI twins of them.
|
|
320
|
+
|
|
321
|
+
| Check | Where | Fails on | `As of 2026-08` |
|
|
322
|
+
|---|---|---|---|
|
|
323
|
+
| Source icon present | `x doctor` | the 1024×1024 source PNG is missing, so install icons and OG images cannot be generated (`X_PWA_ICON_MISSING`) | **shipped** |
|
|
324
|
+
| Offline fallback route present | `x doctor` | no `site/` route serves the configured `pwa.offline.fallback`, so an offline navigation lands on the browser error page (`X_PWA_NO_OFFLINE_FALLBACK`). Judged against the route table (`describePages()`), never against a filename — it named `apps/web/app/offline.tsx`, which is not a route file at all, so the check was red for every app the framework ever produced and no invocation could clear it. `site/` only: `app/` allows `stream \| ssr`, only a `static` route is prerendered, and the worker precaches a rendered document — an `app/` fallback has none | **shipped** |
|
|
325
|
+
| SW scope | `@ultimat3/pwa` | the service-worker scope cannot serve the routes it precaches (`X_SW_SCOPE_INVALID`) | **shipped** |
|
|
326
|
+
| SW checksum | — | nothing computes one; `X_SW_HAND_EDITED` is reserved | **not built** |
|
|
327
|
+
| Strategy coherence | — | an `offline` value contradicting the route's `render` is accepted; `X_SW_UNCACHEABLE` is reserved | **not built** |
|
|
328
|
+
| Precache budget | — | no step reads precache bytes | **not built** |
|
|
329
|
+
| Retention config | `@ultimat3/core` | `defineConfig` refuses an `assets.retain` of the wrong shape or with `builds` under 1 (`X_CONFIG_INVALID`); the web role checks `disk` against the app's `defineStorage({ disks })` when it boots | **shipped** |
|
|
330
|
+
| Build ID present | `@ultimat3/pwa` | `generateServiceWorker` was handed an empty build id, so caches cannot be keyed and skew cannot be detected (`assertBuildId`) | **shipped** |
|
|
331
|
+
| Build ID *shape* | — | only emptiness is rejected. A timestamp or `latest` is accepted, and both defeat the immutable-namespace guarantee | **not built** |
|
|
332
|
+
| Offline fallback declared | `@ultimat3/pwa` | no `offline` block, or one with no `fallback` route — `generateServiceWorker` refuses with `X_PWA_NO_OFFLINE_FALLBACK` rather than building a worker that falls through to the browser error page | **shipped** |
|
|
333
|
+
|
|
334
|
+
`x test e2e` additionally asserts SW install, offline fallback rendering, and the version-skew reload path in a real browser. See [Testing](Testing).
|
|
335
|
+
|
|
336
|
+
## Rules
|
|
337
|
+
|
|
338
|
+
- Never hand-edit `sw.js`. Change the route, rebuild. **Convention, not a rule** — nothing checks it `As of 2026-08`.
|
|
339
|
+
- Never cache an authenticated response without an explicit `offline` field on the route.
|
|
340
|
+
- Never use a timestamp or `latest` as a build ID. **Convention, not a rule** — `assertBuildId` rejects only an empty one.
|
|
341
|
+
- Never force-reload a user without a grace period, except on a `--critical` deploy.
|
|
342
|
+
- Never cache a mutation. Offline writes are the tier-3 mutator queue's job.
|
|
343
|
+
- Never ship a PWA without an `offline.fallback` route — `generateServiceWorker` refuses (`X_PWA_NO_OFFLINE_FALLBACK`). A runtime refusal at build time, not a type error: `OfflineConfig` is passed as a `Partial`.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Policies and authz
|
|
2
|
+
|
|
3
|
+
One authz system. Authentication is Better Auth, wrapped ([Configuration](Configuration)); **authorization is ours**, because it must be identical in HTTP, WebSocket, jobs, and MCP.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId))
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## `can(permission, predicate)`
|
|
10
|
+
|
|
11
|
+
| Parameter | Type | Required | Meaning |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| `permission` | `string` | yes | a capability id, `resource:verb`. Must exist in the declared permission set or the build fails (`X_PERMISSION_UNKNOWN`) |
|
|
14
|
+
| `predicate` | `(args: PolicyArgs) => boolean \| PolicyDecision` | no | the row-level condition. Omitted means the capability alone decides. **Synchronous** — a live query evaluates it per subscriber per change, so an `await` here costs one round trip per watcher |
|
|
15
|
+
|
|
16
|
+
`PolicyArgs` — the one signature, identical on every surface:
|
|
17
|
+
|
|
18
|
+
| Member | Type | Notes |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `actor` | `Actor \| null` | `{ kind: 'user' \| 'service' \| 'agent' \| 'anonymous', id, orgId?, roles, scopes }`. Anonymous is `null`-safe; policies branch on it |
|
|
21
|
+
| `input` | parsed input | already validated against the primitive's `input` schema |
|
|
22
|
+
| `row` | `R \| null` | the already-loaded row a row-level rule decides about; `null` when the rule decides on input alone. Never pass a row through `input` |
|
|
23
|
+
| `ctx` | `Ctx` | repos for lookups, `ctx.tz`, `ctx.logger`. Read-only use |
|
|
24
|
+
|
|
25
|
+
Policies live in `<feature>/policy.ts` and are referenced by name from the feature's `actions/` and `live/` modules. `agent` is a first-class actor kind: an MCP caller goes through this same function, with no separate path.
|
|
26
|
+
|
|
27
|
+
## Where a policy is evaluated
|
|
28
|
+
|
|
29
|
+
| Surface | When | Denial rendering |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| HTTP call | before `handle`, after input parse | `X_FORBIDDEN` (403), or `X_UNAUTHENTICATED` (401) if the actor is anonymous |
|
|
32
|
+
| Typed client call | server-side, on the same route | same as HTTP; the client sees the JSON body |
|
|
33
|
+
| Direct server call | in-process invocation | throws `X_FORBIDDEN` |
|
|
34
|
+
| Job execution | per attempt, per step boundary — an actor's rights can be revoked mid-workflow | throws `X_FORBIDDEN`; the job fails, it does not silently skip |
|
|
35
|
+
| MCP tool call | before the tool body, same evaluation | tool error carrying `X_FORBIDDEN` + `fix` |
|
|
36
|
+
| Live query **subscribe** | at subscribe time | subscription rejected with `X_FORBIDDEN` |
|
|
37
|
+
| Live query **per delivered row** | on every patch, for every row, for the lifetime of the subscription | the row is not delivered; no partial object, no id leak |
|
|
38
|
+
| Admin visibility | screen and field level | the screen does not render, and its MCP tool is not listed |
|
|
39
|
+
|
|
40
|
+
The decision is surface-blind. The surface selects only how a denial is *rendered* — never whether authz runs, and never how input was validated.
|
|
41
|
+
|
|
42
|
+
| Aspect | Rule |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Projects to | HTTP guard, live-query row filter, job actor check, MCP tool gate, admin visibility |
|
|
45
|
+
| Owns | the yes/no and the denial reason |
|
|
46
|
+
| Never | mutate, query outside the declared repos, or return partial data (filter in the `query`) |
|
|
47
|
+
|
|
48
|
+
## Denials
|
|
49
|
+
|
|
50
|
+
Same code, three encodings.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
X_FORBIDDEN: policy denied the request
|
|
54
|
+
cause: actor user_2 (roles: editor) lacks post:publish on post_9
|
|
55
|
+
fix: x actions show publishPost --json # the capability it enforces; grant post:publish to editor in apps/web/shared/policies.ts
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{ "code": "X_FORBIDDEN",
|
|
60
|
+
"cause": "actor user_2 (roles: editor) lacks post:publish on post_9",
|
|
61
|
+
"fix": "x actions show publishPost --json # the capability it enforces; grant post:publish to editor in apps/web/shared/policies.ts",
|
|
62
|
+
"docs": "https://github.com/developerz-ai/ultimate/wiki/Error-Codes" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A `fix` is a command to run, a call to paste, or a file to open — never advice. `x errors explain <CODE> --json` prints the same `{ code, cause, fix, docs }` for any registered code.
|
|
66
|
+
|
|
67
|
+
| Code | Where | Status |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `X_FORBIDDEN` | everywhere a denial is decided: internal, jobs, direct calls, MCP tool errors, and the HTTP edge for an authenticated actor without the capability | 403 at the HTTP edge, no status elsewhere |
|
|
70
|
+
| `X_UNAUTHENTICATED` | HTTP edge, anonymous actor hitting a policy that needs a session | 401 |
|
|
71
|
+
|
|
72
|
+
`--json` and the MCP response carry the identical code and `fix` string as the terminal and the dev overlay. A denial reason never includes data the actor could not otherwise read.
|
|
73
|
+
|
|
74
|
+
## Missing or unknown policy is a build error
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
X_ACTION_POLICY_MISSING: action registered without a policy
|
|
78
|
+
cause: action "publishPost" was registered without a policy
|
|
79
|
+
fix: add `policy: can('publishPost')` to the action definition in the file that exports it
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Code | Trigger |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `X_ACTION_POLICY_MISSING` | an `action` / `mutator` / `query` registered without `policy` |
|
|
85
|
+
| `X_POLICY_MISSING` | a permission is referenced but no policy defines it |
|
|
86
|
+
| `X_PERMISSION_UNKNOWN` | `can('post:pubish')` — a capability not in the declared set. Typos die at build time |
|
|
87
|
+
|
|
88
|
+
There is no `policy: 'public'` shortcut and no default-allow. A route that is genuinely public is a `site/` route with no action behind it; a genuinely public action declares an explicit anonymous-allowing policy, which is greppable and shows up in `x policy explain`.
|
|
89
|
+
|
|
90
|
+
## Introspection
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
$ x policy explain publishPost --json
|
|
94
|
+
{"ok":true,"command":"policy","summary":"publishPost — allowed for 0 of 5 actor evaluation(s)","findings":[],
|
|
95
|
+
"data":{"subject":"publishPost","kind":"action","grantingRoles":["admin","author","owner"],
|
|
96
|
+
"declarations":[{"name":"publishPost","kind":"action","capability":"post:publish",
|
|
97
|
+
"label":"post:publish","decidable":true,"rows":[
|
|
98
|
+
{"actor":"anonymous","allowed":false,"reason":"no actor for post:publish","deciding":"post:publish"},
|
|
99
|
+
{"actor":"admin","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
|
|
100
|
+
{"actor":"author","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
|
|
101
|
+
{"actor":"owner","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
|
|
102
|
+
{"actor":"reader","allowed":false,"reason":"actor lacks post:publish","deciding":"post:publish"}]}]}}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
One row per actor per declaration — every declared role plus `anonymous`, evaluated once for each action or query that enforces the subject. A permission two declarations enforce therefore reports twice the evaluations, which is why the summary counts evaluations and never roles. `deciding` is the clause that produced the verdict and `reason` is why — which is what separates "this role was never granted the permission" from "it holds the grant and the row predicate said no".
|
|
106
|
+
|
|
107
|
+
The matrix runs **outside a request**: no input, no row. A rule that reads either decides again on the real call, so a `predicate returned false` here is a no-input verdict rather than a standing denial — the human render says so under every table. `decidable` is `false` for a policy that cannot be evaluated at all outside a request (a predicate dereferencing `input.post.id` has nothing to dereference); `rows` is then empty and the render says that instead of printing a table of invented denials.
|
|
108
|
+
|
|
109
|
+
`<subject>` resolves in order against a permission, an action name, a query name, then an action's HTTP path — so whichever of the four a throwing surface had to hand, the `fix:` line it printed is runnable.
|
|
110
|
+
|
|
111
|
+
| Want | Command | MCP tool |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| one subject explained | `x policy explain <subject> --json` | none — no dev tool takes a subject, and `policies.list` returns the catalog without the per-declaration matrix |
|
|
114
|
+
| every permission + who grants and enforces it | `x policy list --json` | `policies.list` (no arguments) |
|
|
115
|
+
| unprotected surfaces | `x verify --json` (the `boundaries` step) | `manifest.read` (there is no budgets tool) |
|
|
116
|
+
|
|
117
|
+
`x policy list` also names the permissions **no** action or query enforces. A grant nothing checks is a grant that does nothing, and it is invisible from any single declaration.
|
|
118
|
+
|
|
119
|
+
## Tenancy
|
|
120
|
+
|
|
121
|
+
Authz and tenancy are separate checks, both mandatory.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
X_TENANCY_UNSCOPED: query is not scoped to a tenant
|
|
125
|
+
cause: select on "posts" has no predicate on tenant column "orgId"
|
|
126
|
+
fix: query through posts repo (ctx.posts.*) — it applies the tenant scope from ctx.actor
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Rule | Detail |
|
|
130
|
+
|---|---|
|
|
131
|
+
| Every query is tenant-scoped or it fails | the predicate comes from `ctx.actor.orgId` via the repo, never hand-written per call site |
|
|
132
|
+
| Cache keys include tenant + policy scope | framework-generated. A hand-built key missing the tenant is no build error, a convention — but on a primitive the exercise pass reaches it is `X_TENANT_LEAK` on the proof's `after` leg ([below](#the-dynamic-tenant-proof)) |
|
|
133
|
+
| Live queries scope in the matcher | not as a post-filter, so a cross-tenant row never enters the fanout path |
|
|
134
|
+
| Vector and FTS search filter in SQL | similarity search cannot leak across tenants |
|
|
135
|
+
| A reference stays inside its tenant | both ends scoped is a composite key in the database, refused alike for absent and foreign (`X_DB_FOREIGN_KEY_VIOLATION`). `references(() => x.id, { crossTenant: '<reason>' })` is the stated escape ([Same-tenant keys](Entities-And-Migrations#same-tenant-keys)) |
|
|
136
|
+
| An agent actor cannot exceed its human | the actor is the signed-in user's session; no broad-rights service account |
|
|
137
|
+
|
|
138
|
+
### The static tenant check
|
|
139
|
+
|
|
140
|
+
**A table with no tenant column that hangs off a tenant-owned one is a finding**, `As of 2026-11-05`. The repository guard scopes only tables that carry the column, so a child row with a foreign key to a scoped parent and no tenant of its own is readable by id from another org. `x verify`'s `exercise` step walks every registered entity's references and reports each such entity:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
X_ENTITY_TENANT_UNDECLARED: a child of a tenant-owned table has no tenant
|
|
144
|
+
cause: attachments has no tenant column and references attachments -> posts, which has one, and declares no shared reason: …
|
|
145
|
+
fix: orgId: uuid().tenant() # in entity('attachments', { columns }), then x db gen 'attachments tenant'; or shared: '<why it belongs to no tenant>' in that entity()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Move | When |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `orgId: uuid().tenant()`, then `x db gen '<table> tenant'` | the rows belong to an org. A table that already holds rows backfills the column: `x g backfill <name> --feature <slice>` |
|
|
151
|
+
| `entity('<name>', { shared: '<why>' })` | the rows belong to no org — a price list, a catalog. A blank reason is refused at `entity()`; a `shared` entity is not walked through ([Entities](Entities-And-Migrations#shape)) |
|
|
152
|
+
|
|
153
|
+
Reported in the `exercise` step, not `policy`: the policy step asks whether a permission exists. Source: `packages/cli/src/exercise-static.ts`.
|
|
154
|
+
|
|
155
|
+
### The dynamic tenant proof
|
|
156
|
+
|
|
157
|
+
**Every action, query and job is called as org B with org A's seeded ids, and red if anything of A's
|
|
158
|
+
comes back or any row of A's changes**, `As of 2026-11`. It runs inside `x exercise` and the
|
|
159
|
+
gate's `exercise` step, around each primitive's own call: B, then A, then B again. Full mechanics:
|
|
160
|
+
[The tenant proof](Testing#the-tenant-proof).
|
|
161
|
+
|
|
162
|
+
| Checks | How |
|
|
163
|
+
|---|---|
|
|
164
|
+
| a read past the guard — a tenantless child by id, raw SQL by input id, `crossTenant()` under a policy that is not public, a service actor minted from the input | A's row canary, or an id of A's B was not handed, in B's output |
|
|
165
|
+
| an effect past the guard — a mail to A's address, an outbound body, an enqueued job's input | the same marks, in the effect's payload |
|
|
166
|
+
| a cache key without the tenant | the second B leg, after A filled the entry |
|
|
167
|
+
| a write by input id — update, delete, soft delete | a digest of each of A's seeded rows, before and after each B leg |
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
X_TENANT_LEAK: another tenant's row reached a call's output or effect
|
|
171
|
+
cause: action readAttachment, called by tenant B with tenant A's ids before A ran, handed back A's seeded data at output.url: …
|
|
172
|
+
fix: x exercise readAttachment --json — then read the row behind output.url through the repository scoped to ctx.actor, never by the input id alone
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| Not a failure | Why |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `public` | the policy admits an anonymous caller: what it returns, anyone may read. The read half is skipped; a crossed **write** is still red |
|
|
178
|
+
| `public`, as a declared sweep | a job that reads every org on purpose says so with `crossTenant(reason, fn)`, and its input names no org's rows. The proof sees the scope and quotes the reason; the read half is skipped, a crossed **write** is still red. The same scope in an action or a query, or in a job handed A's id, is still `X_TENANT_LEAK` |
|
|
179
|
+
| `unproven` | A's own call was not reached (that record is already red as `X_CONTRACT_EXERCISE_UNREACHED`), or a query answered A none of A's rows — B's empty answer then proves nothing |
|
|
180
|
+
|
|
181
|
+
B holds the role A held, minus `tenancy:cross`. **Not checked**: a `route` `load` and a `task`
|
|
182
|
+
are not walked, so raw SQL there is outside the proof; neither is another user in the **same**
|
|
183
|
+
org — ownership is a per-app row predicate, a denial test's job (below).
|
|
184
|
+
|
|
185
|
+
### The database's own guard — row security (`As of 2026-11-06`)
|
|
186
|
+
|
|
187
|
+
A second enforcer for the same `tenant:` declaration, below the repository: Postgres row level security, so a raw `sql`, a report or a hand-written join cannot read another tenant's row either. Off by default — it costs a transaction per bare statement and a role split.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// app.config.ts — the whole app, every tenant table. Per-entity would leave the holes this closes.
|
|
191
|
+
import { defineConfig } from '@ultimat3/core';
|
|
192
|
+
|
|
193
|
+
export const config = defineConfig({ name: 'my-app', database: { rowSecurity: true } });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Then `x db gen "row security"`, commit the migration and `packages/db/schema/`, `x db migrate`.
|
|
197
|
+
|
|
198
|
+
| Piece | Rule |
|
|
199
|
+
|---|---|
|
|
200
|
+
| switch | `database.rowSecurity: true`, nothing per entity or per query. `'true'` is `X_CONFIG_INVALID` |
|
|
201
|
+
| migration | `x db gen` writes the policy once per `tenant:` table (`describeTenancy()` names its column), recorded as `rowSecurity: true` on the snapshot; flag off drops each. `x_*` and `shared:` tables get none |
|
|
202
|
+
| drift | the flag against the newest snapshot, no database: `X_DB_ROW_SECURITY_DRIFT`, fix `x db gen "row security"`. After `x db migrate`, the same code for a recorded policy the catalog lacks (or holds unforced), or one it holds unrecorded |
|
|
203
|
+
| schema dump | `10_policies/<table>.sql`: `enable`, `force`, each policy with every clause. A test database loaded from the dump enforces the policy |
|
|
204
|
+
| roles, test services | `acquireWorkerDatabase({ rowSecurity: true, migrate })` migrates as `x_migrator` and hands the suite a URL serving as `x_app` |
|
|
205
|
+
| roles, PGlite | its one session is a superuser; the migrate step (`x db migrate`, `ROLE=migrate`) holds `x_migrator` and takes over what the superuser owned in `public`; `x dev` and a served role switch to `x_app` at boot (`serveAsAppRole`) and grant it DML on every table already there |
|
|
206
|
+
| roles, production | the operator's: `DATABASE_URL` of a serving role is a `nosuperuser nobypassrls` role owning nothing; `ROLE=migrate`'s owns the tables. Grants on tables, the `x_*` ones included, are the operator's too |
|
|
207
|
+
| `x doctor` | with the flag on, connects as `DATABASE_URL` and names `X_DB_ROW_SECURITY_BYPASSED`; the drift half rides the drift probe |
|
|
208
|
+
| policy | `enable` + `force row level security`, one `ultimate_tenant` policy: `org_id = nullif(current_setting('x.tenant', true), '')::<type> or current_setting('x.cross', true) = 'on'`, as `using` AND `with check`. Unset fails closed: no rows |
|
|
209
|
+
| the tenant | set per transaction through `setLocal` — `select set_config($1, $2, true)`, parameter-bound and local, so a pooled connection hands nothing on (pooler transaction mode is safe). `crossTenant()` sets `x.cross` |
|
|
210
|
+
| bare statements | wrapped in one transaction: `BEGIN`, `set_config`, the statement, `COMMIT` |
|
|
211
|
+
| who is subject | a role that is `nosuperuser nobypassrls` and owns no table. Anything else is `X_DB_ROW_SECURITY_BYPASSED` at boot. The migrator (`bypassrls`) owns the tables, so a backfill still sees every row |
|
|
212
|
+
| limits | catches forgetting, not hostile SQL already in the process: a raw `set_config('x.cross', 'on', true)` lifts it. A `crossTenant()` opened inside an open transaction does not reach it — its settings were fixed at `BEGIN` |
|
|
213
|
+
|
|
214
|
+
## Testing denials
|
|
215
|
+
|
|
216
|
+
Generated with the primitive, fails until filled in:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// contract test — generated as a scaffold with the action
|
|
220
|
+
test('publishPost denies a non-owner', async ({ seed, actorFor }) => {
|
|
221
|
+
const { post, stranger } = await seed('two-orgs');
|
|
222
|
+
await expect(publishPost.as(actorFor(stranger), { postId: post.id }))
|
|
223
|
+
.rejects.toBeUltimateError('X_FORBIDDEN');
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Test type | Asserts about authz |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `x test unit` | policy predicates in isolation — pure logic, no DB |
|
|
230
|
+
| `x test contract` | one denial per policy branch, plus the MCP tool + OpenAPI shape |
|
|
231
|
+
| `x test live` | a policy-failing row is **never delivered**, including after a reconnect delta |
|
|
232
|
+
| `x test job` | a revoked actor fails the job instead of skipping the step |
|
|
233
|
+
|
|
234
|
+
Fixtures are deterministic: `seed('two-orgs')` produces identical rows and identical UUIDs every run, so a denial test cannot pass by accident.
|
|
235
|
+
|
|
236
|
+
## Why it is shaped this way
|
|
237
|
+
|
|
238
|
+
> **Two authz systems is how every Meteor-like framework died.** Meteor had `allow`/`deny` rules for the client sync path and plain method bodies for the server path; the two drifted, and the drift *was* the security model. Anything that lets an agent expose data through a second door — a "public" MCP tool, an "internal" RPC, a sync rule table — is a rejected design, not a config option.
|
|
239
|
+
|
|
240
|
+
Designs this rejects, permanently:
|
|
241
|
+
|
|
242
|
+
| Rejected | Why |
|
|
243
|
+
|---|---|
|
|
244
|
+
| A sync/subscription rule table separate from server authz | the two drift; the drift is the vulnerability |
|
|
245
|
+
| MCP-specific permissions, "trusted tool" mode, agent service accounts | an agent must never exceed the human it acts for |
|
|
246
|
+
| An "internal" RPC surface that skips `policy` | a second door, and the one no one audits |
|
|
247
|
+
| GraphQL | a second schema language **and** a second authz surface, per resolver |
|
|
248
|
+
| Row-level security as the only mechanism | policy must also run for jobs and MCP calls, where no DB session carries the actor |
|
|
249
|
+
| Authorization inside `handle` | unreachable from the live-query and MCP paths, so it silently protects one door of four |
|
|
250
|
+
|
|
251
|
+
Source: [`docs/idea/02-primitives.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/02-primitives.md), [`docs/idea/09-ai-first.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/09-ai-first.md). Related: [Actions](Actions), [Queries and live queries](Queries-And-Live-Queries), [MCP and AI](MCP-And-AI), [Error codes](Error-Codes).
|