@ultimat3/cli 29.4.0 → 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 +11 -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 +9 -0
- 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 +41 -81
- 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 +7 -1
- 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,432 @@
|
|
|
1
|
+
# Admin dashboard
|
|
2
|
+
|
|
3
|
+
One declaration serves it. `defineAdmin({ entities, db })` is a list, a detail and a form for every entity at `/admin/<entity>` — the app writes no page, no screen glue and no repo adapter. `As of 2026-10`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// apps/admin/app/admin/admin.ts — the whole dashboard
|
|
7
|
+
import { db } from '@app/db';
|
|
8
|
+
import { adminEntitiesOf, defineAdmin } from '@ultimat3/admin';
|
|
9
|
+
|
|
10
|
+
export const admin = defineAdmin({ entities: adminEntitiesOf(db), db });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Input | Default | Override |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `entities` | required — `adminEntitiesOf(db)` is "every entity the handle serves" | name them: `entities: [posts, users]` |
|
|
16
|
+
| `db` | the app's `database()` handle; each resource reads through its own table — tenancy, soft delete and sealing are the handle's | `resources: { <entity>: { repo } }` for one resource. Neither is `X_ADMIN_REPO_UNBOUND` at declaration |
|
|
17
|
+
| `auth.actor` | the actor the HTTP pipeline resolved for the request | `auth: { actor: (request) => … }` |
|
|
18
|
+
| `auth.authz` | `roleAuthz()` — the role map decides each permission by name, so the admin is closed until a role grants `admin:read` and `<entity>:read` | `policyAuthz({ policies })` for row- or tenant-reading rules; `singlePolicyAuthz(policy)` when one grant runs the whole admin |
|
|
19
|
+
| `resources.<entity>` | derived: list columns, filters, sorts, label | `listFields`, `labelField`, `columns`, `scopes`, `count` (a tile on `/admin` — [below](#the-home)), `rows`, `fields.<name>.sensitive`, `permission` (the noun `<noun>:read\|write\|delete` is named after; default the entity's name) |
|
|
20
|
+
| `basePath` | `/admin` | `basePath: '/back-office'` |
|
|
21
|
+
|
|
22
|
+
`x dev` and the container mount every admin the app declared: one catch-all under its base path, `GET` and `POST`. `x new` scaffolds the declaration; `x g entity` adds an entity to the handle and it is a screen on the next boot.
|
|
23
|
+
|
|
24
|
+
| URL | Screen | Write it accepts |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `/admin` | the home: a count tile per resource that opted in ([below](#the-home)), then each resource the actor may open, with the permission pair behind every operation | `POST` an app-wide action |
|
|
27
|
+
| `/admin/<entity>` | list — scope tabs, a filter bar, the batch bar, sort headers and a keyset pager, all links and native forms (`?scope=`, `?f.<field>=`, `?sort=<field>:<dir>`, `?cursor=`) | `POST _operation=batch` (`name`, `selection=checked\|all`, `ids`) |
|
|
28
|
+
| `/admin/<entity>/lookup?term=` | the picker every filter and input referencing the entity searches: `contains` on its label, keyset-paged | — |
|
|
29
|
+
| `/admin/<entity>/new` | create form | `POST` → 303 to the row, or 422 with the issues |
|
|
30
|
+
| `/admin/<entity>/<id>` | detail: sections, related lists, row actions, history (`?history=` pages older) | `POST _operation=action` (`input.<field>`) · `_operation=delete` (needs `confirmation`) |
|
|
31
|
+
| `/admin/<entity>/<id>?action=<name>` | an action's own form, when it declares an `input` schema | posts at the row |
|
|
32
|
+
| `/admin/<entity>/<id>/edit` | edit form | `POST` → 303 to the row, or 422 |
|
|
33
|
+
| `/admin/search?term=` · `/admin/audit` | built-in screens | — |
|
|
34
|
+
| `/admin/jobs` · `/admin/jobs/runs` · `/queues` · `/tasks` · `/workers` | the jobs dashboard every admin carries — [below](#the-jobs-dashboard) | row actions and the batch bar, as any resource |
|
|
35
|
+
| `/admin/<page path>` | a `pages:` entry | — |
|
|
36
|
+
|
|
37
|
+
A refusal is a 403 document naming the permission, and an audit entry. Anonymous is the pipeline's own 401 (or the app's sign-in redirect). Every admin document is `noindex, nofollow` and carries no `og:*` / `twitter:*` tag. A row action lands on its row (303); a batch answers a page of counts (200).
|
|
38
|
+
|
|
39
|
+
## The home
|
|
40
|
+
|
|
41
|
+
**A resource is counted on `/admin` only when it declares `count: true`.** `As of 2026-10`. A tile is one full `AdminRepo.count()` over the actor's `rows` scope on **every** visit, so an operator reloading the front page over a big table was a load generator — the same reason a scope tab's `count` is opt-in.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
resources: { posts: { count: true } },
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Resource | On the home |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `count: true`, the actor may list it | a `StatTile` linked to the list, and a segment of the `DonutChart` |
|
|
50
|
+
| no `count` | no tile and no segment; still in the permission matrix |
|
|
51
|
+
| the actor may not list it | nothing — no tile, no segment, no matrix |
|
|
52
|
+
| its repo has no `count()`, or the store refuses it (`X_TENANCY_UNSCOPED` for an actor with no org) | no tile; the page never fails |
|
|
53
|
+
|
|
54
|
+
A count reads no row, so it is not audited. Upgrading from a release that counted every listable resource: add `count: true` to each resource whose tile you want back.
|
|
55
|
+
|
|
56
|
+
## The list
|
|
57
|
+
|
|
58
|
+
`As of 2026-10`. Everything on a list page is derived from the entity or declared once on the resource.
|
|
59
|
+
|
|
60
|
+
| On the page | Source | Notes |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| Filter bar | one control per derived filter: text → `contains`, enum → a select of the entity's own values, boolean, number/date/timestamp → a from–to range, reference → a picker | the label field is always first — it is the list's search box. Money, JSON, sealed and tenant columns are never filters |
|
|
63
|
+
| Scope tabs | `scopes: { open: { where, default?, count? } }` | at most one `default`; a count is read only for a scope that sets `count: true` |
|
|
64
|
+
| Reference cells | the TARGET resource's `labelField`, linked to the row | one read per referenced resource per page, never one per row |
|
|
65
|
+
| Computed columns | `columns: { age: { value, render } }` | `render`: `badge`, `relative-time`, `money`, `truncate`, `link`, `json`, or a component |
|
|
66
|
+
| Row scope | `rows: (actor) => AdminFilter[]` | applied by the list, the search, the detail read, the lookup, the labels and the MCP tools — one declaration, no second copy of the resource for a second audience |
|
|
67
|
+
|
|
68
|
+
| URL parameter | Means |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `?f.<field>=<value>` | the field's default operator (`contains` for text, `eq` otherwise) |
|
|
71
|
+
| `?f.<field>.<op>=<value>` | `eq` `neq` `contains` `gt` `gte` `lt` `lte` `in` (repeat the parameter) `is-null` (`true` / `false`) — the ones the field's shape answers |
|
|
72
|
+
| `?scope=<name>` | a declared scope; absent is the default; `*` is no scope at all — what a related card's link opens. A scope may not be declared as `*` |
|
|
73
|
+
| `?sort=<field>:<asc\|desc>` | a sortable field |
|
|
74
|
+
| `?cursor=<signed>` | keyset position on `(sort, id)`: any number of rows sharing a sort value, and NULLs (last ascending, first descending), are reached both ways |
|
|
75
|
+
|
|
76
|
+
An unknown field, operator, sort, scope or parameter — or a value that is not the column's type (a number is decimal digits: no blank, hex or exponent; a date is one the calendar has) — is `X_ADMIN_FILTER_INVALID`: a 400 naming what the list answers, never the unfiltered list. The MCP list tool takes the same grammar (`scope`, `where: [{ field, op?, value }]`).
|
|
77
|
+
|
|
78
|
+
**A view lists read-only.** An entity declared `view: { sql }` ([Entities and migrations](Entities-And-Migrations#views)) offers `list`, `detail` and `search` and nothing else: no `new` or `edit` route, no create, edit or delete button, no `admin.<entity>.create`/`update`/`delete` MCP tool — for an actor holding every permission too, because its rows are Postgres' to derive. An `operations` override naming a write on one is `X_ADMIN_VIEW_WRITE` at `defineAdmin`, before any route exists. `As of 2026-11`.
|
|
79
|
+
|
|
80
|
+
Statements per list page: 1 for the rows (2 when the sort column is nullable, one more when a page ends inside one millisecond of an instant sort, and a page past its first adds the rest of the boundary's tie), 1 per counted scope, 1 per referenced resource per 200 ids (2 for one with more than 50 rows that a control picks from). A reference picker is a `<select>` up to 50 rows and the target's lookup screen above that — both work with scripting off.
|
|
81
|
+
|
|
82
|
+
## Detail, form, actions
|
|
83
|
+
|
|
84
|
+
`As of 2026-10`.
|
|
85
|
+
|
|
86
|
+
| Declared on the resource | Rule |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `sections: [{ titleKey, fields }]` | titled groups on the detail; a field named in none falls into a default section drawn last — adding a column never hides it |
|
|
89
|
+
| `formGroups: [{ titleKey, fields }]` | the same for create and edit |
|
|
90
|
+
| `related: ['comments']` | a `hasMany` of the entity, drawn as the related resource's OWN list — its columns, its `rows`, its policy — filtered to this row. A resource the actor may not list is left out |
|
|
91
|
+
| `fields.<name>.hintKey` · `.on: 'create' \| 'update'` | the line under the control; a field that exists on one side only, enforced on the write too |
|
|
92
|
+
|
|
93
|
+
| Declared on an action | Rule |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `input: t.object({ … })` | the button is a link to the action's form; a refusal is a 422 with each issue on its field. No schema: a confirm only |
|
|
96
|
+
| `when: (row) => boolean` | decides the button on the detail and each list row, and is asked again before the handler: `X_ADMIN_ACTION_NOT_APPLICABLE` (409) |
|
|
97
|
+
| `batch: true` | in the list's batch bar: checked rows, or every row the list's URL matches (200 per request, continued from where it stopped). Each row through the button's gate and on the audit log; answered `done` / `refused` / `failed` / `remaining` |
|
|
98
|
+
| `batch: { threshold, chunk? }` | past `threshold` rows, one `admin.batch` job per chunk, run by a worker as the operator; `queued` in the answer. The batch id is derived from actor, action, rows and input, so retrying a request whose enqueue failed part-way dedupes the chunks already queued |
|
|
99
|
+
| `matching: ({ where, input, ctx }) => { affected, remaining }` | "all matching" as ONE set-based call over the list's `where` (row scope, scope, filters) — a store's bounded bulk verb — decided and audited once; a destructive one types `<entity>:all matching`. Checked rows still run row by row |
|
|
100
|
+
| `readonly: true` | the action changes nothing (an export, a verification), so its admin-level gate is `admin:read`, not `admin:write`: a read-only staff role and an `admin:read` MCP token may run it. The action's own `permission` is still required beside it |
|
|
101
|
+
| `destructive: true` | gated on `admin:destroy`; one row types `<entity>:<id>`, a batch `<entity>:<n> rows` |
|
|
102
|
+
|
|
103
|
+
**The admin-level gate an action needs** — `adminPermissionForAction` (`packages/admin/src/action-gate.ts`), first match wins, so a contradiction fails closed:
|
|
104
|
+
|
|
105
|
+
| Declares | Gate |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `destructive: true` | `admin:destroy`, whatever else it declares |
|
|
108
|
+
| `matching` | `admin:write`, **even with `readonly: true`** — it changes every row the list matches |
|
|
109
|
+
| `readonly: true` | `admin:read` |
|
|
110
|
+
| neither | `admin:write` |
|
|
111
|
+
|
|
112
|
+
Still one MCP tool per action — a batch action's takes `ids`. No script on any of it: the row checkboxes join the bar's form by their `form` attribute, and a confirmation is a server round trip. An action's input fields are labelled `admin.input.<action>.<field>` — beside its own `admin.action.<action>`, never under it, so both fit one JSON catalog. An action on a row the actor cannot see (gone, or outside its `rows`) is refused, `when` or none.
|
|
113
|
+
|
|
114
|
+
A create, or an update of the fields it touches, whose resulting row falls outside the actor's `rows(actor)` is refused before the repo is called — audited as a denial, reason `admin.error.row-out-of-scope`, on the screens and over MCP alike. Both are then decided again on the validated values they write, as the subject's `input` (an update: beside its `row`).
|
|
115
|
+
|
|
116
|
+
An edit form carries the row's version (`_version`); a row someone changed after the form was drawn is a 409 (`admin.error.row-changed`) with the form redrawn over the row as it is now — never overwritten. A `datetime-local` box holds minutes, so an instant posted back exactly as it was drawn is left out of the write.
|
|
117
|
+
|
|
118
|
+
## The audit log
|
|
119
|
+
|
|
120
|
+
`As of 2026-10`. `defineAdmin({ audit })` takes one `AuditLog`; the default is `memoryAuditLog()`.
|
|
121
|
+
|
|
122
|
+
| Store | Keeps | Use |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `memoryAuditLog()` | this process's entries, until a restart | tests, `x dev` |
|
|
125
|
+
| `postgresAuditLog()` | `x_admin_audit` — applied at boot with every framework table, from `@ultimat3/admin/schema`; one insert per audited write, inside the write's own transaction (`AuditLog.atomic`) — a CRUD write, an action's handler, a `matching` set and a queued batch's enqueues alike: an entry that cannot be written rolls the work back, and the log holds one `failed` entry | production: `audit: Bun.env['DATABASE_URL'] ? postgresAuditLog() : memoryAuditLog()` |
|
|
126
|
+
| your own `AuditLog` | anything with `append`, `entries(query)` (async), `atomic(run)` and `kind`. `entries` honours every `AuditQuery` field — `entity`, `entityId`, `actorId`, `orgId`, `outcome`, `since` (inclusive), `until` (exclusive), `changes`, `before`; normalise bounds with `auditWindow`, test an entry with `auditEntryMatches`. `/admin/audit` re-checks each returned entry, so a log that ignores a field draws a short page, never a wider one | — |
|
|
127
|
+
|
|
128
|
+
| Rule | Detail |
|
|
129
|
+
|---|---|
|
|
130
|
+
| What is recorded | every write and every refusal. An allowed read (`list`, `detail`, `search`, a page) is stored by `postgresAuditLog({ reads: true })` only |
|
|
131
|
+
| Redaction | a `sensitive` or sealed value is only ever `[redacted]`, in the diff and on screen |
|
|
132
|
+
| Where it shows | the detail page's history card — `audit.entries({ entity, entityId, changes: true })`, keyset by `?history=`; `/admin/audit` (`audit:read`) for the whole trail, 100 per page, older pages by keyset. Both read only the actor's tenant's entries (`orgId`) when the actor has an org |
|
|
133
|
+
| Searching the trail | `As of 2026-11`. `/admin/audit` is one native GET form: `?entity=` `?actor=` `?outcome=allowed\|denied\|failed` `?since=` `?until=` (ISO-8601 with an offset or `Z`, `YYYY-MM-DD` as that day's first UTC instant, or the form's `YYYY-MM-DDTHH:mm`, read as the UTC its label says; `until` exclusive). A store (`AuditQuery`) takes only the first two: a zoneless time is refused `?changes=1` `?before=<at>~<id>`. The older link carries the filters. Any other parameter — `?org=` included, the tenant is the actor's — or an unreadable value is a 400, `X_ADMIN_FILTER_INVALID`, never the unfiltered trail. `auditHref(path, request)` builds a link to a filtered trail. An entry links to its row when the resource is mounted and the actor may open it. No CSV: an `AdminCustomPage` with `exportRows()` composes one |
|
|
134
|
+
| A copy elsewhere | `memoryAuditLog({ sinks })` / `postgresAuditLog({ sinks })` — each `AuditSink` gets a copy of every entry; a sink is write-only and never read back |
|
|
135
|
+
|
|
136
|
+
## Errors
|
|
137
|
+
|
|
138
|
+
`As of 2026-11`. `pages: errorGroupsPages({ store, navGroup? })` mounts `/admin/errors` — every error group an `errorGroupsSink` folded, newest-seen first, filterable by status — and `/admin/errors/:fingerprint`, one group's sample. Two `pages:` entries, not a new screen kind: the route table, the nav and the refusal audit are every custom page's.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { defineAdmin, errorGroupsPages } from '@ultimat3/admin';
|
|
142
|
+
import { postgresErrorGroupsStore } from '@ultimat3/admin/error-groups';
|
|
143
|
+
|
|
144
|
+
const errorGroups = postgresErrorGroupsStore();
|
|
145
|
+
|
|
146
|
+
export const admin = defineAdmin({
|
|
147
|
+
entities: [],
|
|
148
|
+
pages: errorGroupsPages({ store: errorGroups, navGroup: 'admin.group.ops' }),
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
| Rule | Detail |
|
|
153
|
+
|---|---|
|
|
154
|
+
| Who | `admin:read` plus `errors:read` (`ERRORS_READ`). Grant it in the role map, or the `policy` step is `X_PERMISSION_UNGRANTED` |
|
|
155
|
+
| The store | the one the app's sink writes (`@ultimat3/admin/error-groups`): `postgresErrorGroupsStore()` (`x_error_groups`, applied at boot), `memoryErrorGroupsStore()` in tests. Installing the sink → [Observability](Observability#error-groups) |
|
|
156
|
+
| A row | code, source, operation, the cause's first line, first and last seen, count, status (`open`, `resolved`, `ignored`; a resolved group that recurs re-opens). The detail shows the redacted sample: release, environment, request and trace ids, meta, stack |
|
|
157
|
+
| Labels | `admin.errors.*` keys in the app's catalogs: `title`, `detail.title`, `empty`, `missing`, `back`, `count`, `filter.status`, `status.<status>`, `field.<code\|count\|environment\|first\|last\|operation\|release\|request\|source\|status\|trace>` |
|
|
158
|
+
|
|
159
|
+
## The jobs dashboard
|
|
160
|
+
|
|
161
|
+
`As of 2026-10`. Every `defineAdmin()` serves it; the app declares nothing. It is built from the admin's own pieces — four resources over the queue's operator surface (`JobIntrospection`) with a `repo:`, scopes, a row scope and actions, plus one overview screen. Zero JavaScript, like every admin screen.
|
|
162
|
+
|
|
163
|
+
| URL | Shows | Actions (`job:manage`) |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `/admin/jobs` | tiles: ready, running, delayed, suspended, dead, workers, oldest ready (one `stats()`); done vs failed over `?range=1h\|24h\|7d\|30d` from the settle counters; volume, failure rate and mean duration per job name. Re-read every 30 s by a declared `<meta http-equiv="refresh">` | — |
|
|
166
|
+
| `/admin/jobs/runs` | one tab per state; the search box is an id prefix; filters by name, queue, state, tenant, created time | retry (dead rows), retry from a step, run now (waiting on its clock), cancel (live), remove |
|
|
167
|
+
| `/admin/jobs/runs/<id>` | input (redacted by `@ultimat3/jobs`), steps, error and stack, progress, tenant, trace, history | the row's own |
|
|
168
|
+
| `/admin/jobs/queues` | depth per state, paused, a link to the queue's jobs | pause, resume |
|
|
169
|
+
| `/admin/jobs/tasks` | schedule, IANA zone, last fire, next fire, paused | pause, resume, run now |
|
|
170
|
+
| `/admin/jobs/workers` | identity, host, queues, in flight, heartbeat | — |
|
|
171
|
+
|
|
172
|
+
| Rule | Detail |
|
|
173
|
+
|---|---|
|
|
174
|
+
| Read-only is a permission | `job:read` sees every screen and no control; `job:manage` (behind `admin:write`, or `admin:destroy` for remove) runs every action and is refused on the server when forged |
|
|
175
|
+
| "All matching" | retry, run now and remove over a state tab are ONE bulk call (`requeueMany`, `promoteMany`, `removeMany`, at most 1,000 rows, answering `{ affected, remaining }`). A tab the action is not for, no tab, or a narrowing by id or time is refused by name |
|
|
176
|
+
| Tenancy | an actor with an `orgId` sees that org's job rows only, and none of the fleet — queues, tasks, workers and the overview's figures are every tenant's. A platform operator is the actor without one. Override: `resources: { x_jobs: { rows } }` |
|
|
177
|
+
| No counts on the state tabs | `stats()` counts every tenant, so a count on a tab would show an org-scoped operator other orgs' numbers. The counts are the overview's tiles, which only a platform operator sees |
|
|
178
|
+
| Grants | the four resources share the permission noun `job` (`AdminResourceOptions.permission`), so `job:read` and `job:manage` are the whole vocabulary — never `x_jobs:read`. Both are declared by `defineAdmin()`; a role that should operate the queue is granted them in the role map |
|
|
179
|
+
| MCP | `admin.x_jobs.list`, `admin.x_jobs.read`, `admin.x_job_tasks.list`, `admin.action.job.retry`, `admin.action.job.queue.pause`, … — the resources' and actions' own tools |
|
|
180
|
+
| `/_x/jobs` | the same overview component over the dev process's queue |
|
|
181
|
+
|
|
182
|
+
## Screens and their source
|
|
183
|
+
|
|
184
|
+
The served admin, in every environment:
|
|
185
|
+
|
|
186
|
+
| Screen | Derived from | Shows |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| Entity list | the entity's columns, by type | keyset-paginated table; filters derived per column type ([The list](#the-list)) |
|
|
189
|
+
| Entity detail / edit | the entity's Standard Schema | a widget per column kind (`fields.ts`, the one type → widget table); input handed straight to the schema, so validation is the same one the write uses |
|
|
190
|
+
| Action form | `AdminAction.input` + its `permission` | a form per action, a 422 with each issue on its field, a 403 naming the permission |
|
|
191
|
+
| Jobs dashboard | the queue's operator surface (`JobIntrospection`) | the overview, runs, queues, tasks, workers — [above](#the-jobs-dashboard) |
|
|
192
|
+
| Audit | the `AuditLog` | every recorded entry, newest first — [above](#the-audit-log) |
|
|
193
|
+
|
|
194
|
+
The `/_x` dev dashboard (`devDashboard()`, `@ultimat3/admin/dev`) is a separate page that refuses to mount in production. Its tabs are `DEV_PANELS` in `packages/admin/src/dev/server.ts`; each has a `--json` twin at `/_x/<tab>?json=1`:
|
|
195
|
+
|
|
196
|
+
| Tab | Shows |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `routes` | render mode, hydrate timing, offline strategy, budget, meta status. The admin's own routes are MOUNTED routes — `x routes` prints `defineAdmin() · admin:read + <table>:read` where a page names its file |
|
|
199
|
+
| `timeline` | one request as a flamegraph — SQL, cache hits, action calls, policy decisions — with the N+1 counted |
|
|
200
|
+
| `live` | every live subscriber, what it received, and the matcher's decision trace |
|
|
201
|
+
| `jobs` | the jobs overview component over the dev process's queue, its JSON payload (queues, runs, step traces, tasks) folded under it |
|
|
202
|
+
| `db` | the tables, read-only by default, and the schema diff against the migration history |
|
|
203
|
+
| `mail` | every caught message, rendered, grouped per locale |
|
|
204
|
+
| `cache` | the tag graph and the invalidation log |
|
|
205
|
+
| `policy` | rule-by-rule decision trace for the last request |
|
|
206
|
+
| `manifest` | the emitted `x.manifest.json` diffed against the committed one. Its `admin` section records each resource's filters, sorts, scopes, row scope, sections, related lists, actions and the audit store's `kind` |
|
|
207
|
+
|
|
208
|
+
## Authz
|
|
209
|
+
|
|
210
|
+
One authz seam for the whole admin: the rendered button, the HTTP call behind it, the MCP tool, the nav item, and the search result all ask the same interface.
|
|
211
|
+
|
|
212
|
+
| Property | Detail |
|
|
213
|
+
|---|---|
|
|
214
|
+
| Actor | the actor the HTTP pipeline resolved for the request — the app's own authenticator; `auth: { actor }` overrides |
|
|
215
|
+
| Decision | `roleAuthz()` by default — the role map decides each permission by name, and `admin:destroy` implies `admin:write` implies `admin:read`, as `staticAuthz` answers. `policyAuthz({ policies })` adapts the app's `policy` layer when a rule reads the row or the tenant: a mapped permission is decided by its policy, an unmapped one by the role map, so a grant in `defineRoles` is enough for the jobs and audit screens (`job:read`, `audit:read`). The three framework constructors decide on the actor's roles AND its direct grants (`Actor.permissions`), as the request pipeline does; a custom `AdminAuthz` is handed `actor.permissions` and decides with it as it chooses |
|
|
216
|
+
| Admin permissions | `admin:read`, `admin:write`, `admin:destroy`, `admin:impersonate` — ordinary permission strings evaluated by the same policy engine |
|
|
217
|
+
| Per-entity gate | an actor needs the admin-level permission **and** the table's: `<table>:read` to list, open and search, `<table>:write` to create and edit, `<table>:delete` to delete. `defineAdmin()` declares them; `x g entity` / `x g resource` declare and grant a new table's three to the scaffold's `admin` role |
|
|
218
|
+
| View-only | a role holding `admin:read` and each `<table>:read` and nothing else — every write is refused by the decision that hid its button. It may still run a `readonly` action whose own `permission` it holds. Not a mode |
|
|
219
|
+
| Jobs and audit screens | `job:read` and `audit:read`, beside `admin:read`; every jobs action is `job:manage` |
|
|
220
|
+
| Denial | `{ allowed, permission, reason, trace }`; `reason` is an i18n key or a rule name, never a sentence |
|
|
221
|
+
| UI invariant | the UI cannot show what the call would refuse — one decision per request, consulted by every surface |
|
|
222
|
+
| Destructive ops | `delete` requires an echoed confirmation token and is always audited. Both are data in a table, not a code path in a view |
|
|
223
|
+
| Audit | every write and refusal is an `AuditLog` entry with a redacted field diff — [The audit log](#the-audit-log). `sensitive` columns are never rendered and never diffed |
|
|
224
|
+
|
|
225
|
+
### What it never does
|
|
226
|
+
|
|
227
|
+
| Never | Why |
|
|
228
|
+
|---|---|
|
|
229
|
+
| A second authz system | two authz systems is how every Meteor-like framework died. There is one `policy` layer |
|
|
230
|
+
| An admin-only user table or role table | admins are users with permissions; a parallel identity store drifts and gets forgotten in offboarding |
|
|
231
|
+
| A "superuser" bypass | there is no flag that skips policy evaluation |
|
|
232
|
+
| Writes without a policy | an action with no `policy` does not compile; the admin cannot invoke what does not exist |
|
|
233
|
+
| A raw SQL console that writes | read-only SQL only, statement-capped and row-capped. Writes and data-modifying CTEs are refused with `X_MCP_QUERY_REJECTED` |
|
|
234
|
+
| Offset pagination | offset re-scans every page and skips rows when the table is written to mid-page. Keyset only |
|
|
235
|
+
| Hidden framework internals as user data | `/_x` panels are dev-only and gated by role |
|
|
236
|
+
|
|
237
|
+
## MCP over the app's own actions
|
|
238
|
+
|
|
239
|
+
The admin exposes an MCP surface over the actions **the app already declared**, so the user's agents can drive the user's product with exactly the permissions the human has.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
mcp: { expose: true, description: 'Publish a draft post' },
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
| Property | Consequence |
|
|
246
|
+
|---|---|
|
|
247
|
+
| Actor = the signed-in user's session | an agent can never exceed the human it acts for |
|
|
248
|
+
| Policies unchanged | no separate "API permissions" screen to get wrong |
|
|
249
|
+
| Tool list is generated | adding a feature adds a capability; deleting one removes it |
|
|
250
|
+
| Every tool carries a scope | the admin-level permission it needs — [table below](#tool-scopes). A token can be narrower than its actor, never wider |
|
|
251
|
+
| Ships on | day-one agent access to operations, not a v3 roadmap item |
|
|
252
|
+
| Exposure | two surfaces, two defaults — and this catalog is the **only** exception in the framework. Inside the admin's own catalog a registered action becomes a tool unless it sets `mcp: { expose: false }`, because every tool here is already gated on an admin permission and the CRUD tools carry no `mcp` block at all. Everywhere else — the app MCP surface (`defineAppMcp`), the LLM tool list, `openapi.json`'s `x-ultimate.mcpTool` and `x.manifest.json` — one predicate decides, and it is opt-in: only `mcp: { expose: true }` makes a tool, and naming an un-exposed primitive in `defineAppMcp` is `X_MCP_TOOL_UNDECLARED` at boot ([Actions](Actions)) |
|
|
253
|
+
|
|
254
|
+
The projected tool calls `action.run(...)` — the same entry point the HTTP route calls. Policy evaluation lives inside `run`, so there is nothing to keep in sync. Details: [MCP and AI](MCP-And-AI).
|
|
255
|
+
|
|
256
|
+
### Tool scopes
|
|
257
|
+
|
|
258
|
+
`As of 2026-10`. `adminMcp()` hands the MCP registry one scope per tool (`adminToolScopes`, `packages/admin/src/mcp-scopes.ts`). Tool names are unchanged.
|
|
259
|
+
|
|
260
|
+
| Scope | Tools |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `admin:read` | `admin.<entity>.list`, `admin.<entity>.read`, `admin.search`, a `readonly` action's `admin.action.<name>` |
|
|
263
|
+
| `admin:write` | `admin.<entity>.create`, `admin.<entity>.update`, any other action — `matching` ones included |
|
|
264
|
+
| `admin:destroy` | `admin.<entity>.delete`, a `destructive` action |
|
|
265
|
+
|
|
266
|
+
A call is decided in this order:
|
|
267
|
+
|
|
268
|
+
| # | Check | Refusal |
|
|
269
|
+
|---|---|---|
|
|
270
|
+
| 1 | the actor's own admin authz (`adminMcpTools`) — a tool it denies is **absent** | `X_MCP_TOOL_UNKNOWN` — no such tool for this caller, whatever the token holds |
|
|
271
|
+
| 2 | the token's scope, before any argument is validated or a handler runs | `X_MCP_SCOPE_DENIED`, naming the scope |
|
|
272
|
+
| 3 | the call's row-level decision inside the tool | the tool's own refusal, audited |
|
|
273
|
+
|
|
274
|
+
The token's scopes are `tokenScopes` on what `adminMcp({ actor })` returns for a session (`AdminMcpActor`), expanded by `adminTokenScopes`: **`admin:destroy` ⇒ `admin:write` ⇒ `admin:read`**.
|
|
275
|
+
|
|
276
|
+
| `tokenScopes` | The token may call |
|
|
277
|
+
|---|---|
|
|
278
|
+
| absent | everything its actor may — what every admin token was before tools carried a scope |
|
|
279
|
+
| `['admin:read']` | list, read, search and `readonly` actions — a read-only agent token |
|
|
280
|
+
| `['admin:write']` | the above, plus create, update and every non-destructive action |
|
|
281
|
+
| `['admin:destroy']` | every tool its actor may call |
|
|
282
|
+
| `[]` | nothing — an empty list is a token granted nothing, never "unstated" |
|
|
283
|
+
| a non-admin scope (`posts:write`) | dropped; the admin's tools read its own vocabulary only |
|
|
284
|
+
|
|
285
|
+
Named `tokenScopes`, not `scopes`: core's `Actor.scopes` is a different thing, and an actor handed straight through must not have its `[]` read as "this token may do nothing". A stdio host builds its caller's scopes with `adminTokenScopes(granted)` too.
|
|
286
|
+
|
|
287
|
+
## Theming
|
|
288
|
+
|
|
289
|
+
The generated admin reads the same 24 semantic colour roles as the app, through the same four blocks: light in `:root`, dark behind the media query, both mirrored under `html[data-theme]`. A raw hex in an admin stylesheet is the same `raw-colour` guard finding as anywhere else, and `ThemeTokenRef` is a template-literal type, so `--x-*` typechecks and a hex does not.
|
|
290
|
+
|
|
291
|
+
The `/_x` **dev** dashboard is a standalone page with no stylesheet pipeline, so it inlines its six channels — `bg`, `surface-raised`, `fg`, `fg-muted`, `line`, `accent` — into a `<style>` element. `As of 2026-08` it **derives** them from `@ultimat3/ui`'s `colorTokens` at render time rather than keeping a copy: the copy it used to keep went stale through the WCAG retune and shipped `line` on `surface-raised` at 1.16:1. The barrel is reached by dynamic `import()`, for the same reason the panels reach introspection that way — `/_x` stays out of the production graph → [Theming](Theming).
|
|
292
|
+
|
|
293
|
+
| Concern | Behavior |
|
|
294
|
+
|---|---|
|
|
295
|
+
| Theme | follows the OS; explicit `localStorage` choice wins; applied before first paint → [Theming](Theming) |
|
|
296
|
+
| Strings | every label through `t()`. `labelKey` fields in the permission and field tables exist so no view holds a sentence. Every key a declaration derives — branding, nav, titles, fields, sections, scopes, columns, action labels (`labelKey`, absent: `admin.action.<name>`) and their input fields — is `admin.catalogKeys()`; `x verify`'s `i18n` step asks each app catalog for exactly that list (`X_CATALOG_MISSING_KEYS`) → [I18n](I18n) |
|
|
297
|
+
| Timestamps | the admin refuses to render a timestamp without an IANA zone; the actor's zone comes from the session → [Timezones and dates](Timezones-And-Dates) |
|
|
298
|
+
| Money columns | `{ minor, currency }` formatted with `Intl.NumberFormat`; the raw minor value on hover → [Money](Money) |
|
|
299
|
+
|
|
300
|
+
## Render modes
|
|
301
|
+
|
|
302
|
+
**One shape for every generated route, from one call site** — `adminRouteConfig` in
|
|
303
|
+
[`packages/admin/src/routes.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/admin/src/routes.ts),
|
|
304
|
+
never an author's choice, `As of 2026-08`.
|
|
305
|
+
|
|
306
|
+
| Key | Value | Why |
|
|
307
|
+
|---|---|---|
|
|
308
|
+
| `render` | `ssr` | behind auth and with no SEO value, but it has to be correct on first byte. It was `spa` until 6.0.0 deleted that mode: nothing built the client bundle it preloaded and `renderSpa` never read the route's component, so every generated view served an empty `<div id="x-root">` |
|
|
309
|
+
| `offline` | `network-only` | an operator acting on stale operational data is worse than an error |
|
|
310
|
+
| `hydrate` | `never` | every control on a generated screen is a link or a native `<form method="post">` — paging, search, the forms, row actions — so the page ships **zero JavaScript** and has nothing to boot. It also means a generated screen declares **no island**: an island rendered on a route at `never` is `X_ISLAND_NOT_HYDRATED`, whose fix is to remove the `never` |
|
|
311
|
+
| `policy` | `permissions[0]`, always | the author never writes this `defineRoute` call, so the author cannot omit the guard; an empty permission list is `X_ADMIN_PAGE_UNGUARDED` at construction |
|
|
312
|
+
|
|
313
|
+
Interactivity on an admin URL is an app file that claims the route — [An admin page in your own
|
|
314
|
+
shell](#an-admin-page-in-your-own-shell) — where `hydrate` derives from the islands it declares.
|
|
315
|
+
|
|
316
|
+
The **request timeline** and the **live query inspector** are not admin routes: they are `/_x`
|
|
317
|
+
panels (`panel-timeline.ts`, `panel-live.ts`), mounted by `devDashboard()`, which throws rather
|
|
318
|
+
than mounting when `ROLE` and the environment say production. The live panel reads registered live
|
|
319
|
+
queries and their subscribers out of introspection — `@ultimat3/admin` does not depend on
|
|
320
|
+
`@ultimat3/realtime` and subscribes to nothing.
|
|
321
|
+
|
|
322
|
+
See [Routes and render modes](Routes-And-Render-Modes).
|
|
323
|
+
|
|
324
|
+
## Extending it
|
|
325
|
+
|
|
326
|
+
Own routes in your own app. Never fork the framework.
|
|
327
|
+
|
|
328
|
+
| Want | Do |
|
|
329
|
+
|---|---|
|
|
330
|
+
| A custom screen | `pages: [{ path, titleKey, permissions, component }]` on `defineAdmin` — same route table, same guard, framed in the shell. `x g admin:page <name>` writes it beside the declaration, under `apps/admin/app/admin/`. A path the admin already serves (`/jobs/*` included) is `X_ADMIN_PAGE_PATH_INVALID` |
|
|
331
|
+
| A custom screen in your own shell, or with an island | the `pages:` entry above, plus a `page.tsx` at its path whose config is `claimAdminRoute(admin, path, …)` → [An admin page in your own shell](#an-admin-page-in-your-own-shell) |
|
|
332
|
+
| A different control for one field | `resources: { <entity>: { fields: { <name>: { widget } } } }` — one of the `AdminWidget` kinds in `fields.ts`; a new column kind is a row in that table, not an app hook |
|
|
333
|
+
| A custom operation, on one row or many | `actions: [{ name, permission, entity, input?, when?, batch?, matching?, readonly?, destructive?, handle }]` on `defineAdmin` — a button on the detail and each list row, a row in the batch bar with `batch`, and an MCP tool `admin.action.<name>`. Grant its `permission` in the role map, or the `policy` step is `X_PERMISSION_UNGRANTED` |
|
|
334
|
+
| Different columns in a list | `resources: { <entity>: { listFields, labelField, columns } }` — `x g resource <name> --admin` writes the override, with `listFields` read off the entity, and lists it in `defineAdmin()` |
|
|
335
|
+
| Tabs, or a second audience | `scopes:` and `rows:` on the resource — never a second resource |
|
|
336
|
+
| A secret column | `.sealed()` on the entity: never listed, shown, searched or prefilled; the form writes it through a password input, and an empty box on an edit leaves it unchanged |
|
|
337
|
+
| Branding | `defineTheme({ colors, radius, font })` — the one override seam, validated not escaped → [Theming](Theming) |
|
|
338
|
+
| Hide an entity | the entity's policy denies `admin:read` — visibility is authz, not configuration |
|
|
339
|
+
|
|
340
|
+
No plugin API, in 1.x, 2.x, 3.x or 4.0.0 ([axiom](Home)). The extension point is that the admin is your app.
|
|
341
|
+
|
|
342
|
+
## An admin page in your own shell
|
|
343
|
+
|
|
344
|
+
**`claimAdminRoute(admin, path, options)` — the one way an app file serves an admin path**, `As of
|
|
345
|
+
2026-10`. For a console that keeps the app's own chrome (theme switch, language switch, an
|
|
346
|
+
enrolment banner) or needs an island on a page: every other admin route ships no script.
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
// apps/admin/app/admin/credits/page.tsx — the file at the page's own path
|
|
350
|
+
import { type AdminApp, type AdminClaimData, claimAdminRoute } from '@ultimat3/admin';
|
|
351
|
+
import { island } from '@ultimat3/render';
|
|
352
|
+
import type { JSX } from 'solid-js';
|
|
353
|
+
|
|
354
|
+
declare const admin: AdminApp; // `import { admin } from '../admin'` — the defineAdmin() module
|
|
355
|
+
declare function AppShell(props: {
|
|
356
|
+
readonly titleKey: string | null;
|
|
357
|
+
readonly children: JSX.Element;
|
|
358
|
+
}): JSX.Element;
|
|
359
|
+
|
|
360
|
+
interface ShellData {
|
|
361
|
+
readonly role: string | null;
|
|
362
|
+
readonly orgId: string | null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Islands FIRST: the claim's defineRoute drains them, which is how the page hydrates.
|
|
366
|
+
const GrantForm = island({ src: './credits-grant.island.tsx', props: ['orgId'] });
|
|
367
|
+
|
|
368
|
+
export const config = claimAdminRoute(admin, `${admin.basePath}/credits`, {
|
|
369
|
+
hydrate: 'idle',
|
|
370
|
+
budget: { js: '60kb' },
|
|
371
|
+
// The app's half of the data, read AFTER the screen decided — the refused included (`denied`).
|
|
372
|
+
load: ({ ctx }): ShellData => ({
|
|
373
|
+
role: ctx.actor.roles?.[0] ?? null,
|
|
374
|
+
orgId: ctx.actor.orgId ?? null,
|
|
375
|
+
}),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
export function Page(props: { readonly data: AdminClaimData<ShellData> }): JSX.Element {
|
|
379
|
+
return (
|
|
380
|
+
<AppShell titleKey={props.data.denied ? null : props.data.titleKey}>
|
|
381
|
+
{props.data.body}
|
|
382
|
+
<GrantForm orgId={props.data.app.orgId} />
|
|
383
|
+
</AppShell>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
| The app passes | The admin keeps |
|
|
389
|
+
|---|---|
|
|
390
|
+
| `load` — the shell's own data; `withStatus(404, …)` from it sets the page's status | `render: 'ssr'`, `offline: 'network-only'`, private `no-store` |
|
|
391
|
+
| `hydrate`, `budget`, `navigation` | `policy` — the route's `admin:read`, decided by the pipeline before `load` |
|
|
392
|
+
| the page component: the shell around `data.body` | `meta`: the route's title, `noindex, nofollow` on every answer |
|
|
393
|
+
| | `load`: the SAME screen the catch-all serves — the page's permission pair decided with `auth.actor`'s actor, a refusal audited and answered 403 (`data.denied`, the author's component never called), the body without `AdminLayout` |
|
|
394
|
+
|
|
395
|
+
- **Only a `pages:` entry or the dashboard** (`admin.basePath`). A generated screen posts its forms
|
|
396
|
+
back to its own URL, which only the admin answers: `X_ADMIN_PAGE_PATH_INVALID`, naming what may
|
|
397
|
+
be claimed. A claimed dashboard leaves `POST /admin` (app-wide actions) to the admin.
|
|
398
|
+
- **Only the config `claimAdminRoute` issued, at the path it was issued for.** A hand-written
|
|
399
|
+
`defineRoute` on an admin path — or `defineRoute({ ...claimed, load })` — is `X_ROUTE_DUPLICATE`,
|
|
400
|
+
as before: the admin answers render's `claimable(path, config)` by identity.
|
|
401
|
+
- An island inside the `pages:` component is rendered by the component, but declared by the claiming
|
|
402
|
+
file (the island's `src` resolves against it): hand it down — a module-level slot the file binds,
|
|
403
|
+
or a prop of the shell.
|
|
404
|
+
- `x routes` lists the path once: the file, with `defineAdmin() · <permissions>`.
|
|
405
|
+
|
|
406
|
+
## Deployment
|
|
407
|
+
|
|
408
|
+
| Fact | Detail |
|
|
409
|
+
|---|---|
|
|
410
|
+
| Role | `ROLE=web`, the same process and image as the app — `serve-web.ts` mounts every declared admin beside the app's routes (`adminMountRoutes`) |
|
|
411
|
+
| Scaling | the web role's: stateless, on RPS |
|
|
412
|
+
| Health | the web role's `/healthz`, `/readyz` and SIGTERM drain |
|
|
413
|
+
| Isolation | none of its own: the admin is a path prefix (`basePath`) on the app's host. Restricting it to a network or a hostname is the ingress's job |
|
|
414
|
+
| A batch past its `threshold` | `admin.batch` jobs, run by the `worker` role; a worker process that never ran `defineAdmin` refuses the chunk with `X_ADMIN_MOUNT_MISSING` |
|
|
415
|
+
|
|
416
|
+
See [Deployment](Deployment).
|
|
417
|
+
|
|
418
|
+
## Rules
|
|
419
|
+
|
|
420
|
+
- One declaration, mounted by the framework. A hand-written page for an admin URL is a second server for it; a file serves one only through `claimAdminRoute`, under the admin's guard.
|
|
421
|
+
- One authz seam. The UI never shows what the call would refuse.
|
|
422
|
+
- Destructive operations confirm and audit — enforced by the permission table, not by a view.
|
|
423
|
+
- Read-only SQL, capped. No write console.
|
|
424
|
+
- Keyset pagination only.
|
|
425
|
+
- A list's state is its URL; a parameter the resource does not derive is refused, never ignored.
|
|
426
|
+
- A sealed column is never a filter, a scope predicate, a label or a computed column's input.
|
|
427
|
+
- Every write emits an audit diff with `sensitive` fields redacted.
|
|
428
|
+
- MCP runs with the human's own permissions. Projecting an `action` into an app MCP surface is opt-in — `mcp: { expose: true }`, the one rule every other surface reads too; inside the admin's own catalog, and nowhere else, a registered action opts *out* with `mcp: { expose: false }`.
|
|
429
|
+
- Extend with `pages:`, `resources:` and `actions:`, never by forking `@ultimat3/admin`.
|
|
430
|
+
- No island: every control is a link or a native form, and the admin ships zero JavaScript.
|
|
431
|
+
|
|
432
|
+
Source: [`packages/admin/src`](https://github.com/developerz-ai/ultimate/blob/main/packages/admin/src)
|