@nonext/husk 0.2.3
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/CHANGELOG.md +148 -0
- package/LICENSE +166 -0
- package/README.md +140 -0
- package/api/public-api.md +1070 -0
- package/dist/adapter-C_0mhENy.d.ts +320 -0
- package/dist/admin/admin.css +5 -0
- package/dist/admin/index.d.ts +265 -0
- package/dist/admin/index.js +393 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/tokens.css +597 -0
- package/dist/admin/ui.d.ts +3013 -0
- package/dist/admin/ui.js +9783 -0
- package/dist/admin/ui.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth-B2gBtHl5.d.ts +28 -0
- package/dist/can-Chy7zmx7.d.ts +69 -0
- package/dist/chunk-3I6337WJ.js +83 -0
- package/dist/chunk-3I6337WJ.js.map +1 -0
- package/dist/chunk-4Z6P3XCE.js +62 -0
- package/dist/chunk-4Z6P3XCE.js.map +1 -0
- package/dist/chunk-6E3ZS5Z5.js +1007 -0
- package/dist/chunk-6E3ZS5Z5.js.map +1 -0
- package/dist/chunk-77JDRXXU.js +3 -0
- package/dist/chunk-77JDRXXU.js.map +1 -0
- package/dist/chunk-FHYWJCG6.js +171 -0
- package/dist/chunk-FHYWJCG6.js.map +1 -0
- package/dist/chunk-JAE7HHMM.js +506 -0
- package/dist/chunk-JAE7HHMM.js.map +1 -0
- package/dist/chunk-NPDBQBLW.js +68 -0
- package/dist/chunk-NPDBQBLW.js.map +1 -0
- package/dist/chunk-ORMYARXD.js +6 -0
- package/dist/chunk-ORMYARXD.js.map +1 -0
- package/dist/chunk-R4O7APIX.js +51 -0
- package/dist/chunk-R4O7APIX.js.map +1 -0
- package/dist/chunk-ROLKRJWI.js +187 -0
- package/dist/chunk-ROLKRJWI.js.map +1 -0
- package/dist/chunk-SOTQ3WP5.js +31 -0
- package/dist/chunk-SOTQ3WP5.js.map +1 -0
- package/dist/chunk-TCFFHTY3.js +97 -0
- package/dist/chunk-TCFFHTY3.js.map +1 -0
- package/dist/chunk-TS7Y7GEZ.js +7 -0
- package/dist/chunk-TS7Y7GEZ.js.map +1 -0
- package/dist/chunk-VLGR5PZX.js +3 -0
- package/dist/chunk-VLGR5PZX.js.map +1 -0
- package/dist/chunk-ZW2PUSVS.js +40 -0
- package/dist/chunk-ZW2PUSVS.js.map +1 -0
- package/dist/chunk-ZYNBMYJ6.js +392 -0
- package/dist/chunk-ZYNBMYJ6.js.map +1 -0
- package/dist/cli/nonext-husk.js +3360 -0
- package/dist/cli/nonext-husk.js.map +1 -0
- package/dist/cli/templates/firestore.rules +393 -0
- package/dist/cli/templates/storage.rules +165 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cms-9fSA3uQV.d.ts +158 -0
- package/dist/core/index.d.ts +1267 -0
- package/dist/core/index.js +12 -0
- package/dist/core/index.js.map +1 -0
- package/dist/entry-D3kiE8T1.d.ts +163 -0
- package/dist/field-Diyf5siF.d.ts +158 -0
- package/dist/field-type-D25Vojb0.d.ts +157 -0
- package/dist/firebase/index.d.ts +251 -0
- package/dist/firebase/index.js +991 -0
- package/dist/firebase/index.js.map +1 -0
- package/dist/index-5ylaMBUH.d.ts +155 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/index.d.ts +101 -0
- package/dist/permissions/index.js +7 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/registry-CUC25qay.d.ts +59 -0
- package/dist/richtext/index.d.ts +85 -0
- package/dist/richtext/index.js +127 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/schema/index.d.ts +50 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/write-limits-Djmm6L3R.d.ts +215 -0
- package/package.json +136 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/adapter.ts","../src/core/schema-fields.ts","../src/core/slug-field.ts","../src/core/media-constraints.ts","../src/core/registry.ts","../src/core/schema-version.ts","../src/core/write-limits.ts","../src/core/media-references.ts"],"names":[],"mappings":"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","file":"chunk-JAE7HHMM.js","sourcesContent":["import type { ContentTypeLike } from \"../types/content-type.js\"\nimport type { Entry, EntryStatus } from \"../types/entry.js\"\nimport type { MediaItem } from \"../types/media.js\"\n\n/**\n * The seam between the CMS and its storage.\n *\n * Everything above this interface (the admin, the SDK, the registry, the validator) is\n * written against `CmsAdapter` and knows nothing about Firestore. Everything below it lives\n * under `src/firebase/**`, which is the only place the Firebase SDK may be imported. If a\n * screen or an SDK method needs something this interface does not offer, the interface\n * grows; it is never bypassed.\n *\n * Values crossing this boundary are already in their read shape: timestamps are ISO 8601\n * strings, references are ids, and field values match what `FieldValues` describes. The\n * converters on the Firestore side own the translation in both directions.\n */\n\nexport type FilterOperator =\n | \"==\"\n | \"!=\"\n | \"<\"\n | \"<=\"\n | \">\"\n | \">=\"\n | \"in\"\n | \"not-in\"\n | \"array-contains\"\n | \"array-contains-any\"\n\nexport interface EntryFilter {\n field: string\n op: FilterOperator\n value: unknown\n}\n\nexport interface SortSpec {\n field: string\n direction: \"asc\" | \"desc\"\n}\n\nexport interface ListQuery {\n filters?: readonly EntryFilter[]\n sort?: SortSpec\n /**\n * Free text matched against the content type's `titleField`, as a prefix.\n *\n * What it does, exactly, because a search box that promises more than it delivers is\n * worse than one that says what it is: the adapter turns the term into a range scan\n * (`>= term`, `<= term + U+F8FF`) over the title field. That matches from the beginning\n * of the title and it is case sensitive, because Firestore compares strings by their\n * UTF-8 bytes and offers no substring, no fuzzy and no case-insensitive operator.\n * \"Portfolio\" is found by \"Port\", not by \"port\" and not by \"folio\".\n *\n * Two consequences a caller has to know about:\n *\n * - A range filter forces the first ordering onto the field it ranges over, so a search\n * is always ordered by title ascending and `sort` is ignored while one is active.\n * - A content type with no `titleField` has nothing to match against, so the adapter\n * rejects the query instead of silently returning everything. Ask `canSearch()` in\n * core/list-view.ts before offering the box.\n */\n search?: string\n /** Page size. The adapter caps it; see DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE. */\n limit?: number\n /** Opaque cursor from a previous page. Never constructed by a caller. */\n cursor?: string | null\n /**\n * `\"any\"` reads every status and is the admin default. The public SDK passes\n * `\"published\"`, which is what keeps drafts off a customer's website.\n */\n status?: EntryStatus | \"any\"\n}\n\nexport interface Page<TItem> {\n items: readonly TItem[]\n /** Pass back as `ListQuery.cursor` to read the next page. Null when the page is the last. */\n cursor: string | null\n hasMore: boolean\n}\n\nexport interface MediaQuery {\n limit?: number\n cursor?: string | null\n /** MIME prefix, for example `image/`. */\n type?: string\n}\n\nexport interface MediaInput {\n name: string\n type: string\n size: number\n url: string\n path: string\n alt?: string | null\n width?: number | null\n height?: number | null\n}\n\n/**\n * What the media library occupies, summed from the metadata documents.\n *\n * `bytes` is the sum of the `size` recorded on each media document, not a reading of the\n * Storage bucket itself. Everything Husk uploads records its size, so the two agree; an\n * object placed in the bucket by something other than Husk is not counted, because Husk\n * has no document for it. Stated rather than hidden: a dashboard figure that silently\n * means something narrower than its label is worse than no figure.\n */\nexport interface MediaUsage {\n /** Media documents. */\n count: number\n /** Total bytes across those documents. */\n bytes: number\n}\n\n/**\n * One entry that references a media item.\n *\n * `title` is what the warning prints. It is null when the entry's content type declares no\n * title field, or when the value is empty: the dialog then prints the id, which is the one\n * name that is always correct.\n */\nexport interface MediaUsageReference {\n contentType: string\n entryId: string\n title: string | null\n}\n\nexport interface MediaUsageQuery {\n /** How many referencing entries to name. Defaults to MEDIA_USAGE_LIMIT. */\n limit?: number\n}\n\n/**\n * How many referencing entries a usage lookup names.\n *\n * A delete warning exists to be read. Twenty-five names already say \"this is used a lot\",\n * and the caller compares the length against this number to know the list was cut rather\n * than complete.\n */\nexport const MEDIA_USAGE_LIMIT = 25\n\n/** What a reference refresh managed to rewrite, and what it did not. */\nexport interface MediaRefreshResult {\n updated: number\n /** Entries whose denormalized url could not be rewritten. Named, never swallowed. */\n failed: readonly MediaUsageReference[]\n}\n\nexport interface MediaAdapter {\n list(query?: MediaQuery): Promise<Page<MediaItem>>\n getById(mediaId: string): Promise<MediaItem | null>\n getMany(mediaIds: readonly string[]): Promise<readonly MediaItem[]>\n /**\n * Allocates a media id without writing anything.\n *\n * The upload path needs the id before the document exists, because the Storage object\n * path contains it and the bytes are written first. Nothing is reserved on the server:\n * the id is generated locally, and an id that is never used costs nothing.\n */\n reserveId(): string\n /**\n * Writes the metadata document. With `mediaId`, it is written at that id, which is how\n * an upload's bytes and its document end up naming each other.\n */\n create(input: MediaInput, mediaId?: string): Promise<MediaItem>\n update(mediaId: string, patch: Partial<MediaInput>): Promise<MediaItem>\n delete(mediaId: string): Promise<void>\n /**\n * Entries referencing this media item, so a delete can warn instead of breaking pages.\n *\n * Answered from the `mediaRefs` reverse index each entry write maintains, not from a\n * scan: see core/media-references.ts for what that costs and where it is blind.\n */\n usages(\n mediaId: string,\n query?: MediaUsageQuery,\n ): Promise<readonly MediaUsageReference[]>\n /**\n * Rewrites the denormalized `url` on every entry that references this media item.\n *\n * Only replace needs it. Overwriting the bytes of an existing item mints a new download\n * URL, and the copy of the old one denormalized into each entry would otherwise stay\n * until that entry is next saved. `mediaId` remains the source of truth either way, so a\n * refresh that fails degrades a stale convenience rather than breaking a reference.\n */\n refreshReferences(mediaId: string, url: string): Promise<MediaRefreshResult>\n /**\n * Count and total size of the whole library, for the dashboard's storage figure.\n * One aggregate read, not a walk over every page of media.\n */\n usage(): Promise<MediaUsage>\n}\n\n/**\n * Schema operations act on `schemas/`, never on entries. A locked or code-defined type is\n * rejected here as well as in the UI, so bypassing the Schema Builder changes nothing.\n */\nexport interface SchemaAdapter {\n list(): Promise<readonly ContentTypeLike[]>\n get(slug: string): Promise<ContentTypeLike | null>\n create(type: ContentTypeLike): Promise<ContentTypeLike>\n update(slug: string, type: ContentTypeLike): Promise<ContentTypeLike>\n delete(slug: string): Promise<void>\n /**\n * Live view of every database-defined type, so the sidebar and the routes follow a\n * structure change without a rebuild and without a reload.\n *\n * One listener for the whole collection, feeding `RegistryStore.setDatabaseTypes`. What\n * it costs is written down on that interface. `onError` is called rather than thrown,\n * because a listener that dies silently leaves an admin quietly stale.\n */\n watch(\n listener: (types: readonly ContentTypeLike[]) => void,\n onError?: (error: Error) => void,\n ): () => void\n}\n\nexport interface FieldPurgeOptions {\n /** How many entries to clear in one call. Capped at MAX_FIELD_PURGE. */\n limit?: number\n}\n\nexport interface FieldPurgeResult {\n /** Entries whose stored value was deleted by this call. */\n purged: number\n /** True when the cap was hit and another call is needed to finish. */\n remaining: boolean\n}\n\n/** One call clears at most this many entries, so a purge cannot become an unbounded write. */\nexport const MAX_FIELD_PURGE = 250\n\n/**\n * The stored values of a **removed** field, which is the only data the Schema Builder is\n * ever allowed to reach.\n *\n * It is a separate interface, and not two more methods on `SchemaAdapter`, so the M8\n * guardrail is structural rather than a comment: `SchemaAdapter` has no method that can\n * touch an entry, and this one refuses any key the content type still declares. The Schema\n * Builder writes schemas; purging is a different, separately named, separately confirmed\n * operation that happens to be reachable from the same screen.\n */\nexport interface FieldDataAdapter {\n /**\n * How many entries of this content type hold a non-null value for this top-level field\n * key, so the removal warning can say \"14 entries\" rather than \"some entries\".\n *\n * One count aggregation, not a walk: `where(key, \"!=\", null)` plus `count()`. Firestore\n * indexes every top-level field automatically, so no composite index is needed, and an\n * aggregation is billed per thousand matched index entries rather than per document.\n *\n * Two limits, stated rather than hidden. A key nested inside a repeater row is not a\n * top-level field and cannot be counted this way, so `count` rejects one instead of\n * answering zero. And an entry written before the field existed simply has no such key,\n * which Firestore treats as not matching, which is the answer the warning wants.\n */\n count(slug: string, key: string): Promise<number>\n /**\n * Deletes the stored values of a removed field. The only destructive schema operation.\n *\n * Refused unless the content type lists the key under `removedFields`: a key that is\n * still live, or that the type has never heard of, is never purged. Admin only, and\n * bounded per call.\n */\n purge(slug: string, key: string, options?: FieldPurgeOptions): Promise<FieldPurgeResult>\n}\n\n/**\n * One stored entry as the migration runner sees it: its id, the version it is stamped at,\n * and its values in their stored form.\n *\n * Deliberately not an `Entry`. Reading an entry runs it through the content type's field\n * specs, and the whole point of a migration is that the stored document does not match\n * those specs yet, so converting it first would either fail or quietly drop the values the\n * migration was written to move.\n */\nexport interface StoredEntryRecord {\n id: string\n schemaVersion: number\n values: Record<string, unknown>\n}\n\nexport interface MigrationWrite {\n /** The stored version the transform read. The write is refused if it has moved. */\n expectedVersion: number\n nextVersion: number\n}\n\n/**\n * The storage half of the migration runner. See core/migrations.ts for the runner itself.\n *\n * Kept off `SchemaAdapter` for the same reason `FieldDataAdapter` is: a schema operation\n * must not be able to reach an entry. A migration reaches every entry of a type, which is\n * precisely why it is a separately named, separately permissioned port rather than two\n * more methods on the interface the Schema Builder holds.\n */\nexport interface MigrationAdapter {\n /** How many entries are stamped below the target version. One aggregation. */\n countBehind(slug: string, targetVersion: number): Promise<number>\n /** A page of entries stamped below the target version, oldest version first. */\n listBehind(\n slug: string,\n targetVersion: number,\n query?: { limit?: number },\n ): Promise<Page<StoredEntryRecord>>\n /**\n * Writes migrated values and restamps the entry, but only while the stored version is\n * still the one the transform read.\n *\n * Returns false when it has moved, which is a second runner having got there first and\n * is not an error. That conditional is what makes a migration safe to run twice and safe\n * to run from two places at once.\n */\n applyMigration(\n slug: string,\n entryId: string,\n values: Record<string, unknown>,\n write: MigrationWrite,\n ): Promise<boolean>\n}\n\nexport interface SettingsAdapter {\n get(key: string): Promise<Record<string, unknown> | null>\n set(key: string, value: Record<string, unknown>): Promise<void>\n}\n\nexport interface EntryInputData {\n /** Field values, already validated by the caller and validated again by the adapter. */\n values: Record<string, unknown>\n status?: EntryStatus\n}\n\nexport interface CmsAdapter {\n list(slug: string, query?: ListQuery): Promise<Page<Entry>>\n get(slug: string, entryId: string): Promise<Entry | null>\n /**\n * Looks an entry up by the value of the content type's `slug` field, not by document id.\n *\n * `status` narrows the lookup at the storage layer rather than after it, and on a public\n * website that is not an optimization. A visitor's read is unauthenticated, and the\n * security rules only let an unauthenticated query through when the query itself says\n * `status == \"published\"`; a lookup that asked for any status and filtered afterwards\n * would be refused outright. Defaults to `\"any\"`, which is the admin's uniqueness check.\n */\n getBySlug(\n slug: string,\n value: string,\n options?: Pick<ListQuery, \"status\">,\n ): Promise<Entry | null>\n /** Reads the one entry of a singleton. Rejects a collection. */\n getSingle(slug: string): Promise<Entry | null>\n create(slug: string, input: EntryInputData): Promise<Entry>\n update(slug: string, entryId: string, input: EntryInputData): Promise<Entry>\n delete(slug: string, entryId: string): Promise<void>\n /**\n * How many entries the same narrowing would return. Takes `search` as well, so the\n * list view's \"1-25 of 248\" counts the rows the reader is actually paging through.\n */\n count(\n slug: string,\n query?: Pick<ListQuery, \"filters\" | \"status\" | \"search\">,\n ): Promise<number>\n readonly media: MediaAdapter\n readonly schemas: SchemaAdapter\n readonly fieldData: FieldDataAdapter\n readonly migrations: MigrationAdapter\n readonly settings: SettingsAdapter\n}\n\nexport const DEFAULT_PAGE_SIZE = 25\n/** Above this, a list view is the wrong tool and a query is a denial-of-service risk. */\nexport const MAX_PAGE_SIZE = 100\n\nexport function normalizeLimit(limit: number | undefined): number {\n if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_PAGE_SIZE\n return Math.min(Math.max(Math.trunc(limit), 1), MAX_PAGE_SIZE)\n}\n","import type {\n ContentTypeLike,\n RemovedField,\n RemovedFieldMap,\n} from \"../types/content-type.js\"\nimport type { FieldDefinition, FieldMap } from \"../types/field.js\"\n\nimport { assertValidContentType } from \"./define-content-type.js\"\nimport { SchemaError } from \"./errors.js\"\n\n/**\n * Adding, removing, re-adding and purging one field of a database-defined content type.\n *\n * The rule the whole file exists for: **removing a field never destroys its values.** The\n * definition moves from `fields` to `removedFields` and the stored values stay on every\n * entry until somebody explicitly purges them. Two properties elsewhere are what make that\n * work, and both are asserted by tests rather than assumed:\n *\n * - `schema/validate.ts` only validates keys the schema declares, so a value left behind by\n * a removed field cannot make an entry unsaveable.\n * - `firebase/converters.ts#valuesToStored` carries keys it does not recognise through\n * untouched, so saving an entry after a removal rewrites the declared fields and leaves\n * the parked one exactly where it was.\n *\n * Every function here is pure and returns a new content type. Nothing writes.\n */\n\nexport interface RemovalContext {\n /** uid recorded on the parked definition. */\n removedBy: string\n /** ISO 8601. Injected so a test and a migration can both be deterministic. */\n removedAt?: string\n}\n\n/** What a key currently is on a content type. Drives what the field editor may offer. */\nexport type FieldKeyState = \"free\" | \"live\" | \"removed\"\n\nexport function fieldKeyState(type: ContentTypeLike, key: string): FieldKeyState {\n if (key in type.fields) return \"live\"\n if (type.removedFields !== undefined && key in type.removedFields) return \"removed\"\n return \"free\"\n}\n\nexport function removedFieldEntries(\n type: ContentTypeLike,\n): readonly (RemovedField & { key: string })[] {\n return Object.entries(type.removedFields ?? {})\n .map(([key, removed]) => ({ key, ...removed }))\n .sort((left, right) => left.key.localeCompare(right.key))\n}\n\nfunction withoutKey<TValue>(\n map: { readonly [key: string]: TValue },\n key: string,\n): { [key: string]: TValue } {\n const next: { [key: string]: TValue } = {}\n for (const [existing, value] of Object.entries(map)) {\n if (existing !== key) next[existing] = value\n }\n return next\n}\n\n/**\n * Rebuilds a content type with new field maps, dropping `removedFields` when it is empty.\n *\n * An empty map is deleted rather than stored, so a type that has never lost a field and a\n * type whose last removal was purged look identical. Otherwise every stored document would\n * accumulate an empty object that means nothing.\n */\nfunction rebuild(\n type: ContentTypeLike,\n fields: FieldMap,\n removedFields: RemovedFieldMap,\n): ContentTypeLike {\n const { removedFields: _dropped, ...rest } = type\n const next: ContentTypeLike = {\n ...rest,\n fields,\n ...(Object.keys(removedFields).length > 0 ? { removedFields } : {}),\n }\n\n assertValidContentType(next)\n return next\n}\n\n/**\n * Removes a field and parks its definition.\n *\n * Refuses to remove the last field, because a content type with no fields is invalid, and\n * refuses to remove the field the type names as its title or its default sort: a list view\n * that sorts by a field nobody can edit any more is a broken screen, and pointing the type\n * elsewhere silently would be a change the editor did not ask for. Repoint the type first.\n */\nexport function removeField(\n type: ContentTypeLike,\n key: string,\n context: RemovalContext,\n): ContentTypeLike {\n const field = type.fields[key]\n\n if (!field) {\n throw new SchemaError(\n fieldKeyState(type, key) === \"removed\"\n ? `Field \"${key}\" of content type \"${type.slug}\" is already removed.`\n : `Content type \"${type.slug}\" has no field \"${key}\".`,\n )\n }\n\n if (Object.keys(type.fields).length === 1) {\n throw new SchemaError(\n `Field \"${key}\" is the only field of content type \"${type.slug}\". A content type must keep at least one field; delete the type instead.`,\n )\n }\n\n if (type.titleField === key) {\n throw new SchemaError(\n `Field \"${key}\" is the title field of content type \"${type.slug}\". Choose another title field before removing it.`,\n )\n }\n\n if (type.defaultSort?.field === key) {\n throw new SchemaError(\n `Field \"${key}\" is the default sort of content type \"${type.slug}\". Choose another sort before removing it.`,\n )\n }\n\n const removed: RemovedField = {\n field,\n removedAt: context.removedAt ?? new Date().toISOString(),\n removedBy: context.removedBy,\n }\n\n return rebuild(type, withoutKey(type.fields, key), {\n ...(type.removedFields ?? {}),\n [key]: removed,\n })\n}\n\n/** Adds a field. Rejects a key that is live, and routes a removed key to `restoreField`. */\nexport function addField(\n type: ContentTypeLike,\n key: string,\n field: FieldDefinition,\n): ContentTypeLike {\n const state = fieldKeyState(type, key)\n\n if (state === \"live\") {\n throw new SchemaError(`Content type \"${type.slug}\" already has a field \"${key}\".`)\n }\n\n if (state === \"removed\") {\n throw new SchemaError(\n `Field \"${key}\" of content type \"${type.slug}\" was removed and its values are still stored. Restore it to get those values back, or purge them first.`,\n )\n }\n\n return rebuild(type, { ...type.fields, [key]: field }, type.removedFields ?? {})\n}\n\nexport interface RestoreOptions {\n /**\n * A definition to restore under, instead of the one that was parked.\n *\n * Only accepted when it stores values the same way, which in practice means the same\n * `type` and the same cardinality. Restoring a `text` key as a `number` would leave every\n * preserved value the wrong shape, and the entry would fail validation the next time\n * anyone opened it, on data they never touched. Purge first, then add it fresh.\n */\n field?: FieldDefinition\n}\n\n/**\n * Re-adds a removed key, and with it the values that were preserved.\n *\n * This is the whole point of parking the definition rather than deleting it: the values\n * were never dropped, so declaring the key again makes them readable exactly as they were.\n * Nothing is written to any entry.\n */\nexport function restoreField(\n type: ContentTypeLike,\n key: string,\n options: RestoreOptions = {},\n): ContentTypeLike {\n const removed = type.removedFields?.[key]\n\n if (!removed) {\n throw new SchemaError(`Content type \"${type.slug}\" has no removed field \"${key}\".`)\n }\n\n const field = options.field ?? removed.field\n\n if (!storesTheSameWay(removed.field, field)) {\n throw new SchemaError(\n `Field \"${key}\" of content type \"${type.slug}\" was removed as \"${removed.field.type}\" and cannot be restored as \"${field.type}\": the preserved values are stored in the old shape. Purge the values first, then add the field fresh.`,\n )\n }\n\n return rebuild(\n type,\n { ...type.fields, [key]: field },\n withoutKey(type.removedFields ?? {}, key),\n )\n}\n\n/**\n * Drops the parked definition, after the values have been purged from the entries.\n *\n * The definition side of a purge only. The data side is `CmsAdapter.fieldData.purge`, and\n * the adapter runs both in that order: values first, then this, so an interrupted purge\n * leaves the field still listed as removed with some values gone, which is recoverable,\n * rather than unlisted with values nobody can see, which is not.\n */\nexport function forgetRemovedField(type: ContentTypeLike, key: string): ContentTypeLike {\n if (type.removedFields?.[key] === undefined) {\n throw new SchemaError(`Content type \"${type.slug}\" has no removed field \"${key}\".`)\n }\n\n return rebuild(type, type.fields, withoutKey(type.removedFields, key))\n}\n\n/**\n * Do two definitions put the same shape in Firestore?\n *\n * Deliberately about storage, not about presentation: a label, a description, a\n * placeholder or a validation rule may change freely across a restore, because none of them\n * changes what is already in the document.\n */\nexport function storesTheSameWay(left: FieldDefinition, right: FieldDefinition): boolean {\n return storageSignatureOf(left) === storageSignatureOf(right)\n}\n\n/**\n * The storage-relevant part of one field definition, as a string.\n *\n * What is in it: the type, and every option that changes the stored value. `multiple` and\n * `many` turn a scalar into an array. `to` decides which content type a stored relation id\n * points at. A repeater's nested map is the shape of each stored row. A date's precision\n * decides between `YYYY-MM-DD` and a full instant.\n *\n * What is deliberately not in it: label, description, placeholder, hidden, readonly,\n * validation, select option labels, and the order of the keys. Changing any of those\n * changes the form, not the document, and a version stamp that moved for them would tell a\n * migration runner nothing.\n */\nexport function storageSignatureOf(field: FieldDefinition): string {\n switch (field.type) {\n case \"select\":\n // The option *values* are what a document can hold; their labels are chrome.\n return `select:${field.multiple === true ? \"many\" : \"one\"}:${field.options\n .map((option) => option.value)\n .sort()\n .join(\"|\")}`\n case \"relation\":\n return `relation:${field.to}:${field.many === true ? \"many\" : \"one\"}`\n case \"date\":\n return `date:${field.precision ?? \"date\"}`\n case \"repeater\":\n return `repeater:{${fieldMapSignature(field.fields)}}`\n default:\n return field.type\n }\n}\n\nexport function fieldMapSignature(fields: FieldMap): string {\n return Object.entries(fields)\n .map(([key, field]) => `${key}=${storageSignatureOf(field)}`)\n .sort()\n .join(\",\")\n}\n","import type { ContentTypeLike } from \"../types/content-type.js\"\n\n/**\n * The field a content type is addressed by in a URL, if it has one.\n *\n * It lives in core rather than in the adapter because two callers need it and only one of\n * them may import Firebase: the adapter looks an entry up by its slug value, and the entry\n * form asks whether a field is the one it can answer a uniqueness question about without a\n * composite index. A second copy of this rule would let the two disagree about which field\n * the slug is.\n */\nexport function slugFieldKey(type: ContentTypeLike): string | null {\n for (const [key, field] of Object.entries(type.fields)) {\n if (field.type === \"slug\") return key\n }\n return \"slug\" in type.fields ? \"slug\" : null\n}\n","/**\n * What the media library accepts, in one place.\n *\n * This file is the single definition of the allowed MIME types, their file extensions and\n * their size ceilings. Three things read it and they must not drift apart:\n *\n * 1. The upload path in `firebase/media-upload.ts`, which refuses a bad file before it\n * spends a customer's bandwidth on it.\n * 2. The admin's file input, which sets `accept` and says why a file was refused.\n * 3. `storage.rules` and the `media/{mediaId}` block of `firestore.rules`, which are the\n * actual enforcement. A rule cannot import TypeScript, so the two are kept in step by\n * `media-constraints.test.ts`, which reads the rules files and fails when a type or a\n * limit is present here and absent there.\n *\n * It deliberately contains no Firebase import: the constraint is a product rule, not a\n * storage detail, and the admin has to be able to state it without crossing the adapter\n * boundary.\n */\n\nconst MB = 1024 * 1024\n\nexport type MediaCategory = \"image\" | \"video\" | \"audio\" | \"document\"\n\nexport interface MediaTypeRule {\n /** The exact MIME type. No wildcards: see the note on `image/*` below. */\n readonly type: string\n readonly category: MediaCategory\n readonly maxBytes: number\n /** Lowercase, with the dot. The first one is what a sanitized name is given. */\n readonly extensions: readonly string[]\n}\n\n/**\n * The hard ceiling, whatever the type. Large enough for a brochure or a short clip, small\n * enough that one mistaken upload cannot fill a customer's bucket.\n */\nexport const MAX_MEDIA_BYTES = 25 * MB\n\n/**\n * Images are held to a lower ceiling than the rest.\n *\n * A 25 MB image is never what a website wants, and since Husk stores originals only (see\n * .docs/decisions/2026-09-18-media-variants.md) the original is what a visitor's browser\n * eventually fetches. The limit is the one place that fact is enforced rather than hoped\n * for.\n */\nexport const MAX_IMAGE_BYTES = 10 * MB\n\n/**\n * The allowlist.\n *\n * Note what is missing, because each absence is a decision rather than an oversight:\n *\n * - `image/svg+xml`. An SVG is a script container. It is served from the Storage host, so\n * it cannot reach a customer's own origin, but it can still be handed to a visitor as an\n * executable document under the customer's brand. Nothing in the CMS needs it.\n * - `image/tiff`, `image/bmp`, `video/quicktime`. No browser renders the first two and the\n * third is a phone-camera container that no `<video>` tag can be pointed at. A file that\n * cannot be displayed on the site it was uploaded for is a support ticket in waiting.\n * - Everything else, including archives and office documents. Adding one is a line here,\n * a line in `storage.rules`, and a test; that friction is the point.\n */\nexport const ALLOWED_MEDIA_TYPES: readonly MediaTypeRule[] = [\n {\n type: \"image/jpeg\",\n category: \"image\",\n maxBytes: MAX_IMAGE_BYTES,\n extensions: [\".jpg\", \".jpeg\"],\n },\n {\n type: \"image/png\",\n category: \"image\",\n maxBytes: MAX_IMAGE_BYTES,\n extensions: [\".png\"],\n },\n {\n type: \"image/webp\",\n category: \"image\",\n maxBytes: MAX_IMAGE_BYTES,\n extensions: [\".webp\"],\n },\n {\n type: \"image/avif\",\n category: \"image\",\n maxBytes: MAX_IMAGE_BYTES,\n extensions: [\".avif\"],\n },\n {\n type: \"image/gif\",\n category: \"image\",\n maxBytes: MAX_IMAGE_BYTES,\n extensions: [\".gif\"],\n },\n {\n type: \"application/pdf\",\n category: \"document\",\n maxBytes: MAX_MEDIA_BYTES,\n extensions: [\".pdf\"],\n },\n {\n type: \"video/mp4\",\n category: \"video\",\n maxBytes: MAX_MEDIA_BYTES,\n extensions: [\".mp4\"],\n },\n {\n type: \"video/webm\",\n category: \"video\",\n maxBytes: MAX_MEDIA_BYTES,\n extensions: [\".webm\"],\n },\n {\n type: \"audio/mpeg\",\n category: \"audio\",\n maxBytes: MAX_MEDIA_BYTES,\n extensions: [\".mp3\"],\n },\n {\n type: \"audio/ogg\",\n category: \"audio\",\n maxBytes: MAX_MEDIA_BYTES,\n extensions: [\".ogg\"],\n },\n {\n type: \"audio/wav\",\n category: \"audio\",\n maxBytes: MAX_MEDIA_BYTES,\n extensions: [\".wav\"],\n },\n]\n\n/** Value for an `<input type=\"file\">` accept attribute. The UI courtesy, not the rule. */\nexport const MEDIA_ACCEPT_ATTRIBUTE = ALLOWED_MEDIA_TYPES.map((rule) => rule.type).join(\n \",\",\n)\n\n/**\n * The rule for a declared MIME type, or null when the type is not accepted.\n *\n * Parameters are stripped (`text/plain; charset=utf-8`) and the type is lowercased, because\n * a browser is free to report either form and neither is the user's doing.\n */\nexport function mediaTypeRule(type: string): MediaTypeRule | null {\n const normalized = normalizeMediaType(type)\n return ALLOWED_MEDIA_TYPES.find((rule) => rule.type === normalized) ?? null\n}\n\nexport function normalizeMediaType(type: string): string {\n const [base] = type.split(\";\")\n return (base ?? \"\").trim().toLowerCase()\n}\n\n/** The extension including the dot, lowercased, or the empty string when there is none. */\nexport function fileExtension(name: string): string {\n const index = name.lastIndexOf(\".\")\n return index <= 0 ? \"\" : name.slice(index).toLowerCase()\n}\n\nexport type MediaRejectionCode =\n \"type-not-allowed\" | \"too-large\" | \"empty\" | \"extension-mismatch\"\n\nexport interface MediaRejection {\n code: MediaRejectionCode\n /** Written for an editor to read, because it is what the upload row will show. */\n message: string\n}\n\nexport interface MediaFileFacts {\n name: string\n type: string\n size: number\n}\n\n/**\n * Why this file may not be uploaded, or null when it may.\n *\n * Every check here is repeated in `storage.rules`. This one exists so a refusal is\n * immediate and explains itself; the rule exists because this one runs on the client and a\n * client can be replaced.\n */\nexport function checkMediaFile(file: MediaFileFacts): MediaRejection | null {\n const rule = mediaTypeRule(file.type)\n\n if (rule === null) {\n return {\n code: \"type-not-allowed\",\n message: `Files of type ${file.type || \"unknown\"} cannot be uploaded.`,\n }\n }\n\n if (file.size <= 0) {\n return { code: \"empty\", message: \"The file is empty.\" }\n }\n\n if (file.size > rule.maxBytes) {\n return {\n code: \"too-large\",\n message: `${describeBytes(file.size)} is over the ${describeBytes(rule.maxBytes)} limit for ${rule.type} files.`,\n }\n }\n\n // The name is part of the Storage path and the rules check it against the declared type,\n // so a mismatch here would be accepted by the client and refused by the bucket. Catching\n // it now turns a permission error into a sentence.\n if (!rule.extensions.includes(fileExtension(file.name))) {\n return {\n code: \"extension-mismatch\",\n message: `A ${rule.type} file must be named ${rule.extensions.join(\" or \")}.`,\n }\n }\n\n return null\n}\n\n/**\n * A file name safe to put in a Storage path.\n *\n * Lowercased, stripped of anything outside `[a-z0-9._-]`, with the extension preserved\n * because the rules match on it. A name that sanitizes to nothing becomes `file`, so an\n * upload never produces an object path ending in a bare dot.\n */\nexport function safeFileName(name: string, type: string): string {\n const extension = mediaTypeRule(type)?.extensions[0] ?? fileExtension(name)\n const base = (\n extension !== \"\" && name.toLowerCase().endsWith(extension)\n ? name.slice(0, -extension.length)\n : name\n )\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, \"-\")\n // A run of dots is collapsed, so no object path ever contains a `..` segment fragment.\n .replace(/\\.{2,}/g, \".\")\n .replace(/^[-.]+|[-.]+$/g, \"\")\n .slice(0, 80)\n\n return `${base === \"\" ? \"file\" : base}${extension}`\n}\n\nfunction describeBytes(bytes: number): string {\n if (bytes >= MB) return `${Math.round((bytes / MB) * 10) / 10} MB`\n if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`\n return `${bytes} bytes`\n}\n","import type { ContentTypeLike, ContentTypeSource } from \"../types/content-type.js\"\n\nimport { assertValidContentType } from \"./define-content-type.js\"\nimport { LockedTypeError, SchemaError } from \"./errors.js\"\n\n/** A database-defined type that failed validation. Surfaced, never silently dropped. */\nexport interface InvalidContentType {\n slug: string\n reason: string\n definition: unknown\n}\n\nexport interface RegistryEntry {\n type: ContentTypeLike\n source: ContentTypeSource\n locked: boolean\n /**\n * A database-defined type with the same slug as this code-defined one. Code wins, but the\n * database copy is kept so the admin can show the collision instead of silently\n * discarding what an editor created.\n */\n conflict?: { source: \"database\"; type: ContentTypeLike }\n}\n\nexport interface Registry {\n readonly types: readonly RegistryEntry[]\n readonly conflicts: readonly RegistryEntry[]\n readonly invalid: readonly InvalidContentType[]\n get(slug: string): RegistryEntry | undefined\n require(slug: string): RegistryEntry\n has(slug: string): boolean\n /** Entries whose winning definition came from this source. */\n bySource(source: ContentTypeSource): readonly RegistryEntry[]\n}\n\n/**\n * Merges the two schema sources into one list of content types.\n *\n * Precedence is deterministic and one-directional: **code always wins**. A code-defined\n * type is the customer's committed, reviewable, deployable definition; a database-defined\n * one was created in the admin at runtime. When both define the same slug, the code\n * definition is the live one and the database definition is attached as a conflict so the\n * Schema Builder can show it and offer a resolution. Discarding it silently would lose an\n * editor's work with no trace.\n *\n * A code-defined type that is malformed throws: that is a programming error and should\n * fail at startup. A database-defined type that is malformed is collected into `invalid`\n * instead, because one corrupt stored document must not take the whole admin down.\n */\nexport function buildRegistry(\n code: readonly ContentTypeLike[],\n database: readonly ContentTypeLike[],\n): Registry {\n const bySlug = new Map<string, RegistryEntry>()\n const invalid: InvalidContentType[] = []\n\n for (const type of code) {\n assertValidContentType(type)\n\n if (bySlug.has(type.slug)) {\n throw new SchemaError(\n `Content type \"${type.slug}\" is defined twice in code. A slug identifies exactly one content type.`,\n )\n }\n\n bySlug.set(type.slug, {\n type,\n source: \"code\",\n locked: type.locked ?? false,\n })\n }\n\n for (const type of database) {\n try {\n assertValidContentType(type)\n } catch (thrown) {\n invalid.push({\n slug: typeof type?.slug === \"string\" ? type.slug : \"(unknown)\",\n reason: thrown instanceof Error ? thrown.message : String(thrown),\n definition: type,\n })\n continue\n }\n\n const existing = bySlug.get(type.slug)\n\n if (!existing) {\n // A database-defined type is never locked. Locking exists to protect a definition\n // that lives in git, and this one does not.\n bySlug.set(type.slug, { type, source: \"database\", locked: false })\n continue\n }\n\n if (existing.source === \"database\") {\n invalid.push({\n slug: type.slug,\n reason: `Content type \"${type.slug}\" appears twice in the database. Only the first was loaded.`,\n definition: type,\n })\n continue\n }\n\n bySlug.set(type.slug, { ...existing, conflict: { source: \"database\", type } })\n }\n\n const types = [...bySlug.values()]\n\n return {\n types,\n conflicts: types.filter((entry) => entry.conflict !== undefined),\n invalid,\n get: (slug) => bySlug.get(slug),\n has: (slug) => bySlug.has(slug),\n require(slug) {\n const entry = bySlug.get(slug)\n if (!entry) {\n throw new SchemaError(\n `Unknown content type \"${slug}\". Known types: ${types.map((known) => known.type.slug).join(\", \") || \"none\"}.`,\n )\n }\n return entry\n },\n bySource: (source) => types.filter((entry) => entry.source === source),\n }\n}\n\n/**\n * Domain-level lock enforcement.\n *\n * A locked type may not have its *definition* changed: not from the Schema Builder, not\n * through a direct adapter call, not by a request that skipped the UI. Its entries are\n * edited normally; locking protects the schema, not the content.\n */\nexport function assertSchemaWritable(registry: Registry, slug: string): RegistryEntry {\n const entry = registry.require(slug)\n\n if (entry.locked) {\n throw new LockedTypeError(slug)\n }\n\n if (entry.source === \"code\") {\n throw new LockedTypeError(\n slug,\n `Content type \"${slug}\" is defined in code. The Schema Builder writes database-defined types only; change the TypeScript definition and deploy.`,\n )\n }\n\n return entry\n}\n\n/** True when the Schema Builder should render this type read-only. */\nexport function isSchemaReadOnly(entry: RegistryEntry): boolean {\n return entry.locked || entry.source === \"code\"\n}\n","import type { ContentTypeLike } from \"../types/content-type.js\"\n\nimport { fieldMapSignature } from \"./schema-fields.js\"\n\n/**\n * The schema version stamp, and the one question it answers.\n *\n * `content/{slug}.schemaVersion` is what the M10 migration runner reads, and every entry\n * carries the version it was last written at. The runner's only question is: *is this entry\n * stored in the shape the type declares today?* So the stamp has to move exactly when the\n * answer can change, and not otherwise. A stamp that moves on every save tells the runner\n * nothing, because every entry is then behind; a stamp that never moves tells it nothing\n * either, because no entry is ever behind.\n *\n * **Bumps:** adding a field, removing a field, changing a field's type, changing a select's\n * option values or its `multiple`, changing a relation's target or its `many`, changing a\n * date's precision, changing a repeater's nested field map.\n *\n * **Does not bump:** label, plural, description, icon, placeholder, hidden, readonly,\n * validation rules, select option labels, field order, title field, default sort,\n * permissions, and lock state. None of them changes a stored document, so an entry written\n * before the change is still stored exactly as the type describes.\n *\n * Purging a removed field's values does not bump either. The purge deletes data that the\n * type had already stopped declaring, so the declared shape is the same before and after.\n */\n\n/** Everything the stamp is computed from. Anything absent from here cannot move it. */\nexport function storageSignature(type: Pick<ContentTypeLike, \"kind\" | \"fields\">): string {\n // Kind is in it because a singleton stores its one entry at a derived document id and a\n // collection does not, so flipping it relocates every document.\n return `${type.kind}|${fieldMapSignature(type.fields)}`\n}\n\nexport const INITIAL_SCHEMA_VERSION = 1\n\nexport function schemaVersionOf(type: Pick<ContentTypeLike, \"schemaVersion\">): number {\n const version = type.schemaVersion\n return typeof version === \"number\" && Number.isInteger(version) && version >= 1\n ? version\n : INITIAL_SCHEMA_VERSION\n}\n\n/**\n * The version the next write should carry.\n *\n * Computed from the stored definition and the new one, never supplied by a caller, so a\n * client cannot stamp a version that does not describe what it is writing.\n */\nexport function nextSchemaVersion(\n previous: ContentTypeLike | null,\n next: ContentTypeLike,\n): number {\n if (previous === null) return INITIAL_SCHEMA_VERSION\n\n const current = schemaVersionOf(previous)\n return storageSignature(previous) === storageSignature(next) ? current : current + 1\n}\n\n/** True when the two definitions would be stamped differently. Drives the \"this is a breaking change\" warning. */\nexport function storageShapeChanged(\n previous: ContentTypeLike,\n next: ContentTypeLike,\n): boolean {\n return storageSignature(previous) !== storageSignature(next)\n}\n","/**\n * What a single write may weigh, and how often one document may be written.\n *\n * Two layers, and they are not the same strength. Be exact about which is which:\n *\n * - **Enforced by firestore.rules.** The top-level field count of an incoming document,\n * and a minimum interval between two writes to the same document. A rule can see both,\n * so a client that skips the adapter is still refused. The numbers live here and are\n * repeated in firestore.rules, because a rules file cannot import.\n * - **Enforced by the adapter only.** Estimated document size, nesting depth, array\n * length, and the burst limiter below. A Firestore rule cannot see the byte size of a\n * document and cannot count a caller's writes across documents, so these catch a\n * mistake and refuse it with a message a person can act on. They are not a guarantee\n * against a determined client holding a Firebase config: that client's real ceiling is\n * Firestore's own 1 MiB per document and the per-document floor in the rules.\n *\n * The two do not overlap, deliberately. The adapter does not repeat the per-document\n * interval: the rules already refuse that write, repeating it here would only move the\n * refusal earlier for callers who never needed it, and a legitimate script that creates an\n * entry and immediately corrects it would be stopped by Husk before the database had a\n * chance to say yes. Each guard lives at exactly one layer, and it is the layer that can\n * see the thing it is guarding.\n *\n * Anything a customer can hit by accident should fail here, with a sentence naming the\n * field, rather than at Firestore with `INVALID_ARGUMENT`.\n */\n\n/** Firestore's own hard ceiling for one document. Nothing here may exceed it. */\nexport const FIRESTORE_DOCUMENT_BYTES = 1_048_576\n\n/**\n * Husk's ceiling, deliberately under Firestore's.\n *\n * The gap is headroom for the fields the adapter adds after the values are measured\n * (`mediaRefs`, the audit stamps, the status) plus the difference between the estimate\n * below and Firestore's own accounting, which counts field names and type tags.\n */\nexport const MAX_WRITE_BYTES = 900_000\n\n/** Top-level fields on an entry document. Mirrored in firestore.rules. */\nexport const MAX_ENTRY_FIELDS = 200\n\n/** Fields on one content type. Mirrored in firestore.rules on `schemas/{slug}`. */\nexport const MAX_CONTENT_TYPE_FIELDS = 100\n\n/** Top-level keys of one settings document. Mirrored in firestore.rules. */\nexport const MAX_SETTINGS_FIELDS = 100\n\n/** Firestore refuses deeper nesting than this, so refusing it here names the cause. */\nexport const MAX_NESTING_DEPTH = 20\n\n/** Items in one stored array, which in practice is a repeater or a multi-relation. */\nexport const MAX_ARRAY_ITEMS = 1000\n\n/**\n * The floor between two writes to the same document. Mirrored in firestore.rules.\n *\n * Chosen so a human cannot reach it and a loop cannot ignore it: 200 ms throttles a\n * runaway client to five writes a second on one document, while the fastest a person can\n * meaningfully save the same entry twice is an order of magnitude slower.\n */\nexport const MIN_WRITE_INTERVAL_MS = 200\n\n/**\n * The adapter-side burst window: at most this many writes in this many milliseconds.\n *\n * A loop detector, not a quota. It has to sit above everything the product does on\n * purpose, and the product does bulk writes: a media replace rewrites the url on up to 500\n * referencing entries, a field purge clears 250, a migration batch moves 50. Twenty writes\n * a second is far past any of those and still nowhere near a runaway loop, which does\n * thousands. Set it too low and the first thing it breaks is a legitimate operation.\n */\nexport const WRITE_BURST_LIMIT = 200\nexport const WRITE_BURST_WINDOW_MS = 10_000\n\nexport type WriteLimitCode =\n \"too-many-fields\" | \"too-large\" | \"too-deep\" | \"too-many-items\" | \"too-fast\"\n\nexport interface WriteLimitRejection {\n code: WriteLimitCode\n /** Dotted path to the offending value, or \"\" when the document as a whole is at fault. */\n path: string\n message: string\n}\n\n/**\n * A rough byte count for a value on its way to Firestore.\n *\n * Deliberately an estimate. Firestore's accounting adds the field name plus one byte per\n * value and counts strings as UTF-8 plus one, and reproducing it exactly would be a second\n * implementation to keep in step for no gain: the number is used against a ceiling that is\n * already 148 KB below the real one. What it must not do is undercount badly, which is why\n * strings are measured in UTF-8 bytes rather than in code units.\n */\nexport function approximateBytes(value: unknown): number {\n if (value === null || value === undefined) return 1\n if (typeof value === \"boolean\") return 1\n if (typeof value === \"number\") return 8\n if (typeof value === \"string\") return utf8Length(value) + 1\n if (value instanceof Date) return 8\n\n if (Array.isArray(value)) {\n return value.reduce<number>((total, item) => total + approximateBytes(item), 2)\n }\n\n if (typeof value === \"object\") {\n return Object.entries(value as Record<string, unknown>).reduce(\n (total, [key, item]) => total + utf8Length(key) + 1 + approximateBytes(item),\n 2,\n )\n }\n\n return 8\n}\n\nfunction utf8Length(value: string): number {\n // TextEncoder is in every runtime Husk supports (Node 22, every modern browser), and it\n // is the only way to get the byte length a non-ASCII title actually costs.\n return new TextEncoder().encode(value).length\n}\n\nexport interface PayloadLimits {\n /** Top-level field cap. Defaults to the entry cap. */\n maxFields?: number\n /** Byte ceiling. Defaults to MAX_WRITE_BYTES. */\n maxBytes?: number\n}\n\n/**\n * Checks one document's worth of values. Returns the first problem, or null.\n *\n * First problem rather than all of them on purpose: these are not field validation errors\n * a form routes to inputs, they are a refusal to write at all, and the caller shows one\n * sentence. Field validation already ran; this is the guard behind it.\n */\nexport function checkWritePayload(\n values: Record<string, unknown>,\n limits: PayloadLimits = {},\n): WriteLimitRejection | null {\n const maxFields = limits.maxFields ?? MAX_ENTRY_FIELDS\n const maxBytes = limits.maxBytes ?? MAX_WRITE_BYTES\n const keys = Object.keys(values)\n\n if (keys.length > maxFields) {\n return {\n code: \"too-many-fields\",\n path: \"\",\n message: `This write has ${keys.length} top-level fields and the limit is ${maxFields}.`,\n }\n }\n\n const structural = checkStructure(values, \"\", 1)\n if (structural !== null) return structural\n\n const bytes = approximateBytes(values)\n if (bytes > maxBytes) {\n return {\n code: \"too-large\",\n path: \"\",\n message: `This write is about ${Math.round(bytes / 1024)} KB and the limit is ${Math.round(maxBytes / 1024)} KB. Move large content into a media file and reference it.`,\n }\n }\n\n return null\n}\n\nfunction checkStructure(\n value: unknown,\n path: string,\n depth: number,\n): WriteLimitRejection | null {\n if (depth > MAX_NESTING_DEPTH) {\n return {\n code: \"too-deep\",\n path,\n message: `This value nests more than ${MAX_NESTING_DEPTH} levels deep, which Firestore refuses to store.`,\n }\n }\n\n if (Array.isArray(value)) {\n if (value.length > MAX_ARRAY_ITEMS) {\n return {\n code: \"too-many-items\",\n path,\n message: `\"${path}\" holds ${value.length} items and the limit is ${MAX_ARRAY_ITEMS}.`,\n }\n }\n\n for (const [index, item] of value.entries()) {\n const found = checkStructure(item, `${path}.${index}`, depth + 1)\n if (found !== null) return found\n }\n return null\n }\n\n if (value !== null && typeof value === \"object\" && !(value instanceof Date)) {\n for (const [key, item] of Object.entries(value as Record<string, unknown>)) {\n const found = checkStructure(item, path === \"\" ? key : `${path}.${key}`, depth + 1)\n if (found !== null) return found\n }\n }\n\n return null\n}\n\n/** Reads a clock. Injected so a test does not have to sleep to cross a window. */\nexport type Clock = () => number\n\nexport interface WriteRateLimiter {\n /**\n * Records an attempted write and says whether it is refused.\n *\n * One question only: is this process writing faster than anything but a loop would? The\n * per-document interval is not asked here, because firestore.rules asks it of every\n * write whether or not it came through this adapter.\n */\n check(): WriteLimitRejection | null\n}\n\n/**\n * The adapter's burst guard.\n *\n * Honest about what it is: one process's memory. A second tab has its own, and a script\n * holding the customer's Firebase config has none at all. It exists to turn a runaway loop\n * in the admin into one clear refusal instead of a few thousand writes and a bill, and the\n * rules-side per-document floor is what covers the case this cannot see.\n */\nexport function createWriteRateLimiter(clock: Clock = Date.now): WriteRateLimiter {\n let window: number[] = []\n\n return {\n check() {\n const now = clock()\n window = window.filter((at) => now - at < WRITE_BURST_WINDOW_MS)\n\n if (window.length >= WRITE_BURST_LIMIT) {\n return {\n code: \"too-fast\",\n path: \"\",\n message: `More than ${WRITE_BURST_LIMIT} writes in ${WRITE_BURST_WINDOW_MS / 1000} seconds. Something is looping; nothing was written.`,\n }\n }\n\n window.push(now)\n return null\n },\n }\n}\n","/**\n * The reverse index that answers \"which entries use this media item\".\n *\n * Firestore has no reverse lookup and no join. A media reference lives inside an entry's\n * field values, possibly several levels down inside a repeater row, and nothing about the\n * media document names the entries that point at it. Two ways out of that were available:\n *\n * 1. **Scan.** On every usage question, read every entry of every content type and look\n * inside each one. Correct for all data, past and present, and priced at one document\n * read per entry per question. A customer with 20,000 entries pays 20,000 reads to\n * learn whether one photo may be deleted, and pays it again for the next photo. That is\n * not a cost a CMS may hide inside a delete button.\n * 2. **Index.** Every entry write also writes the flat list of media ids that entry\n * references, into a `mediaRefs` array on the entry document itself. The question then\n * becomes one indexed collection-group query whose cost is the number of answers, not\n * the size of the database.\n *\n * Husk takes the index. It is written in the same document write as the values it\n * summarizes, so it cannot drift from them: there is no second document, no transaction\n * spanning two collections, and no background job that can fall behind. Deleting an entry\n * deletes its index with it.\n *\n * Both functions below walk the stored data and recognize a reference by its shape: an\n * object carrying a string `mediaId`. That is deliberate, and it is the reason neither of\n * them takes a content type, a field map or a field type registry.\n *\n * - The shape is the contract. `types/media.ts` defines `MediaReference` as exactly this,\n * and nothing else in the product stores an object with a `mediaId` key.\n * - A walk over the data cannot be wrong about what the data contains. A schema walk would\n * have to know which field types can hold media, recurse through repeater definitions,\n * and it would still miss a reference stored under a field the schema no longer declares.\n * - It needs no registry, so the adapter indexes every entry it writes, including one\n * written by a script that never loads the admin's React field components.\n *\n * **Where the bound is, stated rather than implied.** The index exists only on entries\n * written by a version that writes it. An entry stored before this change, or written\n * straight into the Firestore console, carries no `mediaRefs` field and is therefore\n * invisible to every usage question until it is saved once. The consequence is named in\n * the UI rather than hidden: the delete warning says what the count is based on. See\n * .docs/decisions/2026-09-18-media-usage-index.md.\n */\n\n/** The entry-document field the reverse index is written to. */\nexport const MEDIA_REFS_KEY = \"mediaRefs\"\n\n/**\n * The ceiling on indexed ids per entry.\n *\n * An array field is part of the document and a document is capped at 1 MiB, but the real\n * reason for a limit is the index itself: Firestore writes one index entry per array\n * element, so an entry holding a thousand references would cost a thousand index writes on\n * every save. A hundred is far above any real page and low enough that the write stays\n * ordinary. Beyond it the list is truncated rather than the save refused, because a save\n * that fails over a bookkeeping field would be the worse outcome; the consequence is that\n * usage for the truncated ids is under-reported, which is why the number is generous.\n */\nexport const MAX_MEDIA_REFS = 100\n\n/**\n * Every media id an entry's stored values reference, deduplicated and ordered.\n *\n * Ordered because two arrays holding the same ids in a different order are the same index,\n * and a stable order is what lets a caller compare one write against the last. Deduplicated\n * because the same photo used twice on one page is one reference.\n */\nexport function collectMediaIds(data: Record<string, unknown>): string[] {\n const found = new Set<string>()\n\n function walk(value: unknown): void {\n if (Array.isArray(value)) {\n for (const item of value) walk(item)\n return\n }\n if (!isPlainObject(value)) return\n\n const mediaId = value[\"mediaId\"]\n if (typeof mediaId === \"string\") {\n // A blank id is what a half-built reference looks like. It points at nothing, so\n // indexing it would create a usage nobody can ever resolve or clear.\n if (mediaId !== \"\") found.add(mediaId)\n return\n }\n\n for (const nested of Object.values(value)) walk(nested)\n }\n\n walk(data)\n\n return [...found].sort().slice(0, MAX_MEDIA_REFS)\n}\n\n/**\n * Rewrites the denormalized `url` of every reference to one media item, anywhere in a\n * stored entry document.\n *\n * Returns a new object when anything changed and `null` when nothing did, so a caller can\n * skip a write instead of touching an entry's `updatedAt` for no reason.\n */\nexport function retargetMediaUrls(\n data: Record<string, unknown>,\n mediaId: string,\n url: string,\n): Record<string, unknown> | null {\n let changed = false\n\n function walk(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(walk)\n if (!isPlainObject(value)) return value\n\n const record = value\n if (record[\"mediaId\"] === mediaId) {\n if (record[\"url\"] === url) return record\n changed = true\n return { ...record, url }\n }\n\n const next: Record<string, unknown> = {}\n for (const [key, nested] of Object.entries(record)) next[key] = walk(nested)\n return next\n }\n\n const result = walk(data) as Record<string, unknown>\n return changed ? result : null\n}\n\n/**\n * A map written by the CMS, as opposed to a class instance the storage layer put there.\n *\n * A stored entry holds `Timestamp` objects among its plain maps. Spreading one into an\n * object literal would turn a timestamp into an inert bag of private fields, so both walks\n * above descend into plain objects only and hand everything else back untouched.\n */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (typeof value !== \"object\" || value === null) return false\n const proto: unknown = Object.getPrototypeOf(value)\n return proto === Object.prototype || proto === null\n}\n"]}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ENTRY_ACTIONS, ROLES } from './chunk-TS7Y7GEZ.js';
|
|
2
|
+
|
|
3
|
+
// src/permissions/can.ts
|
|
4
|
+
var ADMIN_ACTIONS = [
|
|
5
|
+
"manage-schema",
|
|
6
|
+
"manage-users",
|
|
7
|
+
"manage-settings",
|
|
8
|
+
"manage-media"
|
|
9
|
+
];
|
|
10
|
+
var ACTIONS = [...ENTRY_ACTIONS, ...ADMIN_ACTIONS];
|
|
11
|
+
var PERMISSION_DEFAULTS = {
|
|
12
|
+
admin: {
|
|
13
|
+
read: "allow",
|
|
14
|
+
create: "allow",
|
|
15
|
+
update: "allow",
|
|
16
|
+
delete: "allow",
|
|
17
|
+
"manage-schema": "allow",
|
|
18
|
+
"manage-users": "allow",
|
|
19
|
+
"manage-settings": "allow",
|
|
20
|
+
"manage-media": "allow"
|
|
21
|
+
},
|
|
22
|
+
editor: {
|
|
23
|
+
read: "allow",
|
|
24
|
+
create: "allow",
|
|
25
|
+
update: "allow",
|
|
26
|
+
delete: "allow",
|
|
27
|
+
// Structure and user accounts stay with an admin: an editor who could change either
|
|
28
|
+
// could grant themselves everything else.
|
|
29
|
+
"manage-schema": "deny",
|
|
30
|
+
"manage-users": "deny",
|
|
31
|
+
"manage-settings": "allow",
|
|
32
|
+
"manage-media": "allow"
|
|
33
|
+
},
|
|
34
|
+
author: {
|
|
35
|
+
read: "allow",
|
|
36
|
+
create: "allow",
|
|
37
|
+
update: "own-entry-only",
|
|
38
|
+
delete: "deny",
|
|
39
|
+
"manage-schema": "deny",
|
|
40
|
+
"manage-users": "deny",
|
|
41
|
+
"manage-settings": "deny",
|
|
42
|
+
"manage-media": "deny"
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function isEntryAction(action) {
|
|
46
|
+
return ENTRY_ACTIONS.includes(action);
|
|
47
|
+
}
|
|
48
|
+
function can(user, action, context = {}) {
|
|
49
|
+
if (user.disabled) return false;
|
|
50
|
+
if (!ROLES.includes(user.role)) return false;
|
|
51
|
+
if (user.role === "admin") return true;
|
|
52
|
+
const decision = resolveDecision(user.role, action, context.type);
|
|
53
|
+
if (decision === "allow") return true;
|
|
54
|
+
if (decision === "deny") return false;
|
|
55
|
+
return context.entry !== void 0 && context.entry.createdBy === user.uid;
|
|
56
|
+
}
|
|
57
|
+
function resolveDecision(role, action, type) {
|
|
58
|
+
if (role === "admin") return PERMISSION_DEFAULTS.admin[action];
|
|
59
|
+
const fallback = PERMISSION_DEFAULTS[role][action];
|
|
60
|
+
const matrixRoles = isEntryAction(action) && type?.permissions !== void 0 ? type.permissions[action] : void 0;
|
|
61
|
+
if (matrixRoles === void 0) return fallback;
|
|
62
|
+
if (fallback === "deny") return "deny";
|
|
63
|
+
return matrixRoles.includes(role) ? fallback : "deny";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { ACTIONS, ADMIN_ACTIONS, PERMISSION_DEFAULTS, can, isEntryAction, resolveDecision };
|
|
67
|
+
//# sourceMappingURL=chunk-NPDBQBLW.js.map
|
|
68
|
+
//# sourceMappingURL=chunk-NPDBQBLW.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/permissions/can.ts"],"names":[],"mappings":";;;AAcO,IAAM,aAAA,GAAgB;AAAA,EAC3B,eAAA;AAAA,EACA,cAAA;AAAA,EACA,iBAAA;AAAA,EACA;AACF;AAGO,IAAM,OAAA,GAA6B,CAAC,GAAG,aAAA,EAAe,GAAG,aAAa;AAatE,IAAM,mBAAA,GAET;AAAA,EACF,KAAA,EAAO;AAAA,IACL,IAAA,EAAM,OAAA;AAAA,IACN,MAAA,EAAQ,OAAA;AAAA,IACR,MAAA,EAAQ,OAAA;AAAA,IACR,MAAA,EAAQ,OAAA;AAAA,IACR,eAAA,EAAiB,OAAA;AAAA,IACjB,cAAA,EAAgB,OAAA;AAAA,IAChB,iBAAA,EAAmB,OAAA;AAAA,IACnB,cAAA,EAAgB;AAAA,GAClB;AAAA,EACA,MAAA,EAAQ;AAAA,IACN,IAAA,EAAM,OAAA;AAAA,IACN,MAAA,EAAQ,OAAA;AAAA,IACR,MAAA,EAAQ,OAAA;AAAA,IACR,MAAA,EAAQ,OAAA;AAAA;AAAA;AAAA,IAGR,eAAA,EAAiB,MAAA;AAAA,IACjB,cAAA,EAAgB,MAAA;AAAA,IAChB,iBAAA,EAAmB,OAAA;AAAA,IACnB,cAAA,EAAgB;AAAA,GAClB;AAAA,EACA,MAAA,EAAQ;AAAA,IACN,IAAA,EAAM,OAAA;AAAA,IACN,MAAA,EAAQ,OAAA;AAAA,IACR,MAAA,EAAQ,gBAAA;AAAA,IACR,MAAA,EAAQ,MAAA;AAAA,IACR,eAAA,EAAiB,MAAA;AAAA,IACjB,cAAA,EAAgB,MAAA;AAAA,IAChB,iBAAA,EAAmB,MAAA;AAAA,IACnB,cAAA,EAAgB;AAAA;AAEpB;AAeO,SAAS,cAAc,MAAA,EAAuC;AACnE,EAAA,OAAQ,aAAA,CAAoC,SAAS,MAAM,CAAA;AAC7D;AAuBO,SAAS,GAAA,CACd,IAAA,EACA,MAAA,EACA,OAAA,GAA6B,EAAC,EACrB;AACT,EAAA,IAAI,IAAA,CAAK,UAAU,OAAO,KAAA;AAG1B,EAAA,IAAI,CAAC,KAAA,CAAM,QAAA,CAAS,IAAA,CAAK,IAAI,GAAG,OAAO,KAAA;AAEvC,EAAA,IAAI,IAAA,CAAK,IAAA,KAAS,OAAA,EAAS,OAAO,IAAA;AAElC,EAAA,MAAM,WAAW,eAAA,CAAgB,IAAA,CAAK,IAAA,EAAM,MAAA,EAAQ,QAAQ,IAAI,CAAA;AAEhE,EAAA,IAAI,QAAA,KAAa,SAAS,OAAO,IAAA;AACjC,EAAA,IAAI,QAAA,KAAa,QAAQ,OAAO,KAAA;AAEhC,EAAA,OAAO,QAAQ,KAAA,KAAU,MAAA,IAAa,OAAA,CAAQ,KAAA,CAAM,cAAc,IAAA,CAAK,GAAA;AACzE;AASO,SAAS,eAAA,CACd,IAAA,EACA,MAAA,EACA,IAAA,EACU;AAEV,EAAA,IAAI,IAAA,KAAS,OAAA,EAAS,OAAO,mBAAA,CAAoB,MAAM,MAAM,CAAA;AAE7D,EAAA,MAAM,QAAA,GAAW,mBAAA,CAAoB,IAAI,CAAA,CAAE,MAAM,CAAA;AACjD,EAAA,MAAM,WAAA,GACJ,aAAA,CAAc,MAAM,CAAA,IAAK,IAAA,EAAM,gBAAgB,MAAA,GAC3C,IAAA,CAAK,WAAA,CAAY,MAAM,CAAA,GACvB,MAAA;AAEN,EAAA,IAAI,WAAA,KAAgB,QAAW,OAAO,QAAA;AAGtC,EAAA,IAAI,QAAA,KAAa,QAAQ,OAAO,MAAA;AAEhC,EAAA,OAAO,WAAA,CAAY,QAAA,CAAS,IAAI,CAAA,GAAI,QAAA,GAAW,MAAA;AACjD","file":"chunk-NPDBQBLW.js","sourcesContent":["import type { Action, AdminAction, AuthUser, EntryAction, Role } from \"../types/auth.js\"\nimport { ENTRY_ACTIONS, ROLES } from \"../types/auth.js\"\nimport type { ContentTypeLike } from \"../types/content-type.js\"\nimport type { EntryMeta } from \"../types/entry.js\"\n\n/**\n * The one permission resolver.\n *\n * The admin UI calls it to hide what a user may not do, the adapter calls it again to\n * refuse the call when the UI is bypassed, and the security rules encode the same matrix a\n * third time. Three layers, one truth table. Keep this pure and synchronous: it has to run\n * inside a render, inside a write path, and inside a test, with no Firebase and no IO.\n */\n\nexport const ADMIN_ACTIONS = [\n \"manage-schema\",\n \"manage-users\",\n \"manage-settings\",\n \"manage-media\",\n] as const satisfies readonly AdminAction[]\n\n/** Every action the resolver answers for. The test cross-checks it against the table. */\nexport const ACTIONS: readonly Action[] = [...ENTRY_ACTIONS, ...ADMIN_ACTIONS]\n\n/**\n * An allow that still depends on who created the entry. Kept as a third state rather than\n * resolved to a boolean early, so an ownership-gated action can be told apart from a plain\n * denial and answered against the entry the caller passed in.\n */\nexport type Decision = \"allow\" | \"deny\" | \"own-entry-only\"\n\n/**\n * The role defaults, exhaustive by construction: `Record<Role, Record<Action, Decision>>`\n * fails to typecheck the moment a role or an action is added without a decision here.\n */\nexport const PERMISSION_DEFAULTS: Readonly<\n Record<Role, Readonly<Record<Action, Decision>>>\n> = {\n admin: {\n read: \"allow\",\n create: \"allow\",\n update: \"allow\",\n delete: \"allow\",\n \"manage-schema\": \"allow\",\n \"manage-users\": \"allow\",\n \"manage-settings\": \"allow\",\n \"manage-media\": \"allow\",\n },\n editor: {\n read: \"allow\",\n create: \"allow\",\n update: \"allow\",\n delete: \"allow\",\n // Structure and user accounts stay with an admin: an editor who could change either\n // could grant themselves everything else.\n \"manage-schema\": \"deny\",\n \"manage-users\": \"deny\",\n \"manage-settings\": \"allow\",\n \"manage-media\": \"allow\",\n },\n author: {\n read: \"allow\",\n create: \"allow\",\n update: \"own-entry-only\",\n delete: \"deny\",\n \"manage-schema\": \"deny\",\n \"manage-users\": \"deny\",\n \"manage-settings\": \"deny\",\n \"manage-media\": \"deny\",\n },\n}\n\n/** Just enough of an entry to answer an ownership question. */\nexport type EntryOwnership = Pick<EntryMeta, \"createdBy\">\n\nexport interface PermissionContext {\n /** The content type the action targets. Carries the per-type permission matrix, if any. */\n type?: ContentTypeLike\n /**\n * The entry the action targets. Absent means the caller does not know which entry, which\n * denies an ownership-gated action rather than guessing in the user's favour.\n */\n entry?: EntryOwnership\n}\n\nexport function isEntryAction(action: Action): action is EntryAction {\n return (ENTRY_ACTIONS as readonly Action[]).includes(action)\n}\n\n/**\n * Answers one permission question.\n *\n * Ownership is resolved against `context.entry`, not against the content type, because an\n * author's `update` is a question about one entry and cannot be answered honestly without\n * it. A caller that does not pass the entry gets `false`: \"I cannot tell\" has to read as a\n * denial at the UI layer, since the adapter and the rules will deny it too.\n *\n * `context.type.permissions` narrows the role defaults for entry actions on that type. An\n * absent matrix, and an action absent from it, mean \"use the defaults\", never \"deny\n * everything\", so adding a matrix for one action does not silently lock out the rest.\n *\n * A matrix narrows and never widens. Three things follow, and M10 settled all three by\n * making the security rules agree with them rather than by softening them here. It cannot\n * restrict an admin. It cannot lift an author's ownership rule, so ticking `author` under\n * `update` grants that action on the author's own entries only. And it cannot grant an\n * action the role's own default denies, so ticking `author` under `delete` changes nothing:\n * firestore.rules resolves the role defaults from the token alone and only consults the\n * matrix to narrow further, because a rule that widened a privilege from a stored document\n * would be trusting the document to grant it. See permissions/matrix.ts.\n */\nexport function can(\n user: AuthUser,\n action: Action,\n context: PermissionContext = {},\n): boolean {\n if (user.disabled) return false\n\n // The role arrives from a stored user document or a custom claim, both untrusted input.\n if (!ROLES.includes(user.role)) return false\n\n if (user.role === \"admin\") return true\n\n const decision = resolveDecision(user.role, action, context.type)\n\n if (decision === \"allow\") return true\n if (decision === \"deny\") return false\n\n return context.entry !== undefined && context.entry.createdBy === user.uid\n}\n\n/**\n * The truth table lookup on its own, without the entry.\n *\n * Exported because `permissions/matrix.ts` labels the Developer area's table with it and a\n * second implementation of the same lookup would be a second place for the three layers to\n * drift apart. `can()` is this plus the ownership question.\n */\nexport function resolveDecision(\n role: Role,\n action: Action,\n type: Pick<ContentTypeLike, \"permissions\"> | undefined,\n): Decision {\n // An admin is never restricted by a matrix, so there is nothing to look up.\n if (role === \"admin\") return PERMISSION_DEFAULTS.admin[action]\n\n const fallback = PERMISSION_DEFAULTS[role][action]\n const matrixRoles =\n isEntryAction(action) && type?.permissions !== undefined\n ? type.permissions[action]\n : undefined\n\n if (matrixRoles === undefined) return fallback\n // Narrowing only. A role the defaults deny stays denied however the matrix is written,\n // because firestore.rules will deny it from the token without reading the matrix at all.\n if (fallback === \"deny\") return \"deny\"\n\n return matrixRoles.includes(role) ? fallback : \"deny\"\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types/entry.ts"],"names":[],"mappings":";AAGO,IAAM,cAAA,GAAiB,CAAC,OAAA,EAAS,WAAA,EAAa,UAAU","file":"chunk-ORMYARXD.js","sourcesContent":["import type { ContentTypeInputValues, ContentTypeValues } from \"./content-type.js\"\nimport type { FieldMap } from \"./field.js\"\n\nexport const ENTRY_STATUSES = [\"draft\", \"published\", \"archived\"] as const\n\nexport type EntryStatus = (typeof ENTRY_STATUSES)[number]\n\n/**\n * Everything the adapter owns on an entry. Written on every mutation, never by a form.\n * Timestamps are ISO 8601 strings at this boundary; the Firestore converter turns them\n * into Timestamps on the way in and back on the way out.\n */\nexport interface EntryMeta {\n id: string\n status: EntryStatus\n createdAt: string\n createdBy: string\n updatedAt: string\n updatedBy: string\n publishedAt: string | null\n schemaVersion: number\n}\n\n/** What a read returns: the metadata plus the typed field values. */\nexport type Entry<TType extends AnyContentTypeShape = AnyContentTypeShape> = EntryMeta &\n ContentTypeValues<TType>\n\n/** What a write accepts: required field values, optional ones omittable, no metadata. */\nexport type EntryInput<TType extends AnyContentTypeShape = AnyContentTypeShape> =\n ContentTypeInputValues<TType> & {\n status?: EntryStatus\n }\n\n/**\n * Structural stand-in for `ContentType<TFields>`. `ContentType` is invariant in its field\n * map (it uses `keyof TFields` for titleField and defaultSort), so a concrete content type\n * is not assignable to `ContentType<FieldMap>` and cannot be used as a constraint here.\n */\nexport type AnyContentTypeShape = { fields: FieldMap }\n\nexport interface FieldError {\n /** Dotted path, so a repeater reports `blocks.2.title` rather than `blocks`. */\n path: string\n rule: string\n message: string\n}\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SingletonError } from './chunk-4Z6P3XCE.js';
|
|
2
|
+
|
|
3
|
+
// src/core/content-kind.ts
|
|
4
|
+
function isCollection(type) {
|
|
5
|
+
return type.kind === "collection";
|
|
6
|
+
}
|
|
7
|
+
function isSingleton(type) {
|
|
8
|
+
return type.kind === "singleton";
|
|
9
|
+
}
|
|
10
|
+
function singletonEntryId(slug) {
|
|
11
|
+
return slug;
|
|
12
|
+
}
|
|
13
|
+
function assertSingletonCreatable(type, exists) {
|
|
14
|
+
if (!isSingleton(type)) return;
|
|
15
|
+
if (exists) {
|
|
16
|
+
throw new SingletonError(
|
|
17
|
+
type.slug,
|
|
18
|
+
`Content type "${type.slug}" is a singleton and already has its entry. Update it instead of creating another.`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function assertSingletonEntryId(type, entryId) {
|
|
23
|
+
if (!isSingleton(type)) return;
|
|
24
|
+
const expected = singletonEntryId(type.slug);
|
|
25
|
+
if (entryId !== expected) {
|
|
26
|
+
throw new SingletonError(
|
|
27
|
+
type.slug,
|
|
28
|
+
`Content type "${type.slug}" is a singleton, so its entry id is always "${expected}". Received "${entryId}".`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function assertListable(type) {
|
|
33
|
+
if (isSingleton(type)) {
|
|
34
|
+
throw new SingletonError(
|
|
35
|
+
type.slug,
|
|
36
|
+
`Content type "${type.slug}" is a singleton and cannot be listed, filtered, or paginated. Read it with single("${type.slug}").get().`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function assertDeletable(type) {
|
|
41
|
+
if (isSingleton(type)) {
|
|
42
|
+
throw new SingletonError(
|
|
43
|
+
type.slug,
|
|
44
|
+
`Content type "${type.slug}" is a singleton and its entry cannot be deleted. Clear its fields instead.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { assertDeletable, assertListable, assertSingletonCreatable, assertSingletonEntryId, isCollection, isSingleton, singletonEntryId };
|
|
50
|
+
//# sourceMappingURL=chunk-R4O7APIX.js.map
|
|
51
|
+
//# sourceMappingURL=chunk-R4O7APIX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/content-kind.ts"],"names":[],"mappings":";;;AAUO,SAAS,aAAa,IAAA,EAA0B;AACrD,EAAA,OAAO,KAAK,IAAA,KAAS,YAAA;AACvB;AAEO,SAAS,YAAY,IAAA,EAA0B;AACpD,EAAA,OAAO,KAAK,IAAA,KAAS,WAAA;AACvB;AASO,SAAS,iBAAiB,IAAA,EAAsB;AACrD,EAAA,OAAO,IAAA;AACT;AASO,SAAS,wBAAA,CAAyB,MAAiB,MAAA,EAAuB;AAC/E,EAAA,IAAI,CAAC,WAAA,CAAY,IAAI,CAAA,EAAG;AACxB,EAAA,IAAI,MAAA,EAAQ;AACV,IAAA,MAAM,IAAI,cAAA;AAAA,MACR,IAAA,CAAK,IAAA;AAAA,MACL,CAAA,cAAA,EAAiB,KAAK,IAAI,CAAA,kFAAA;AAAA,KAC5B;AAAA,EACF;AACF;AAGO,SAAS,sBAAA,CAAuB,MAAiB,OAAA,EAAuB;AAC7E,EAAA,IAAI,CAAC,WAAA,CAAY,IAAI,CAAA,EAAG;AAExB,EAAA,MAAM,QAAA,GAAW,gBAAA,CAAiB,IAAA,CAAK,IAAI,CAAA;AAC3C,EAAA,IAAI,YAAY,QAAA,EAAU;AACxB,IAAA,MAAM,IAAI,cAAA;AAAA,MACR,IAAA,CAAK,IAAA;AAAA,MACL,iBAAiB,IAAA,CAAK,IAAI,CAAA,6CAAA,EAAgD,QAAQ,gBAAgB,OAAO,CAAA,EAAA;AAAA,KAC3G;AAAA,EACF;AACF;AAGO,SAAS,eAAe,IAAA,EAAuB;AACpD,EAAA,IAAI,WAAA,CAAY,IAAI,CAAA,EAAG;AACrB,IAAA,MAAM,IAAI,cAAA;AAAA,MACR,IAAA,CAAK,IAAA;AAAA,MACL,CAAA,cAAA,EAAiB,IAAA,CAAK,IAAI,CAAA,oFAAA,EAAuF,KAAK,IAAI,CAAA,SAAA;AAAA,KAC5H;AAAA,EACF;AACF;AAGO,SAAS,gBAAgB,IAAA,EAAuB;AACrD,EAAA,IAAI,WAAA,CAAY,IAAI,CAAA,EAAG;AACrB,IAAA,MAAM,IAAI,cAAA;AAAA,MACR,IAAA,CAAK,IAAA;AAAA,MACL,CAAA,cAAA,EAAiB,KAAK,IAAI,CAAA,2EAAA;AAAA,KAC5B;AAAA,EACF;AACF","file":"chunk-R4O7APIX.js","sourcesContent":["import type { ContentTypeKind } from \"../types/content-type.js\"\n\nimport { SingletonError } from \"./errors.js\"\n\n/** The minimum a kind guard needs to know about a content type. */\nexport interface KindShape {\n slug: string\n kind: ContentTypeKind\n}\n\nexport function isCollection(type: KindShape): boolean {\n return type.kind === \"collection\"\n}\n\nexport function isSingleton(type: KindShape): boolean {\n return type.kind === \"singleton\"\n}\n\n/**\n * The one document id a singleton is allowed to use.\n *\n * Derived from the slug rather than generated, so \"exactly one entry\" is a property of the\n * path itself: a second create targets the same document and is a write conflict rather\n * than a second entry. See .docs/decisions/2026-09-18-firestore-layout.md.\n */\nexport function singletonEntryId(slug: string): string {\n return slug\n}\n\n/**\n * Guards a create against a singleton.\n *\n * `exists` is what the adapter read a moment ago. The path-derived id makes the race\n * harmless: two concurrent creates address the same document, so the second is an\n * overwrite of the first rather than a duplicate entry.\n */\nexport function assertSingletonCreatable(type: KindShape, exists: boolean): void {\n if (!isSingleton(type)) return\n if (exists) {\n throw new SingletonError(\n type.slug,\n `Content type \"${type.slug}\" is a singleton and already has its entry. Update it instead of creating another.`,\n )\n }\n}\n\n/** Guards an addressed write or read against a singleton. */\nexport function assertSingletonEntryId(type: KindShape, entryId: string): void {\n if (!isSingleton(type)) return\n\n const expected = singletonEntryId(type.slug)\n if (entryId !== expected) {\n throw new SingletonError(\n type.slug,\n `Content type \"${type.slug}\" is a singleton, so its entry id is always \"${expected}\". Received \"${entryId}\".`,\n )\n }\n}\n\n/** Guards list-shaped operations, which a singleton does not have. */\nexport function assertListable(type: KindShape): void {\n if (isSingleton(type)) {\n throw new SingletonError(\n type.slug,\n `Content type \"${type.slug}\" is a singleton and cannot be listed, filtered, or paginated. Read it with single(\"${type.slug}\").get().`,\n )\n }\n}\n\n/** Guards deletion, which would leave a singleton in a state the schema does not describe. */\nexport function assertDeletable(type: KindShape): void {\n if (isSingleton(type)) {\n throw new SingletonError(\n type.slug,\n `Content type \"${type.slug}\" is a singleton and its entry cannot be deleted. Clear its fields instead.`,\n )\n }\n}\n"]}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { FIELD_TYPES, RESERVED_FIELD_KEYS } from './chunk-SOTQ3WP5.js';
|
|
2
|
+
import { assertValidPermissionMatrix } from './chunk-TCFFHTY3.js';
|
|
3
|
+
import { SchemaError } from './chunk-4Z6P3XCE.js';
|
|
4
|
+
|
|
5
|
+
// src/core/define-content-type.ts
|
|
6
|
+
var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
var FIELD_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
8
|
+
var FIELD_TYPE_SET = new Set(FIELD_TYPES);
|
|
9
|
+
var RESERVED_SET = new Set(RESERVED_FIELD_KEYS);
|
|
10
|
+
function assertFieldMap(fields, slug, path) {
|
|
11
|
+
const keys = Object.keys(fields);
|
|
12
|
+
if (keys.length === 0) {
|
|
13
|
+
throw new SchemaError(
|
|
14
|
+
`Content type "${slug}" defines no fields at ${path || "the root"}.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
const at = path ? `${path}.${key}` : key;
|
|
19
|
+
if (!FIELD_KEY_PATTERN.test(key)) {
|
|
20
|
+
throw new SchemaError(
|
|
21
|
+
`Content type "${slug}" has an invalid field key "${at}". A field key must be a valid identifier: letters, digits, and underscores, not starting with a digit.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (!path && RESERVED_SET.has(key)) {
|
|
25
|
+
throw new SchemaError(
|
|
26
|
+
`Content type "${slug}" defines a field named "${key}", which is reserved for entry metadata. Reserved keys: ${RESERVED_FIELD_KEYS.join(", ")}.`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const field = fields[key];
|
|
30
|
+
if (!field) continue;
|
|
31
|
+
if (!FIELD_TYPE_SET.has(field.type)) {
|
|
32
|
+
throw new SchemaError(
|
|
33
|
+
`Field "${at}" of content type "${slug}" has unknown type "${field.type}". Known types: ${FIELD_TYPES.join(", ")}.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (field.type === "select" && field.options.length === 0) {
|
|
37
|
+
throw new SchemaError(
|
|
38
|
+
`Select field "${at}" of content type "${slug}" has no options.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (field.type === "slug" && field.from !== void 0 && !(field.from in fields)) {
|
|
42
|
+
throw new SchemaError(
|
|
43
|
+
`Slug field "${at}" of content type "${slug}" derives from "${field.from}", which is not a field of the same level.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (field.type === "repeater") {
|
|
47
|
+
assertFieldMap(field.fields, slug, at);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function defineContentType(definition) {
|
|
52
|
+
assertValidContentType(definition);
|
|
53
|
+
return definition;
|
|
54
|
+
}
|
|
55
|
+
function assertValidContentType(definition) {
|
|
56
|
+
const { slug, kind, fields, removedFields, titleField, defaultSort } = definition;
|
|
57
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
58
|
+
throw new SchemaError(
|
|
59
|
+
`Content type slug "${slug}" is invalid. Use lowercase letters, digits, and single hyphens, for example "blog-posts".`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (kind !== "collection" && kind !== "singleton") {
|
|
63
|
+
throw new SchemaError(
|
|
64
|
+
`Content type "${slug}" has kind "${String(kind)}". Use "collection" or "singleton".`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
assertFieldMap(fields, slug, "");
|
|
68
|
+
if (removedFields !== void 0) {
|
|
69
|
+
for (const [key, removed] of Object.entries(removedFields)) {
|
|
70
|
+
if (key in fields) {
|
|
71
|
+
throw new SchemaError(
|
|
72
|
+
`Content type "${slug}" lists "${key}" as a removed field and as a live field. Re-adding a removed key must take it out of removedFields.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (!FIELD_KEY_PATTERN.test(key) || RESERVED_SET.has(key)) {
|
|
76
|
+
throw new SchemaError(
|
|
77
|
+
`Content type "${slug}" has an invalid removed field key "${key}".`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (!removed || typeof removed.removedAt !== "string") {
|
|
81
|
+
throw new SchemaError(
|
|
82
|
+
`Removed field "${key}" of content type "${slug}" has no removedAt timestamp.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
assertFieldMap({ ...fields, [key]: removed.field }, slug, "removedFields");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (titleField !== void 0 && !(titleField in fields)) {
|
|
89
|
+
throw new SchemaError(
|
|
90
|
+
`Content type "${slug}" names "${titleField}" as its titleField, but has no such field.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (defaultSort !== void 0 && !(defaultSort.field in fields)) {
|
|
94
|
+
throw new SchemaError(
|
|
95
|
+
`Content type "${slug}" sorts by "${defaultSort.field}", but has no such field.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
assertValidPermissionMatrix(slug, definition.permissions);
|
|
99
|
+
if (kind === "singleton" && defaultSort !== void 0) {
|
|
100
|
+
throw new SchemaError(
|
|
101
|
+
`Content type "${slug}" is a singleton and cannot declare a defaultSort. A singleton has exactly one entry and no list view.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/core/schema-builder.ts
|
|
107
|
+
var RESERVED_TYPE_SLUGS = [
|
|
108
|
+
"schemas",
|
|
109
|
+
"content",
|
|
110
|
+
"entries",
|
|
111
|
+
"media",
|
|
112
|
+
"users",
|
|
113
|
+
"settings"
|
|
114
|
+
];
|
|
115
|
+
var AVAILABLE = { available: true };
|
|
116
|
+
var SLUG_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
117
|
+
function checkTypeSlug(registry, slug) {
|
|
118
|
+
if (!SLUG_PATTERN2.test(slug)) {
|
|
119
|
+
return {
|
|
120
|
+
available: false,
|
|
121
|
+
reason: "invalid",
|
|
122
|
+
message: `"${slug}" is not a valid slug. Use lowercase letters, digits, and single hyphens, for example "blog-posts".`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (RESERVED_TYPE_SLUGS.includes(slug)) {
|
|
126
|
+
return {
|
|
127
|
+
available: false,
|
|
128
|
+
reason: "reserved",
|
|
129
|
+
message: `"${slug}" is reserved by the CMS. Reserved slugs: ${RESERVED_TYPE_SLUGS.join(", ")}.`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const existing = registry.get(slug);
|
|
133
|
+
if (!existing) return AVAILABLE;
|
|
134
|
+
if (existing.source === "code") {
|
|
135
|
+
return {
|
|
136
|
+
available: false,
|
|
137
|
+
reason: "taken-by-code",
|
|
138
|
+
source: "code",
|
|
139
|
+
message: `"${slug}" is already defined in code as "${existing.type.label}". Code-defined types are not listed in the Schema Builder; change the TypeScript definition and deploy, or choose another slug.`
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
available: false,
|
|
144
|
+
reason: "taken-by-database",
|
|
145
|
+
source: "database",
|
|
146
|
+
message: `"${slug}" is already used by the content type "${existing.type.label}", created here in the Schema Builder.`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function assertTypeSlugAvailable(registry, slug) {
|
|
150
|
+
const availability = checkTypeSlug(registry, slug);
|
|
151
|
+
if (!availability.available) {
|
|
152
|
+
throw new SchemaError(availability.message ?? `Slug "${slug}" is not available.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
var INITIAL_FIELD_KEY = "title";
|
|
156
|
+
var INITIAL_FIELD = {
|
|
157
|
+
type: "text",
|
|
158
|
+
label: "Title",
|
|
159
|
+
validation: { required: true }
|
|
160
|
+
};
|
|
161
|
+
function newContentType(input) {
|
|
162
|
+
const label = input.name.trim();
|
|
163
|
+
if (label.length === 0) {
|
|
164
|
+
throw new SchemaError(`Content type "${input.slug}" needs a name.`);
|
|
165
|
+
}
|
|
166
|
+
const type = {
|
|
167
|
+
slug: input.slug,
|
|
168
|
+
kind: input.kind,
|
|
169
|
+
label,
|
|
170
|
+
...input.namePlural !== void 0 && input.namePlural.trim().length > 0 ? { labelPlural: input.namePlural.trim() } : {},
|
|
171
|
+
...input.description !== void 0 && input.description.trim().length > 0 ? { description: input.description.trim() } : {},
|
|
172
|
+
...input.icon !== void 0 && input.icon.length > 0 ? { icon: input.icon } : {},
|
|
173
|
+
fields: { [INITIAL_FIELD_KEY]: INITIAL_FIELD },
|
|
174
|
+
titleField: INITIAL_FIELD_KEY,
|
|
175
|
+
schemaVersion: 1
|
|
176
|
+
};
|
|
177
|
+
assertValidContentType(type);
|
|
178
|
+
return type;
|
|
179
|
+
}
|
|
180
|
+
function createContentType(registry, input) {
|
|
181
|
+
assertTypeSlugAvailable(registry, input.slug);
|
|
182
|
+
return newContentType(input);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { INITIAL_FIELD_KEY, RESERVED_TYPE_SLUGS, assertTypeSlugAvailable, assertValidContentType, checkTypeSlug, createContentType, defineContentType, newContentType };
|
|
186
|
+
//# sourceMappingURL=chunk-ROLKRJWI.js.map
|
|
187
|
+
//# sourceMappingURL=chunk-ROLKRJWI.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/define-content-type.ts","../src/core/schema-builder.ts"],"names":["SLUG_PATTERN"],"mappings":"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","file":"chunk-ROLKRJWI.js","sourcesContent":["import { assertValidPermissionMatrix } from \"../permissions/matrix.js\"\nimport { RESERVED_FIELD_KEYS } from \"../types/content-type.js\"\nimport type { ContentType, RemovedFieldMap } from \"../types/content-type.js\"\nimport { FIELD_TYPES } from \"../types/field.js\"\nimport type { FieldDefinition, FieldMap } from \"../types/field.js\"\n\nimport { SchemaError } from \"./errors.js\"\n\n/** Lowercase, digits, and single hyphens. The same shape the admin routes use. */\nconst SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n/** A field key becomes a TypeScript property and a Firestore field name. */\nconst FIELD_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/\n\nconst FIELD_TYPE_SET: ReadonlySet<string> = new Set(FIELD_TYPES)\nconst RESERVED_SET: ReadonlySet<string> = new Set(RESERVED_FIELD_KEYS)\n\nfunction assertFieldMap(fields: FieldMap, slug: string, path: string): void {\n const keys = Object.keys(fields)\n if (keys.length === 0) {\n throw new SchemaError(\n `Content type \"${slug}\" defines no fields at ${path || \"the root\"}.`,\n )\n }\n\n for (const key of keys) {\n const at = path ? `${path}.${key}` : key\n\n if (!FIELD_KEY_PATTERN.test(key)) {\n throw new SchemaError(\n `Content type \"${slug}\" has an invalid field key \"${at}\". A field key must be a valid identifier: letters, digits, and underscores, not starting with a digit.`,\n )\n }\n\n // Entry metadata owns these keys. Allowing a field to shadow one would make\n // `entry.status` mean two different things depending on the content type.\n if (!path && RESERVED_SET.has(key)) {\n throw new SchemaError(\n `Content type \"${slug}\" defines a field named \"${key}\", which is reserved for entry metadata. Reserved keys: ${RESERVED_FIELD_KEYS.join(\", \")}.`,\n )\n }\n\n const field = fields[key] as FieldDefinition | undefined\n if (!field) continue\n\n if (!FIELD_TYPE_SET.has(field.type)) {\n throw new SchemaError(\n `Field \"${at}\" of content type \"${slug}\" has unknown type \"${field.type}\". Known types: ${FIELD_TYPES.join(\", \")}.`,\n )\n }\n\n if (field.type === \"select\" && field.options.length === 0) {\n throw new SchemaError(\n `Select field \"${at}\" of content type \"${slug}\" has no options.`,\n )\n }\n\n if (field.type === \"slug\" && field.from !== undefined && !(field.from in fields)) {\n throw new SchemaError(\n `Slug field \"${at}\" of content type \"${slug}\" derives from \"${field.from}\", which is not a field of the same level.`,\n )\n }\n\n if (field.type === \"repeater\") {\n assertFieldMap(field.fields, slug, at)\n }\n }\n}\n\n/**\n * Declares a content type and infers its entry shape from its field map.\n *\n * The `const` type parameter is what makes inference work: without it, `required: true`\n * widens to `boolean` and every field would read as nullable, and select options would\n * widen to `string` instead of the union of the declared values.\n *\n * Structural mistakes throw here rather than at first write, so a code-defined type fails\n * at module load and never reaches Firestore.\n */\nexport function defineContentType<const TFields extends FieldMap>(\n definition: ContentType<TFields>,\n): ContentType<TFields> {\n assertValidContentType(definition)\n return definition\n}\n\n/**\n * The structural checks, separated from `defineContentType` so that a database-defined type\n * read out of Firestore goes through exactly the same guards as a code-defined one. The\n * registry calls this; it must never trust a stored definition more than a written one.\n */\nexport function assertValidContentType(definition: {\n slug: string\n kind: unknown\n fields: FieldMap\n removedFields?: RemovedFieldMap | undefined\n titleField?: string | undefined\n defaultSort?: { field: string; direction: \"asc\" | \"desc\" } | undefined\n permissions?: unknown\n}): void {\n const { slug, kind, fields, removedFields, titleField, defaultSort } = definition\n\n if (!SLUG_PATTERN.test(slug)) {\n throw new SchemaError(\n `Content type slug \"${slug}\" is invalid. Use lowercase letters, digits, and single hyphens, for example \"blog-posts\".`,\n )\n }\n\n if (kind !== \"collection\" && kind !== \"singleton\") {\n throw new SchemaError(\n `Content type \"${slug}\" has kind \"${String(kind)}\". Use \"collection\" or \"singleton\".`,\n )\n }\n\n assertFieldMap(fields, slug, \"\")\n\n if (removedFields !== undefined) {\n for (const [key, removed] of Object.entries(removedFields)) {\n // A key cannot be live and removed at once. If it could, every consumer would have to\n // decide which of the two definitions wins, and the answer would differ per consumer.\n if (key in fields) {\n throw new SchemaError(\n `Content type \"${slug}\" lists \"${key}\" as a removed field and as a live field. Re-adding a removed key must take it out of removedFields.`,\n )\n }\n\n if (!FIELD_KEY_PATTERN.test(key) || RESERVED_SET.has(key)) {\n throw new SchemaError(\n `Content type \"${slug}\" has an invalid removed field key \"${key}\".`,\n )\n }\n\n if (!removed || typeof removed.removedAt !== \"string\") {\n throw new SchemaError(\n `Removed field \"${key}\" of content type \"${slug}\" has no removedAt timestamp.`,\n )\n }\n\n // The parked definition goes through the same field checks a live one does, so a\n // corrupted record surfaces here rather than when it is offered back to an editor.\n // Merged with the live fields so a parked `slug` field can still resolve its\n // `from` sibling. The live half was validated above and cannot fail twice.\n assertFieldMap({ ...fields, [key]: removed.field }, slug, \"removedFields\")\n }\n }\n\n if (titleField !== undefined && !(titleField in fields)) {\n throw new SchemaError(\n `Content type \"${slug}\" names \"${titleField}\" as its titleField, but has no such field.`,\n )\n }\n\n if (defaultSort !== undefined && !(defaultSort.field in fields)) {\n throw new SchemaError(\n `Content type \"${slug}\" sorts by \"${defaultSort.field}\", but has no such field.`,\n )\n }\n\n // The matrix is stored on the content type and copied onto `content/{slug}`, where the\n // security rules read it. A malformed one would be read by a rule as a list that never\n // matches, which denies without ever saying why, so it is refused at the write instead.\n assertValidPermissionMatrix(slug, definition.permissions)\n\n // A singleton has no list view, so list affordances would be dead configuration.\n if (kind === \"singleton\" && defaultSort !== undefined) {\n throw new SchemaError(\n `Content type \"${slug}\" is a singleton and cannot declare a defaultSort. A singleton has exactly one entry and no list view.`,\n )\n }\n}\n","import type {\n ContentTypeKind,\n ContentTypeLike,\n ContentTypeSource,\n} from \"../types/content-type.js\"\nimport type { FieldDefinition } from \"../types/field.js\"\n\nimport { assertValidContentType } from \"./define-content-type.js\"\nimport { SchemaError } from \"./errors.js\"\nimport type { Registry } from \"./registry.js\"\n\n/**\n * Creating a database-defined content type: the domain half.\n *\n * Everything here is pure. The Schema Builder screen calls it to decide what to enable and\n * what to say; the adapter calls the same functions again before it writes, so a request\n * that skipped the screen is answered identically.\n */\n\n/**\n * Slugs a content type may not take.\n *\n * Under the layout in .docs/decisions/2026-09-18-firestore-layout.md a content type slug\n * never becomes a top-level collection, so none of these can actually collide today. They\n * are reserved anyway: the names are the CMS's own vocabulary, a type called `users` or\n * `settings` would read as the admin's own section in every sidebar and URL, and a future\n * layout change must not be blocked by a customer having taken the name.\n *\n * `src/firebase/paths.ts` re-exports this as `RESERVED_COLLECTIONS`, so the list is written\n * once and the storage layer and the Schema Builder cannot drift apart.\n */\nexport const RESERVED_TYPE_SLUGS: readonly string[] = [\n \"schemas\",\n \"content\",\n \"entries\",\n \"media\",\n \"users\",\n \"settings\",\n]\n\nexport type SlugRejection = \"invalid\" | \"reserved\" | \"taken-by-code\" | \"taken-by-database\"\n\nexport interface SlugAvailability {\n available: boolean\n reason?: SlugRejection\n /** Ready to print. Names the source, because \"that slug is taken\" is a dead end. */\n message?: string\n /** Which source holds the slug already. Absent for `invalid` and `reserved`. */\n source?: ContentTypeSource\n}\n\nconst AVAILABLE: SlugAvailability = { available: true }\n\n/** Same shape the routes and `assertValidContentType` use. Kept in step by a test. */\nconst SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\n/**\n * Is this slug free for a new database-defined content type?\n *\n * Checked against **both** sources. A slug defined in code is not available even though\n * `schemas/` is empty and the Schema Builder's own list shows nothing, which is precisely\n * why the message has to say where the collision is: an editor looking at an empty list and\n * being told the name is taken has nowhere to go.\n */\nexport function checkTypeSlug(registry: Registry, slug: string): SlugAvailability {\n if (!SLUG_PATTERN.test(slug)) {\n return {\n available: false,\n reason: \"invalid\",\n message: `\"${slug}\" is not a valid slug. Use lowercase letters, digits, and single hyphens, for example \"blog-posts\".`,\n }\n }\n\n if (RESERVED_TYPE_SLUGS.includes(slug)) {\n return {\n available: false,\n reason: \"reserved\",\n message: `\"${slug}\" is reserved by the CMS. Reserved slugs: ${RESERVED_TYPE_SLUGS.join(\", \")}.`,\n }\n }\n\n const existing = registry.get(slug)\n if (!existing) return AVAILABLE\n\n if (existing.source === \"code\") {\n return {\n available: false,\n reason: \"taken-by-code\",\n source: \"code\",\n message: `\"${slug}\" is already defined in code as \"${existing.type.label}\". Code-defined types are not listed in the Schema Builder; change the TypeScript definition and deploy, or choose another slug.`,\n }\n }\n\n return {\n available: false,\n reason: \"taken-by-database\",\n source: \"database\",\n message: `\"${slug}\" is already used by the content type \"${existing.type.label}\", created here in the Schema Builder.`,\n }\n}\n\n/** Throws the message `checkTypeSlug` would have printed. The adapter's create guard. */\nexport function assertTypeSlugAvailable(registry: Registry, slug: string): void {\n const availability = checkTypeSlug(registry, slug)\n if (!availability.available) {\n throw new SchemaError(availability.message ?? `Slug \"${slug}\" is not available.`)\n }\n}\n\nexport interface NewContentTypeInput {\n name: string\n slug: string\n kind: ContentTypeKind\n /** Lucide icon name from the vocabulary in .docs/styleguide/05-icons.md. */\n icon?: string\n namePlural?: string\n description?: string\n}\n\n/**\n * The single field every new content type starts with.\n *\n * A content type with no fields is invalid, so creation has to seed one. A required title\n * is the one field that is right for every content type: it is what the list view, the\n * relation picker and the entry header all print, and an editor who wanted something else\n * renames it rather than starting from an unusable empty type.\n */\nexport const INITIAL_FIELD_KEY = \"title\"\n\nconst INITIAL_FIELD: FieldDefinition = {\n type: \"text\",\n label: \"Title\",\n validation: { required: true },\n}\n\n/**\n * Builds the content type document a create writes, stamped at version 1.\n *\n * Validated here rather than at the write, so the Schema Builder fails on the form instead\n * of on the round trip.\n */\nexport function newContentType(input: NewContentTypeInput): ContentTypeLike {\n const label = input.name.trim()\n\n if (label.length === 0) {\n throw new SchemaError(`Content type \"${input.slug}\" needs a name.`)\n }\n\n const type: ContentTypeLike = {\n slug: input.slug,\n kind: input.kind,\n label,\n ...(input.namePlural !== undefined && input.namePlural.trim().length > 0\n ? { labelPlural: input.namePlural.trim() }\n : {}),\n ...(input.description !== undefined && input.description.trim().length > 0\n ? { description: input.description.trim() }\n : {}),\n ...(input.icon !== undefined && input.icon.length > 0 ? { icon: input.icon } : {}),\n fields: { [INITIAL_FIELD_KEY]: INITIAL_FIELD },\n titleField: INITIAL_FIELD_KEY,\n schemaVersion: 1,\n }\n\n assertValidContentType(type)\n return type\n}\n\n/**\n * The create path in one call: collision check against both sources, then the draft.\n *\n * The adapter runs this again before writing, which is what makes a bypassed UI change\n * nothing.\n */\nexport function createContentType(\n registry: Registry,\n input: NewContentTypeInput,\n): ContentTypeLike {\n assertTypeSlugAvailable(registry, input.slug)\n return newContentType(input)\n}\n"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/types/content-type.ts
|
|
2
|
+
var RESERVED_FIELD_KEYS = [
|
|
3
|
+
"id",
|
|
4
|
+
"status",
|
|
5
|
+
"createdAt",
|
|
6
|
+
"createdBy",
|
|
7
|
+
"updatedAt",
|
|
8
|
+
"updatedBy",
|
|
9
|
+
"publishedAt",
|
|
10
|
+
"schemaVersion"
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// src/types/field.ts
|
|
14
|
+
var FIELD_TYPES = [
|
|
15
|
+
"text",
|
|
16
|
+
"textarea",
|
|
17
|
+
"number",
|
|
18
|
+
"boolean",
|
|
19
|
+
"select",
|
|
20
|
+
"date",
|
|
21
|
+
"slug",
|
|
22
|
+
"richtext",
|
|
23
|
+
"image",
|
|
24
|
+
"file",
|
|
25
|
+
"relation",
|
|
26
|
+
"repeater"
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export { FIELD_TYPES, RESERVED_FIELD_KEYS };
|
|
30
|
+
//# sourceMappingURL=chunk-SOTQ3WP5.js.map
|
|
31
|
+
//# sourceMappingURL=chunk-SOTQ3WP5.js.map
|