{"_id":"@allodium/admin","_rev":"5-87f09026cf0968f8c83ea3ab5c900b30","name":"@allodium/admin","dist-tags":{"latest":"0.4.0"},"versions":{"0.0.1":{"name":"@allodium/admin","version":"0.0.1","license":"MIT","_id":"@allodium/admin@0.0.1","maintainers":[{"name":"glossydev","email":"adam@glossydev.com"}],"dist":{"shasum":"1b9183390ac0a4be281f2729f0737a0ff98c43c1","tarball":"https://registry.npmjs.org/@allodium/admin/-/admin-0.0.1.tgz","fileCount":5,"integrity":"sha512-oJgyCNwI6/83rM0ToHrGZRBKbXskoz8exkGy3Ia3nZboR0jbtLqfZ/9KEEorWxVG/D5fbiBuhjnEtoybvaF5fw==","signatures":[{"sig":"MEYCIQDjinBl+NeAh1y36R80moYpHAa5bNBMoucSi3PMrmNc5QIhANdCQoe0nA7f22aHg2a1xIk5r4cuwRQea9dR9C6RPKoL","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":2317},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"gitHead":"dcb271a2b5c97f5e238f9debb419b91837bd8763","scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"glossydev","email":"adam@glossydev.com"},"_npmVersion":"11.5.2","description":"Schema-driven CRUD admin generated from your Drizzle schema — no parallel config language.","directories":{},"_nodeVersion":"22.18.0","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/admin_0.0.1_1785044987659_0.05853268771963793","host":"s3://npm-registry-packages-npm-production"}},"0.1.0":{"name":"@allodium/admin","version":"0.1.0","license":"MIT","_id":"@allodium/admin@0.1.0","maintainers":[{"name":"glossydev","email":"adam@glossydev.com"}],"dist":{"shasum":"2a274c939012e751804ba6e56e3c2b12ebf1fe99","tarball":"https://registry.npmjs.org/@allodium/admin/-/admin-0.1.0.tgz","fileCount":9,"integrity":"sha512-Fx4f05PAHlbPhWwltH5bUGu8qZiQFzFmLT4hQjephY56l/ruibz7yKgaRfxse8HMNGK/LKBdUFLj62xIWKMDag==","signatures":[{"sig":"MEQCIDzwfxwr1WdHl4tUL5ZkSvxAFIMI5B6Yuqz3W3jhrTPsAiAMC7MdIFLsFgh/IViiuajwHE782RMArkt3FFtiHmMsTQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":12425},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"gitHead":"c805fc913cc1f3770f17b7a9e6c3167e3f4d929b","scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"glossydev","email":"adam@glossydev.com"},"_npmVersion":"11.5.2","description":"Schema-driven CRUD admin generated from your Drizzle schema — no parallel config language.","directories":{},"_nodeVersion":"22.18.0","_hasShrinkwrap":false,"devDependencies":{"drizzle-orm":"^0.45.0"},"peerDependencies":{"drizzle-orm":">=0.38.0"},"_npmOperationalInternal":{"tmp":"tmp/admin_0.1.0_1785211016835_0.4778992998232483","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@allodium/admin","version":"0.2.0","license":"MIT","_id":"@allodium/admin@0.2.0","maintainers":[{"name":"glossydev","email":"adam@glossydev.com"}],"homepage":"https://github.com/glossydev/allodium#readme","bugs":{"url":"https://github.com/glossydev/allodium/issues"},"dist":{"shasum":"34e7e6b48c0e94a194a499bf1cf06ac6b01b40c8","tarball":"https://registry.npmjs.org/@allodium/admin/-/admin-0.2.0.tgz","fileCount":52,"integrity":"sha512-E1sF3ZvGZvD0PR920C2E0uv/zNu6JKuDsZwEgtChpYU4SAbQruKQfcaIj4pAFcnb+BBcsx6o6nX7Dlhv5q+UQw==","signatures":[{"sig":"MEQCIGs9HLziyl4erEi0RAUyyXi3Axj1afN1db32szDtslg4AiAd6hZ1VJfSftWDiqjpOUsSVyTUS1ypn3nsn9haC+9sww==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIQCEoS/8Rbts5Yn5y74DxuzMeOQzGL55cdCT8Ea62SnQNQIgAwHP18NujJo1+Q+pzD4zIL26npRJT8pNMM+1MP3RgPo=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@allodium%2fadmin@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":311244},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./view":{"types":"./dist/view.d.ts","import":"./dist/view.js"},"./react":{"types":"./dist/react/index.d.ts","import":"./dist/react/index.js"},"./server":{"types":"./dist/server/index.d.ts","import":"./dist/server/index.js"},"./package.json":"./package.json","./view.schema.json":"./view.schema.json"},"gitHead":"8374294b8cb9f031760ef63f6a0fb82123c0ee1f","scripts":{"test":"node test/view-schema.mjs && node test/view-store.mjs && node test/masking.mjs && node test/filter.mjs && node test/related.mjs && node test/access.mjs && node test/routes.mjs","build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:3c52c948-efef-4fbd-9eed-7f88df368acd"}},"repository":{"url":"git+https://github.com/glossydev/allodium.git","type":"git","directory":"packages/admin"},"_npmVersion":"12.0.2","description":"Schema-driven CRUD admin generated from your Drizzle schema — no parallel config language.","directories":{},"_nodeVersion":"22.23.2","_hasShrinkwrap":false,"devDependencies":{"react":"^19.1.0","drizzle-orm":"^0.45.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=18","drizzle-orm":">=0.38.0"},"peerDependenciesMeta":{"react":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/admin_0.2.0_1789062673442_0.14028445876162454","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@allodium/admin","version":"0.3.0","license":"MIT","_id":"@allodium/admin@0.3.0","maintainers":[{"name":"glossydev","email":"adam@glossydev.com"}],"homepage":"https://github.com/glossydev/allodium#readme","bugs":{"url":"https://github.com/glossydev/allodium/issues"},"dist":{"shasum":"be6fe7b534cb9ccc881c57d5a9a9333db68802c3","tarball":"https://registry.npmjs.org/@allodium/admin/-/admin-0.3.0.tgz","fileCount":56,"integrity":"sha512-IpXtSIrA7LOUZ98hUtADWKynyzrrSqD+MJ+lVRFPN8ofw+gkGdna5QLBu6i+nAlLhNhm2yNjvJG9BdYjqv8stw==","signatures":[{"sig":"MEQCIE6r2BnDSWRGHEd1AILuvXoEuNWgXHtJ1QWCMhF+pxoFAiBwtb8t6hQB+GSOBzl1OFv/b4BFhTbw7D5IwMkNgS0PvA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIF3/VywH+MQ/8ni2DFlh/hIYRvilA/hXYemErBZC0zRLAiEArjx4iRKNiCP++8zD8DjNJ4G1EhSoKWEjrCW8g9fwBEI=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@allodium%2fadmin@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":352020},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./view":{"types":"./dist/view.d.ts","import":"./dist/view.js"},"./react":{"types":"./dist/react/index.d.ts","import":"./dist/react/index.js"},"./server":{"types":"./dist/server/index.d.ts","import":"./dist/server/index.js"},"./package.json":"./package.json","./view.schema.json":"./view.schema.json"},"gitHead":"33665445a657311ffc50f1211edc7854277020ce","scripts":{"test":"node test/view-schema.mjs && node test/view-store.mjs && node test/masking.mjs && node test/filter.mjs && node test/related.mjs && node test/access.mjs && node test/routes.mjs && node test/links.mjs && node test/datetime.mjs","build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:3c52c948-efef-4fbd-9eed-7f88df368acd"}},"repository":{"url":"git+https://github.com/glossydev/allodium.git","type":"git","directory":"packages/admin"},"_npmVersion":"12.0.2","description":"Schema-driven CRUD admin generated from your Drizzle schema — no parallel config language.","directories":{},"_nodeVersion":"22.23.2","_hasShrinkwrap":false,"devDependencies":{"react":"^19.1.0","drizzle-orm":"^0.45.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=18","drizzle-orm":">=0.38.0"},"peerDependenciesMeta":{"react":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/admin_0.3.0_1789584899140_0.28457013892814675","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@allodium/admin@0.4.0","bugs":{"url":"https://github.com/glossydev/allodium/issues"},"dist":{"shasum":"53fede8c3e07be43ab3e426537917bbe2d51668d","tarball":"https://registry.npmjs.org/@allodium/admin/-/admin-0.4.0.tgz","fileCount":56,"integrity":"sha512-VCVyikLpeSvs6abJ7jWrM9lUcG94sEArwOaYKB4EAB5fvihTUVp//cepf/VGjbHzfs5Qd0ikw5C3fE0G/b8rAw==","signatures":[{"sig":"MEQCIEC68C9xIiTQSZuzrmB32NJXD5DSY/3FUSCmlpw1GeqWAiAYDV26agE/lsXRH2y6d+cJUOBSdbqb7ygARseH8j5tGg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCss6fTvTDqQzM7IZ4ObhgN6fH6ZOpk4VfesxVQIhfi5gIgDZg7ckLk7VNxptyptJu/KUKkX3e3B58qqNds5401btE="}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@allodium%2fadmin@0.4.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":388916},"main":"./dist/index.js","name":"@allodium/admin","type":"module","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./view":{"types":"./dist/view.d.ts","import":"./dist/view.js"},"./react":{"types":"./dist/react/index.d.ts","import":"./dist/react/index.js"},"./server":{"types":"./dist/server/index.d.ts","import":"./dist/server/index.js"},"./package.json":"./package.json","./view.schema.json":"./view.schema.json"},"gitHead":"09ed4f85816656239241f817f0cea7d1f856622c","license":"MIT","scripts":{"test":"node test/view-schema.mjs && node test/view-store.mjs && node test/masking.mjs && node test/filter.mjs && node test/related.mjs && node test/access.mjs && node test/routes.mjs && node test/links.mjs && node test/datetime.mjs && node test/uploads.mjs && node test/dates.mjs","build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit"},"version":"0.4.0","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:3c52c948-efef-4fbd-9eed-7f88df368acd"}},"homepage":"https://github.com/glossydev/allodium#readme","repository":{"url":"git+https://github.com/glossydev/allodium.git","type":"git","directory":"packages/admin"},"_npmVersion":"12.0.2","description":"Schema-driven CRUD admin generated from your Drizzle schema — no parallel config language.","directories":{},"maintainers":[{"name":"glossydev","email":"adam@glossydev.com"}],"_nodeVersion":"22.23.2","_hasShrinkwrap":false,"devDependencies":{"react":"^19.1.0","drizzle-orm":"^0.45.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=18","drizzle-orm":">=0.38.0"},"peerDependenciesMeta":{"react":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/admin_0.4.0_1789588702980_0.744133205716534"}}},"time":{"created":"2026-07-26T05:49:47.515Z","modified":"2026-09-16T19:58:23.403Z","0.0.1":"2026-07-26T05:49:47.808Z","0.1.0":"2026-07-28T03:56:56.970Z","0.2.0":"2026-09-10T17:51:13.554Z","0.3.0":"2026-09-16T18:54:59.324Z","0.4.0":"2026-09-16T19:58:23.077Z"},"bugs":{"url":"https://github.com/glossydev/allodium/issues"},"license":"MIT","homepage":"https://github.com/glossydev/allodium#readme","repository":{"url":"git+https://github.com/glossydev/allodium.git","type":"git","directory":"packages/admin"},"description":"Schema-driven CRUD admin generated from your Drizzle schema — no parallel config language.","maintainers":[{"name":"glossydev","email":"adam@glossydev.com"}],"readme":"# @allodium/admin\n\nAdmin screens described by a small JSON file, rendered by unstyled components you point\nCSS at.\n\nTwo lanes: a **developer console** for building the site, and an **admin dashboard** for\nrunning it — where the console generates the dashboard. This package is the dashboard\nhalf plus the metadata both lanes read.\n\n```bash\nnpm install @allodium/admin\nnpm install react          # peer, only for the /react entry\n```\n\nRequires Node 20+ and PostgreSQL.\n\n> **Status.** Everything below is the **0.2.x** line. Published 0.1.0 contained only\n> `createTableRegistry`. The format is settling — pin exactly if you adopt it early.\n\n### Next.js: transpile this package, externalize the rest\n\nThe `/react` entry is client components. Next must compile them with **your** copy of\nReact, or the server render throws `Invalid hook call` while the browser quietly\nrecovers — tests that only look at the hydrated page pass, and the server logs fill up.\n\n```ts\n// next.config.ts\nexport default {\n  transpilePackages: ['@allodium/admin'],\n  serverExternalPackages: ['pg', 'argon2', '@allodium/db', '@allodium/auth', '@allodium/storage'],\n};\n```\n\nThe others carry native or Node-only code and must stay external. Putting\n`@allodium/admin` in that list works under npm's hoisting and breaks under pnpm's, which\nis the kind of difference that surfaces only on the second machine.\n\n## The idea\n\nA view definition is a JSON file **in your repo**, committed and reviewed like code:\n\n```json\n{\n  \"table\": \"posts\",\n  \"title\": \"Blog posts\",\n  \"fields\": [\n    { \"column\": \"title\", \"label\": \"Headline\", \"help\": \"Shown in search results.\" },\n    { \"kind\": \"relation\", \"column\": \"author_id\",\n      \"relation\": { \"table\": \"authors\", \"display\": \"name\" } },\n    { \"kind\": \"m2m\", \"through\": \"post_tags\", \"near\": \"post_id\",\n      \"far\": \"tag_id\", \"farTable\": \"tags\", \"display\": \"label\" }\n  ]\n}\n```\n\nIt carries **only what a human decided**. Column types, nullability, enum members, which\ntable a foreign key points at — all read from the live database at render time and never\nduplicated here, because a definition that restated the schema would rot at your next\nmigration.\n\nOmission means *sensible default*, never *off*. This is a complete, working screen:\n\n```json\n{ \"table\": \"authors\" }\n```\n\n**Why files and not database rows:** config in the database cannot be diffed, cannot be\nreviewed in a pull request, and drifts between environments. Schema changes are code;\nscreens are too.\n\n## Rendering\n\nThree layers. Enter wherever a screen needs you to.\n\n### Layer 1 — the definition\n\nWritten by hand or by the Allodium console's Admin Builder.\n\n### Layer 2 — headless hooks\n\nAll data and behavior, no markup at all.\n\n```tsx\nimport { useAdminForm } from '@allodium/admin/react';\n\nconst { fields, values, setValue, save, problems, dirty, optionsFor } =\n  useAdminForm({ baseUrl: '/api/admin', view: 'posts', id });\n```\n\nFetching, pagination, search, dirty tracking, validation, relation options, save and\ndelete. Render whatever you like.\n\n### Layer 3 — unstyled components\n\nCorrect, accessible markup with **zero visual opinion** — no colours, no spacing, not one\nclass of our own.\n\n```tsx\nimport { AdminForm, AdminList } from '@allodium/admin/react';\n\n<AdminList config={{ baseUrl: '/api/admin', view: 'posts' }} onSelect={open} />\n<AdminForm config={{ baseUrl: '/api/admin', view: 'posts' }} id={id} />\n```\n\nThe entire styling contract is data attributes:\n\n```css\n[data-allodium=\"field\"]      /* one field wrapper        */\n[data-field=\"title\"]         /* that wrapper, by column  */\n[data-widget=\"select\"]       /* that wrapper, by input   */\n[data-required] [data-invalid]\n```\n\nNo theme prop, no class-name API, nothing to learn beyond the attribute names. When one\nscreen needs to be genuinely different, drop it to the hooks — same data, same behavior,\nyour markup.\n\nLabels are tied to inputs, help text is wired through `aria-describedby`, and errors use\n`aria-invalid`. A generated admin that fails a screen reader would be worse than\nhand-written HTML.\n\n### Links out of the admin\n\nA screen that edits published content needs a way to the published thing, and only your\napp knows the address. So a view carries URL templates over the row, and a condition:\n\n```json\n\"links\": [\n  { \"label\": \"View on site\", \"href\": \"/blog/{slug}\", \"target\": \"_blank\",\n    \"when\": [{ \"column\": \"status\", \"value\": \"published\" }] },\n  { \"label\": \"Preview\", \"href\": \"/preview/posts/{id}\", \"in\": [\"form\"] }\n]\n```\n\n`AdminList` renders them in a trailing cell that does not open the row; `AdminForm`\nrenders them under the heading, for saved records only. Values are URL-encoded as they\nare substituted, a row whose placeholder is empty gets no link, and the template must be\na path or an absolute http(s) URL — a definition cannot put `javascript:` on an\noperator's screen. `when` is the same predicate shape as every other filter, so \"one\ntable, two public routes\" is two links with opposite conditions:\n\n```json\n{ \"label\": \"View\", \"href\": \"/portfolio/{slug}\", \"when\": { \"kind\": \"professional\" } },\n{ \"label\": \"View\", \"href\": \"/playground/{slug}\", \"when\": { \"kind\": \"playground\" } }\n```\n\nStyle them through `[data-allodium=\"link\"]`, `[data-allodium=\"links\"]` and\n`[data-allodium=\"links-header\"]`.\n\n## The server\n\nFramework-free: it takes anything with a pg-shaped `query` method, so it works with a\n`Pool`, a `Client`, or a proxy, under any HTTP framework.\n\n```ts\nimport { createViewResolver, createFileViewStore, createAdminRoutes } from '@allodium/admin/server';\n\n// Who may do what is REQUIRED. Pass a policy from @allodium/auth's grant loader,\n// or the explicit 'unrestricted' for a caller that is already the trust boundary.\nconst resolver = createViewResolver(pool, { access: policy });\n\nawait resolver.resolve(def);                    // definition + live catalog → a full screen spec\nawait resolver.list(def, { page: 1, actor });   // rows, with relation labels already joined\nawait resolver.read(def, id, actor);            // one record, m2m members included; null outside the grant\nawait resolver.create(def, values, actor);\nawait resolver.update(def, id, values, actor);  // columns + m2m set reconciliation\nawait resolver.remove(def, id, actor);\nawait resolver.options(def, 'author_id', { search, actor });  // a relation picker's choices\n```\n\nEvery call takes the **actor** — `{ userId, roles, claims }`, or the public actor for\nnobody — and the grant decides what comes back. A refusal throws (`Not permitted: …`);\na row outside a grant reads as `null`, the same as a row that does not exist, so ids\ncannot be enumerated. Relation labels and picker options are reads of the *other*\ntable and are gated as such, row scope included: an operator who may see only their own\ncustomer is not shown every customer's name down an orders list.\n\nRelation labels resolve in the **same query** as the rows, so a list does not become one\nquery per row per relation.\n\n### Over HTTP\n\n```ts\nconst views = createFileViewStore({ dir: 'admin/views' });   // reads *.view.json, live\nconst routes = createAdminRoutes({\n  resolver,\n  views,\n  actorFor: async (request) => /* resolve the session cookie, or PUBLIC_ACTOR */,\n  allowOrigins: ['https://admin.example.com'],               // omit for same-origin\n  onWrite: (e) => audit(e),                                  // every create/update/delete\n});\n\n// Next.js: app/api/admin/[...path]/route.ts\nconst handler = (req, { params }) => routes.handle(req, params.path);\nexport { handler as GET, handler as POST, handler as PATCH, handler as PUT, handler as DELETE, handler as OPTIONS };\n```\n\nThe URL contract `@allodium/admin/react` speaks, relative to the mount point:\n\n| | |\n|---|---|\n| `GET <view>/view` | the resolved screen spec — gated as a read, because a table's shape is a leak |\n| `GET <view>/list?page=&pageSize=&search=&sort=&direction=&filter=col:op:value&scope=…` | rows |\n| `GET <view>/record/<id>` | one row, or 404 |\n| `POST <view>/record` | create, 201 |\n| `PATCH <view>/record/<id>` | update (`PUT` accepted) |\n| `DELETE <view>/record/<id>` | |\n| `GET <view>/options/<field>?search=` | a picker's choices |\n\n`<view>` is a `.view.json` name, or a table name when no such file exists — a granted\ntable with no curated screen renders its implicit one. Refusals map to 403, a row\noutside a grant to 404, a bad filter to 400, a constraint violation to 400 with\nPostgres's own detail. Writes from an origin that is neither this host nor allowlisted,\nor with a body not typed as JSON, are refused before anything else runs — CORS headers\nsay who may read a response, not who may send a request, and the difference is a\ncross-site request forgery.\n\nThere is no separate content API. A public site mounts the same routes and the public\nactor gets what its role was granted — published posts, approved comments — through the\none enforcement path.\n\n### Files\n\nUploads are a relation to a files table, edited by uploading. Give the routes a byte\nstore and the serving headers from `@allodium/storage`:\n\n```ts\nimport { createLocalDiskDriver, assetContentHeaders, diskExtension } from '@allodium/storage';\n\ncreateAdminRoutes({\n  …,\n  uploads: {\n    driver: createLocalDiskDriver({ root: () => process.env.UPLOADS_DIR! }),\n    serve: assetContentHeaders,   // the stored-XSS policy lives there, not here\n    extension: diskExtension,\n    maxBytes: 20 * 1024 * 1024,   // default 10 MiB, enforced on the stream\n    accept: ['image/*', 'application/pdf'],\n  },\n});\n```\n\nThat adds three endpoints under the reserved name `_files`:\n\n| | |\n|---|---|\n| `POST _files` | multipart with a `file` field (and optional `title`), or a raw body with an `X-Filename` header — gated as a **create** on the files table; 201 with the row |\n| `GET _files/<id>` | the bytes, with `Content-Type`, `nosniff`, an inline-or-attachment disposition, and a cache policy — gated as a **read** of the row, so a file the actor may not see is a 404; `?download` forces an attachment |\n| `DELETE _files/<id>` | the row and the bytes |\n\nA raster image is checked against its own first bytes, since the browser will render it\ninline; a \"png\" that is not one is a 415. The files table is the shape the console's\nFiles silo uses (`disk_name`, `filename`, `mime_type`, `filesize_bytes`, `title`);\n`table` and `columns` rename it.\n\nOn the form, a relation field with `\"widget\": \"file\"` shows the current filename, an\nupload control and Remove; the upload's row id becomes the field's value, and the record\nsaves a foreign key exactly as it would from a picker. `uploadFile(config, file)` from\n`/react` is the same call for a custom form. Serve a cover image on the public site from\nthe same mount: `<img src=\"/api/content/_files/{cover_image_id}\">`, with the public\ngranted read on `files`.\n\n### Many-to-many\n\nDetected as **a composite primary key whose columns are all foreign keys** — not \"a table\nwith two foreign keys\", which misreads a join table carrying extra columns\n(`granted_at`, `granted_by`, `sort_order`). Writes reconcile to exactly the submitted\nset: delete what is gone, insert what is new, leave untouched rows alone so join-table\npayload survives an edit.\n\n### Help text comes from your schema\n\n`COMMENT ON COLUMN` becomes a field's default help text; `COMMENT ON TABLE` becomes a\nview's description. Describe a column once, in the schema, and every screen showing it\ninherits that — no second copy to keep in sync.\n\n## Warnings, not silence\n\nA definition that resolves but would render something useless — a select with no\noptions, a list column that is not a field — comes back with warnings attached. \"The\ndropdown is empty and I don't know why\" is the failure mode this project exists to\navoid.\n\n## Also here: the Drizzle table registry\n\n```ts\nimport { createTableRegistry } from '@allodium/admin';\n\nconst registry = createTableRegistry({\n  schema: appSchema,\n  maskedColumns: { users: ['password'], sessions: ['access_hash', 'refresh_hash'] },\n});\n```\n\nRuntime metadata from your Drizzle schema — names, SQL types, primary keys, the foreign\nkey graph — with secret columns masked end to end: never selected, never editable, never\non the wire. Masking is the one piece of judgment a deployment must supply.\n\n## License\n\nMIT. No CLA.\n","readmeFilename":"README.md"}