@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/types/content-type.ts","../src/types/field.ts"],"names":[],"mappings":";AAiHO,IAAM,mBAAA,GAAsB;AAAA,EACjC,IAAA;AAAA,EACA,QAAA;AAAA,EACA,WAAA;AAAA,EACA,WAAA;AAAA,EACA,WAAA;AAAA,EACA,WAAA;AAAA,EACA,aAAA;AAAA,EACA;AACF;;;ACxHO,IAAM,WAAA,GAAc;AAAA,EACzB,MAAA;AAAA,EACA,UAAA;AAAA,EACA,QAAA;AAAA,EACA,SAAA;AAAA,EACA,QAAA;AAAA,EACA,MAAA;AAAA,EACA,MAAA;AAAA,EACA,UAAA;AAAA,EACA,OAAA;AAAA,EACA,MAAA;AAAA,EACA,UAAA;AAAA,EACA;AACF","file":"chunk-SOTQ3WP5.js","sourcesContent":["import type { FieldDefinition, FieldInputValues, FieldMap, FieldValues } from \"./field.js\"\nimport type { PermissionMatrix } from \"./auth.js\"\n\nexport type ContentTypeKind = \"collection\" | \"singleton\"\n\n/**\n * A field that was removed from a content type while its stored values were kept.\n *\n * Removal is a state, not an absence. The definition is parked here instead of being\n * deleted, so the admin can name the field in a warning, offer it back unchanged, and so a\n * later purge knows what it is about to destroy. Nothing downstream of the registry reads\n * this map: forms, validation, list columns and the SDK all read `fields`, which is why a\n * removed field is inert everywhere without a single consumer having to filter it out.\n */\nexport interface RemovedField {\n /** Exactly the definition the field had at the moment it was removed. */\n field: FieldDefinition\n /** ISO 8601. A plain string, because a stored content type is plain JSON. */\n removedAt: string\n /** uid of whoever removed it. */\n removedBy: string\n}\n\nexport type RemovedFieldMap = { readonly [key: string]: RemovedField }\n\n/** Where a content type came from. Code always wins a slug conflict. See the registry. */\nexport type ContentTypeSource = \"code\" | \"database\"\n\nexport interface ContentType<TFields extends FieldMap = FieldMap> {\n slug: string\n kind: ContentTypeKind\n label: string\n /** Plural label for a collection. Defaults to `label` when absent. */\n labelPlural?: string\n description?: string\n /** Lucide icon name from the fixed vocabulary in .docs/styleguide/05-icons.md. */\n icon?: string\n fields: TFields\n /**\n * The order the fields are shown in, as a list of field keys.\n *\n * It exists because `fields` is a map, and object key order is not a place to store\n * meaning: it is not a documented Firestore guarantee, Firestore's own ordering of map\n * values compares keys sorted, and any re-serialization on the way through may change it.\n * An array has one order and keeps it. A code-defined type never round trips and keeps its\n * literal's order, so this key is absent there and the object order is used; the Schema\n * Builder writes it on every save, which is what makes a reorder survive a reload.\n *\n * It is a hint, not a second source of truth. An unknown key in it is ignored and a field\n * missing from it is appended, so a `fieldOrder` that has drifted from `fields` still\n * renders every field exactly once.\n */\n fieldOrder?: readonly string[]\n /**\n * Fields removed from `fields` whose stored values are still on the entries. Absent on a\n * type that has never had a field removed.\n */\n removedFields?: RemovedFieldMap\n /**\n * A locked type cannot be edited or deleted through the Schema Builder, and the adapter\n * rejects schema writes against it even when the UI is bypassed. Only meaningful for a\n * code-defined type; a database-defined type is never locked.\n */\n locked?: boolean\n /** Field key shown as the entry's title in lists and pickers. */\n titleField?: keyof TFields & string\n /** Default sort for list views, as a field key with a direction. */\n defaultSort?: { field: keyof TFields & string; direction: \"asc\" | \"desc\" }\n permissions?: PermissionMatrix\n /** Bumped when the stored shape changes. Read by the M10 migration runner. */\n schemaVersion?: number\n}\n\n/**\n * A content type with its field map erased to `FieldMap`.\n *\n * `ContentType<TFields>` is invariant in `TFields`, so a concrete content type produced by\n * `defineContentType` is not assignable to `ContentType<FieldMap>`. Everything that handles\n * content types generically (the registry, the adapter, the Schema Builder) takes this\n * structural shape instead, which a concrete content type does satisfy. Authoring and\n * inference keep the generic form.\n */\nexport interface ContentTypeLike {\n slug: string\n kind: ContentTypeKind\n label: string\n labelPlural?: string\n description?: string\n icon?: string\n fields: FieldMap\n /**\n * The order the fields are shown in, as a list of field keys.\n *\n * It exists because `fields` is a map and a map does not keep an order once it has been\n * through Firestore: a stored document returns its map keys sorted, not in the order they\n * were written. A code-defined type never round trips and keeps its literal's order, so\n * this key is absent there and the object order is used; the Schema Builder writes it on\n * every save, which is what makes a reorder survive a reload.\n *\n * It is a hint, not a second source of truth. An unknown key in it is ignored and a field\n * missing from it is appended, so a `fieldOrder` that has drifted from `fields` still\n * renders every field exactly once.\n */\n fieldOrder?: readonly string[]\n removedFields?: RemovedFieldMap\n locked?: boolean\n titleField?: string\n defaultSort?: { field: string; direction: \"asc\" | \"desc\" }\n permissions?: PermissionMatrix\n schemaVersion?: number\n}\n\n/** Field keys the entry metadata already owns; a content type may not redefine them. */\nexport const RESERVED_FIELD_KEYS = [\n \"id\",\n \"status\",\n \"createdAt\",\n \"createdBy\",\n \"updatedAt\",\n \"updatedBy\",\n \"publishedAt\",\n \"schemaVersion\",\n] as const\n\nexport type ReservedFieldKey = (typeof RESERVED_FIELD_KEYS)[number]\n\nexport type ContentTypeValues<TType extends { fields: FieldMap }> = FieldValues<\n TType[\"fields\"]\n>\n\nexport type ContentTypeInputValues<TType extends { fields: FieldMap }> = FieldInputValues<\n TType[\"fields\"]\n>\n","import type { MediaReference } from \"./media.js\"\n\nexport const FIELD_TYPES = [\n \"text\",\n \"textarea\",\n \"number\",\n \"boolean\",\n \"select\",\n \"date\",\n \"slug\",\n \"richtext\",\n \"image\",\n \"file\",\n \"relation\",\n \"repeater\",\n] as const\n\nexport type FieldType = (typeof FIELD_TYPES)[number]\n\n/**\n * The full rule vocabulary. Every rule compiles to the same runtime validator, which the\n * admin form and the adapter both run, so a rule can never be enforced in one place only.\n */\nexport interface ValidationRules {\n required?: boolean\n minLength?: number\n maxLength?: number\n min?: number\n max?: number\n /** Serialized as a string, not a RegExp, because rules travel through Firestore. */\n pattern?: string\n /** Rejects a value already held by another entry of the same content type. */\n unique?: boolean\n}\n\ninterface FieldCommon {\n label: string\n description?: string\n placeholder?: string\n validation?: ValidationRules\n /** Hidden fields still store and validate; they are simply not rendered in the form. */\n hidden?: boolean\n readonly?: boolean\n}\n\nexport interface TextField extends FieldCommon {\n type: \"text\"\n defaultValue?: string\n}\n\nexport interface TextareaField extends FieldCommon {\n type: \"textarea\"\n rows?: number\n defaultValue?: string\n}\n\nexport interface NumberField extends FieldCommon {\n type: \"number\"\n step?: number\n defaultValue?: number\n}\n\nexport interface BooleanField extends FieldCommon {\n type: \"boolean\"\n defaultValue?: boolean\n}\n\nexport interface SelectOption<TValue extends string = string> {\n value: TValue\n label: string\n}\n\nexport interface SelectField<TValue extends string = string> extends FieldCommon {\n type: \"select\"\n options: readonly SelectOption<TValue>[]\n multiple?: boolean\n defaultValue?: TValue\n}\n\nexport interface DateField extends FieldCommon {\n type: \"date\"\n /** `date` stores `YYYY-MM-DD`; `datetime` stores a full ISO 8601 instant. */\n precision?: \"date\" | \"datetime\"\n}\n\nexport interface SlugField extends FieldCommon {\n type: \"slug\"\n /** Field key the slug is derived from until the editor overrides it. */\n from?: string\n}\n\n/**\n * Rich text is stored as a serialized document, not as an HTML string, so the editor choice\n * (settled in M5) stays an implementation detail and the value can be re-rendered rather\n * than re-parsed.\n */\nexport interface RichTextValue {\n format: \"husk-richtext-v1\"\n content: unknown\n}\n\nexport interface RichtextField extends FieldCommon {\n type: \"richtext\"\n /** Marks and blocks the toolbar offers. Absent means the default set. */\n features?: readonly string[]\n}\n\nexport interface ImageField extends FieldCommon {\n type: \"image\"\n accept?: readonly string[]\n}\n\nexport interface FileField extends FieldCommon {\n type: \"file\"\n accept?: readonly string[]\n}\n\nexport interface RelationField<TTo extends string = string> extends FieldCommon {\n type: \"relation\"\n /** Slug of the referenced content type. */\n to: TTo\n many?: boolean\n}\n\nexport interface RepeaterField<TFields extends FieldMap = FieldMap> extends FieldCommon {\n type: \"repeater\"\n fields: TFields\n min?: number\n max?: number\n}\n\nexport type FieldDefinition =\n | TextField\n | TextareaField\n | NumberField\n | BooleanField\n | SelectField\n | DateField\n | SlugField\n | RichtextField\n | ImageField\n | FileField\n | RelationField\n | RepeaterField\n\nexport type FieldMap = { readonly [key: string]: FieldDefinition }\n\n/** The value one field definition produces, before required-ness is applied. */\nexport type FieldValue<TField extends FieldDefinition> = TField extends\n TextField | TextareaField | SlugField\n ? string\n : TField extends NumberField\n ? number\n : TField extends BooleanField\n ? boolean\n : TField extends DateField\n ? string\n : TField extends SelectField<infer TOption>\n ? TField extends { multiple: true }\n ? TOption[]\n : TOption\n : TField extends RichtextField\n ? RichTextValue\n : TField extends ImageField | FileField\n ? MediaReference\n : TField extends RelationField\n ? TField extends { many: true }\n ? string[]\n : string\n : TField extends RepeaterField<infer TFields>\n ? FieldValues<TFields>[]\n : never\n\ntype IsRequired<TField extends FieldDefinition> = TField extends {\n validation: { required: true }\n}\n ? true\n : false\n\n/**\n * Read shape: every key is present. An optional field reads as `null` rather than\n * `undefined`, so a consumer never has to distinguish \"absent\" from \"empty\".\n */\nexport type FieldValues<TFields extends FieldMap> = {\n [K in keyof TFields]: IsRequired<TFields[K]> extends true\n ? FieldValue<TFields[K]>\n : FieldValue<TFields[K]> | null\n}\n\n/** Write shape: optional fields may be omitted entirely. */\nexport type FieldInputValues<TFields extends FieldMap> = {\n [K in keyof TFields as IsRequired<TFields[K]> extends true ? K : never]: FieldValue<\n TFields[K]\n >\n} & {\n [K in keyof TFields as IsRequired<TFields[K]> extends true ? never : K]?: FieldValue<\n TFields[K]\n > | null\n}\n"]}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { PERMISSION_DEFAULTS, resolveDecision } from './chunk-NPDBQBLW.js';
|
|
2
|
+
import { ENTRY_ACTIONS, ROLES } from './chunk-TS7Y7GEZ.js';
|
|
3
|
+
import { SchemaError } from './chunk-4Z6P3XCE.js';
|
|
4
|
+
|
|
5
|
+
// src/permissions/matrix.ts
|
|
6
|
+
var MATRIX_ACTIONS = ENTRY_ACTIONS;
|
|
7
|
+
var MATRIX_LIMITS = {
|
|
8
|
+
admin: "An admin is never restricted by a matrix. Removing every other role from an action leaves it admin-only; there is no way to lock an admin out of their own project.",
|
|
9
|
+
ownership: "An author may only ever update or delete entries they created. Ticking an author here grants the action on their own entries, never on somebody else's.",
|
|
10
|
+
narrowing: "A matrix can only take permissions away. Ticking a role that its own role does not grant changes nothing, and the row says so.",
|
|
11
|
+
publicRead: "Denying read hides entries from the admin, not from the public website. A published entry is readable by anyone with its address by design; unpublish it to take it down."
|
|
12
|
+
};
|
|
13
|
+
function isEntryActionKey(value) {
|
|
14
|
+
return ENTRY_ACTIONS.includes(value);
|
|
15
|
+
}
|
|
16
|
+
function isRole(value) {
|
|
17
|
+
return typeof value === "string" && ROLES.includes(value);
|
|
18
|
+
}
|
|
19
|
+
function assertValidPermissionMatrix(slug, matrix) {
|
|
20
|
+
if (matrix === void 0) return;
|
|
21
|
+
if (typeof matrix !== "object" || matrix === null || Array.isArray(matrix)) {
|
|
22
|
+
throw new SchemaError(
|
|
23
|
+
`Content type "${slug}" has a permissions matrix that is not an object.`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
for (const [action, roles] of Object.entries(matrix)) {
|
|
27
|
+
if (!isEntryActionKey(action)) {
|
|
28
|
+
throw new SchemaError(
|
|
29
|
+
`Content type "${slug}" has a permissions entry for "${action}", which is not an entry action. Use one of: ${ENTRY_ACTIONS.join(", ")}.`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(roles)) {
|
|
33
|
+
throw new SchemaError(
|
|
34
|
+
`Content type "${slug}" lists permissions for "${action}" as something other than a list of roles.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const seen = /* @__PURE__ */ new Set();
|
|
38
|
+
for (const role of roles) {
|
|
39
|
+
if (!isRole(role)) {
|
|
40
|
+
throw new SchemaError(
|
|
41
|
+
`Content type "${slug}" grants "${action}" to "${String(role)}", which is not a role. Known roles: ${ROLES.join(", ")}.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (seen.has(role)) {
|
|
45
|
+
throw new SchemaError(
|
|
46
|
+
`Content type "${slug}" lists "${role}" twice under "${action}".`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
seen.add(role);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function normalizePermissionMatrix(matrix) {
|
|
54
|
+
if (matrix === void 0) return void 0;
|
|
55
|
+
const normalized = {};
|
|
56
|
+
for (const action of MATRIX_ACTIONS) {
|
|
57
|
+
const roles = matrix[action];
|
|
58
|
+
if (roles === void 0) continue;
|
|
59
|
+
const kept = ROLES.filter((role) => roles.includes(role));
|
|
60
|
+
if (sameRoles(kept, defaultRoles(action))) continue;
|
|
61
|
+
normalized[action] = kept;
|
|
62
|
+
}
|
|
63
|
+
return Object.keys(normalized).length === 0 ? void 0 : normalized;
|
|
64
|
+
}
|
|
65
|
+
function defaultRoles(action) {
|
|
66
|
+
return ROLES.filter((role) => PERMISSION_DEFAULTS[role][action] !== "deny");
|
|
67
|
+
}
|
|
68
|
+
function sameRoles(left, right) {
|
|
69
|
+
return left.length === right.length && left.every((role) => right.includes(role));
|
|
70
|
+
}
|
|
71
|
+
function matrixDecision(type, role, action) {
|
|
72
|
+
return resolveDecision(role, action, type);
|
|
73
|
+
}
|
|
74
|
+
function permissionMatrixRows(type) {
|
|
75
|
+
return MATRIX_ACTIONS.map((action) => ({
|
|
76
|
+
action,
|
|
77
|
+
cells: ROLES.map((role) => {
|
|
78
|
+
const listed = type?.permissions?.[action];
|
|
79
|
+
return {
|
|
80
|
+
role,
|
|
81
|
+
listed: listed === void 0 ? true : listed.includes(role),
|
|
82
|
+
decision: matrixDecision(type, role, action),
|
|
83
|
+
fixed: role === "admin" || PERMISSION_DEFAULTS[role][action] === "deny"
|
|
84
|
+
};
|
|
85
|
+
})
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
function withMatrixRole(matrix, action, role, listed) {
|
|
89
|
+
if (role === "admin" || PERMISSION_DEFAULTS[role][action] === "deny") return matrix;
|
|
90
|
+
const current = matrix?.[action] ?? defaultRoles(action);
|
|
91
|
+
const next = listed ? ROLES.filter((candidate) => candidate === role || current.includes(candidate)) : current.filter((candidate) => candidate !== role);
|
|
92
|
+
return normalizePermissionMatrix({ ...matrix, [action]: next });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { MATRIX_ACTIONS, MATRIX_LIMITS, assertValidPermissionMatrix, defaultRoles, matrixDecision, normalizePermissionMatrix, permissionMatrixRows, withMatrixRole };
|
|
96
|
+
//# sourceMappingURL=chunk-TCFFHTY3.js.map
|
|
97
|
+
//# sourceMappingURL=chunk-TCFFHTY3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/permissions/matrix.ts"],"names":[],"mappings":"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","file":"chunk-TCFFHTY3.js","sourcesContent":["import { SchemaError } from \"../core/errors.js\"\nimport { ENTRY_ACTIONS, ROLES } from \"../types/auth.js\"\nimport type { EntryAction, PermissionMatrix, Role } from \"../types/auth.js\"\nimport type { ContentTypeLike } from \"../types/content-type.js\"\n\nimport { PERMISSION_DEFAULTS, resolveDecision } from \"./can.js\"\nimport type { Decision } from \"./can.js\"\n\n/**\n * The per-content-type permission matrix: the editable half of the truth table.\n *\n * `can()` is the resolver. This module is everything around it that is not a decision: what\n * a stored matrix is allowed to look like, what one means once the role defaults are\n * applied, and how an editing screen turns a checkbox into the next matrix. It is here\n * rather than in `can.ts` so the resolver stays a pure lookup that a render can call in a\n * loop.\n *\n * The one rule that governs the whole module: **a matrix narrows, it never widens.** The\n * effective answer is the intersection of the role default and the matrix, so listing\n * `author` under `delete` does not give an author a delete. That is not a simplification\n * for its own sake. The security rules enforce the role defaults directly from the token,\n * and only consult the matrix to narrow further, because widening in the rules would mean\n * trusting a document to grant a privilege. A matrix that could widen would therefore be\n * accepted by this resolver and refused by the database, which is the one thing a\n * permission system must never do.\n */\n\n/** Every action a matrix can speak about. An admin action is never per content type. */\nexport const MATRIX_ACTIONS: readonly EntryAction[] = ENTRY_ACTIONS\n\n/** What a role may do with an action once the defaults and the matrix are both applied. */\nexport type MatrixDecision = Decision\n\n/**\n * The limitations the Developer area has to state rather than imply.\n *\n * A screen that draws a checkbox for something no layer enforces is worse than a screen\n * that omits it: it reads as a guarantee. These are the exact gaps, in the words the\n * screen should use.\n */\nexport const MATRIX_LIMITS = {\n admin:\n \"An admin is never restricted by a matrix. Removing every other role from an action leaves it admin-only; there is no way to lock an admin out of their own project.\",\n ownership:\n \"An author may only ever update or delete entries they created. Ticking an author here grants the action on their own entries, never on somebody else's.\",\n narrowing:\n \"A matrix can only take permissions away. Ticking a role that its own role does not grant changes nothing, and the row says so.\",\n publicRead:\n \"Denying read hides entries from the admin, not from the public website. A published entry is readable by anyone with its address by design; unpublish it to take it down.\",\n} as const\n\nfunction isEntryActionKey(value: string): value is EntryAction {\n return (ENTRY_ACTIONS as readonly string[]).includes(value)\n}\n\nfunction isRole(value: unknown): value is Role {\n return typeof value === \"string\" && (ROLES as readonly string[]).includes(value)\n}\n\n/**\n * Refuses a malformed matrix before it is stored.\n *\n * Called from `assertValidContentType`, so a database-defined type read back out of\n * Firestore goes through exactly the same check as a code-defined one. A matrix that named\n * an unknown role would be read by the security rules as a list that simply never matches,\n * which denies silently; better to refuse the write than to ship a rule nobody can explain.\n */\nexport function assertValidPermissionMatrix(slug: string, matrix: unknown): void {\n if (matrix === undefined) return\n\n if (typeof matrix !== \"object\" || matrix === null || Array.isArray(matrix)) {\n throw new SchemaError(\n `Content type \"${slug}\" has a permissions matrix that is not an object.`,\n )\n }\n\n for (const [action, roles] of Object.entries(matrix as Record<string, unknown>)) {\n if (!isEntryActionKey(action)) {\n throw new SchemaError(\n `Content type \"${slug}\" has a permissions entry for \"${action}\", which is not an entry action. Use one of: ${ENTRY_ACTIONS.join(\", \")}.`,\n )\n }\n\n if (!Array.isArray(roles)) {\n throw new SchemaError(\n `Content type \"${slug}\" lists permissions for \"${action}\" as something other than a list of roles.`,\n )\n }\n\n const seen = new Set<string>()\n for (const role of roles as readonly unknown[]) {\n if (!isRole(role)) {\n throw new SchemaError(\n `Content type \"${slug}\" grants \"${action}\" to \"${String(role)}\", which is not a role. Known roles: ${ROLES.join(\", \")}.`,\n )\n }\n if (seen.has(role)) {\n throw new SchemaError(\n `Content type \"${slug}\" lists \"${role}\" twice under \"${action}\".`,\n )\n }\n seen.add(role)\n }\n }\n}\n\n/**\n * The stored form: known actions only, roles deduplicated and in a fixed order.\n *\n * Fixed order because the matrix is written to Firestore and read back by the Schema\n * Builder, and a list whose order changes on every save shows a diff where nothing changed.\n * An action whose list matches the roles the defaults already allow is dropped, so an\n * untouched matrix is stored as absent rather than as a copy of the defaults that would\n * silently stop tracking them.\n */\nexport function normalizePermissionMatrix(\n matrix: PermissionMatrix | undefined,\n): PermissionMatrix | undefined {\n if (matrix === undefined) return undefined\n\n const normalized: { -readonly [K in EntryAction]?: readonly Role[] } = {}\n\n for (const action of MATRIX_ACTIONS) {\n const roles = matrix[action]\n if (roles === undefined) continue\n\n const kept = ROLES.filter((role) => roles.includes(role))\n if (sameRoles(kept, defaultRoles(action))) continue\n\n normalized[action] = kept\n }\n\n return Object.keys(normalized).length === 0 ? undefined : normalized\n}\n\n/** The roles whose own defaults allow an action at all, ownership aside. */\nexport function defaultRoles(action: EntryAction): readonly Role[] {\n return ROLES.filter((role) => PERMISSION_DEFAULTS[role][action] !== \"deny\")\n}\n\nfunction sameRoles(left: readonly Role[], right: readonly Role[]): boolean {\n return left.length === right.length && left.every((role) => right.includes(role))\n}\n\n/**\n * The effective decision for one role and one action on one content type.\n *\n * Exactly what `can()` resolves, minus the entry lookup, so a screen can label a row\n * without inventing a user. Keep the two in step: a test drives both over the same inputs.\n */\nexport function matrixDecision(\n type: Pick<ContentTypeLike, \"permissions\"> | undefined,\n role: Role,\n action: EntryAction,\n): MatrixDecision {\n // Delegated rather than reimplemented. The screen must label a cell with the answer the\n // adapter and the rules will give, not with a second opinion that happens to agree today.\n return resolveDecision(role, action, type)\n}\n\n/** One role's cell in the Developer area's table. */\nexport interface MatrixCell {\n role: Role\n /** Whether the checkbox is ticked: the role is listed, or the matrix is silent. */\n listed: boolean\n /** What the three layers will actually do. */\n decision: MatrixDecision\n /**\n * True when the checkbox cannot change the outcome: an admin, who is never restricted,\n * or a role whose own defaults deny the action regardless of the matrix. The screen\n * disables the control and says why rather than offering a lie.\n */\n fixed: boolean\n}\n\nexport interface MatrixRow {\n action: EntryAction\n cells: readonly MatrixCell[]\n}\n\n/** The whole table, in a fixed order, ready to render. */\nexport function permissionMatrixRows(\n type: Pick<ContentTypeLike, \"permissions\"> | undefined,\n): readonly MatrixRow[] {\n return MATRIX_ACTIONS.map((action) => ({\n action,\n cells: ROLES.map((role) => {\n const listed = type?.permissions?.[action]\n return {\n role,\n listed: listed === undefined ? true : listed.includes(role),\n decision: matrixDecision(type, role, action),\n fixed: role === \"admin\" || PERMISSION_DEFAULTS[role][action] === \"deny\",\n }\n }),\n }))\n}\n\n/**\n * The next matrix after one checkbox is toggled.\n *\n * Unticking a role while the matrix is silent about the action has to materialize the\n * action first, from the defaults rather than from nothing: an empty list means \"nobody but\n * an admin\", and a screen that produced one from a single untick would revoke two roles for\n * one click. Toggling a fixed cell returns the matrix unchanged, so a UI bug cannot store a\n * row that claims something no layer will do.\n */\nexport function withMatrixRole(\n matrix: PermissionMatrix | undefined,\n action: EntryAction,\n role: Role,\n listed: boolean,\n): PermissionMatrix | undefined {\n if (role === \"admin\" || PERMISSION_DEFAULTS[role][action] === \"deny\") return matrix\n\n const current = matrix?.[action] ?? defaultRoles(action)\n const next = listed\n ? ROLES.filter((candidate) => candidate === role || current.includes(candidate))\n : current.filter((candidate) => candidate !== role)\n\n return normalizePermissionMatrix({ ...matrix, [action]: next })\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types/auth.ts"],"names":[],"mappings":";AAAO,IAAM,KAAA,GAAQ,CAAC,OAAA,EAAS,QAAA,EAAU,QAAQ;AAa1C,IAAM,aAAA,GAAgB,CAAC,MAAA,EAAQ,QAAA,EAAU,UAAU,QAAQ","file":"chunk-TS7Y7GEZ.js","sourcesContent":["export const ROLES = [\"admin\", \"editor\", \"author\"] as const\n\nexport type Role = (typeof ROLES)[number]\n\n/** Actions performed on the entries of one content type. */\nexport type EntryAction = \"read\" | \"create\" | \"update\" | \"delete\"\n\n/** Actions that are not scoped to a content type. */\nexport type AdminAction =\n \"manage-schema\" | \"manage-users\" | \"manage-settings\" | \"manage-media\"\n\nexport type Action = EntryAction | AdminAction\n\nexport const ENTRY_ACTIONS = [\"read\", \"create\", \"update\", \"delete\"] as const\n\n/**\n * Who may do what with one content type. A missing action falls back to the role defaults\n * in `can()`, so an empty matrix means \"use the defaults\", not \"deny everything\".\n */\nexport type PermissionMatrix = Partial<Record<EntryAction, readonly Role[]>>\n\n/** One resolved permission question, as the UI, the adapter, and the tests all phrase it. */\nexport interface Permission {\n action: Action\n /** Content type slug. Absent for an `AdminAction`. */\n contentType?: string\n}\n\nexport interface AuthUser {\n uid: string\n email: string\n displayName: string | null\n role: Role\n disabled: boolean\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-VLGR5PZX.js"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { SchemaError } from './chunk-4Z6P3XCE.js';
|
|
2
|
+
|
|
3
|
+
// src/schema/field-type.ts
|
|
4
|
+
function registerFieldTypes(specs) {
|
|
5
|
+
const byName = /* @__PURE__ */ new Map();
|
|
6
|
+
for (const spec of specs) {
|
|
7
|
+
if (byName.has(spec.name)) {
|
|
8
|
+
throw new SchemaError(
|
|
9
|
+
`Field type "${spec.name}" is registered twice. Each field type has exactly one spec.`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
byName.set(spec.name, spec);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
get: (name) => byName.get(name),
|
|
16
|
+
has: (name) => byName.has(name),
|
|
17
|
+
require(name) {
|
|
18
|
+
const spec = byName.get(name);
|
|
19
|
+
if (!spec) {
|
|
20
|
+
throw new SchemaError(
|
|
21
|
+
`Unknown field type "${name}". Registered types: ${[...byName.keys()].join(", ") || "none"}.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return spec;
|
|
25
|
+
},
|
|
26
|
+
names: [...byName.keys()]
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
var UNKNOWN_TS_TYPE = "unknown";
|
|
30
|
+
function resolveTsType(registry, field) {
|
|
31
|
+
const spec = registry.get(field.type);
|
|
32
|
+
if (spec === void 0) return UNKNOWN_TS_TYPE;
|
|
33
|
+
const declared = spec.tsType;
|
|
34
|
+
if (typeof declared === "string") return declared;
|
|
35
|
+
return declared(field, (nested) => resolveTsType(registry, nested));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { UNKNOWN_TS_TYPE, registerFieldTypes, resolveTsType };
|
|
39
|
+
//# sourceMappingURL=chunk-ZW2PUSVS.js.map
|
|
40
|
+
//# sourceMappingURL=chunk-ZW2PUSVS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/schema/field-type.ts"],"names":[],"mappings":";;;AAwLO,SAAS,mBACd,KAAA,EACmB;AACnB,EAAA,MAAM,MAAA,uBAAa,GAAA,EAA8B;AAEjD,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,IAAI,MAAA,CAAO,GAAA,CAAI,IAAA,CAAK,IAAI,CAAA,EAAG;AACzB,MAAA,MAAM,IAAI,WAAA;AAAA,QACR,CAAA,YAAA,EAAe,KAAK,IAAI,CAAA,4DAAA;AAAA,OAC1B;AAAA,IACF;AACA,IAAA,MAAA,CAAO,GAAA,CAAI,IAAA,CAAK,IAAA,EAAM,IAAI,CAAA;AAAA,EAC5B;AAEA,EAAA,OAAO;AAAA,IACL,GAAA,EAAK,CAAC,IAAA,KAAS,MAAA,CAAO,IAAI,IAAI,CAAA;AAAA,IAC9B,GAAA,EAAK,CAAC,IAAA,KAAS,MAAA,CAAO,IAAI,IAAI,CAAA;AAAA,IAC9B,QAAQ,IAAA,EAAM;AACZ,MAAA,MAAM,IAAA,GAAO,MAAA,CAAO,GAAA,CAAI,IAAI,CAAA;AAC5B,MAAA,IAAI,CAAC,IAAA,EAAM;AACT,QAAA,MAAM,IAAI,WAAA;AAAA,UACR,CAAA,oBAAA,EAAuB,IAAI,CAAA,qBAAA,EAAwB,CAAC,GAAG,MAAA,CAAO,IAAA,EAAM,CAAA,CAAE,IAAA,CAAK,IAAI,CAAA,IAAK,MAAM,CAAA,CAAA;AAAA,SAC5F;AAAA,MACF;AACA,MAAA,OAAO,IAAA;AAAA,IACT,CAAA;AAAA,IACA,KAAA,EAAO,CAAC,GAAG,MAAA,CAAO,MAAM;AAAA,GAC1B;AACF;AAGO,IAAM,eAAA,GAAkB;AAUxB,SAAS,aAAA,CACd,UACA,KAAA,EACQ;AACR,EAAA,MAAM,IAAA,GAAO,QAAA,CAAS,GAAA,CAAI,KAAA,CAAM,IAAI,CAAA;AACpC,EAAA,IAAI,IAAA,KAAS,QAAW,OAAO,eAAA;AAE/B,EAAA,MAAM,WAAwB,IAAA,CAAK,MAAA;AACnC,EAAA,IAAI,OAAO,QAAA,KAAa,QAAA,EAAU,OAAO,QAAA;AAEzC,EAAA,OAAO,SAAS,KAAA,EAAO,CAAC,WAAW,aAAA,CAAc,QAAA,EAAU,MAAM,CAAC,CAAA;AACpE","file":"chunk-ZW2PUSVS.js","sourcesContent":["import type { ComponentType } from \"react\"\n\nimport { SchemaError } from \"../core/errors.js\"\nimport type { AnyContentTypeShape, FieldError } from \"../types/entry.js\"\nimport type {\n FieldDefinition,\n FieldMap,\n FieldType,\n ValidationRules,\n} from \"../types/field.js\"\n\n/** Props every admin field input receives. Field types never read from a store directly. */\nexport interface FieldInputProps<\n TValue,\n TField extends FieldDefinition = FieldDefinition,\n> {\n /** Dotted path, unique within the form: `title`, `blocks.2.heading`. */\n path: string\n field: TField\n value: TValue | null\n onChange: (value: TValue | null) => void\n // Spelled with the explicit `| undefined` that `exactOptionalPropertyTypes` requires of a\n // caller forwarding an optional prop. A repeater passes its own `onBlur` straight down to\n // every nested input, and without this it could not.\n onBlur?: (() => void) | undefined\n errors: readonly FieldError[]\n disabled?: boolean | undefined\n readonly?: boolean | undefined\n}\n\nexport type FieldInputComponent<\n TValue,\n TField extends FieldDefinition = FieldDefinition,\n> = ComponentType<FieldInputProps<TValue, TField>>\n\n/** Everything a validator may know without touching the database. */\nexport interface ValidateCtx {\n path: string\n contentType: AnyContentTypeShape\n /** All values of the entry being validated, for cross-field rules. */\n entry: Record<string, unknown>\n}\n\n/**\n * Recursion, for the two field types that contain other fields.\n *\n * `repeater` holds a whole field map, and it cannot convert or name what is inside it\n * without applying the same field type contract one level down. It must not reach for the\n * registry itself: the registry imports every spec, so a spec that imports the registry\n * closes a cycle. The caller that already holds the registry passes the recursion in\n * instead, and both members below are optional, so a spec that nests nothing ignores them\n * and a caller that has no registry still gets a working round trip.\n */\nexport interface NestedConverter {\n toStored(fields: FieldMap, values: Record<string, unknown>): Record<string, unknown>\n fromStored(fields: FieldMap, data: Record<string, unknown>): Record<string, unknown>\n}\n\n/** Names the SDK-facing type of any field definition, one level down. */\nexport type TsTypeResolver = (field: FieldDefinition) => string\n\n/**\n * Layer 5, as a value or as a function of the field.\n *\n * A plain string is the common case and stays exactly as it was. A function is what lets a\n * field type whose SDK type depends on its own options say so: `select` narrows on\n * `multiple`, `relation` on `many`, and `repeater` names the object its rows actually\n * hold. `FieldValue<TField>` in types/field.ts already narrows all three for a code-defined\n * type; this is what codegen can say for a type created in the Schema Builder, where the\n * field definition exists only at runtime.\n */\nexport type FieldTsType<TField extends FieldDefinition = FieldDefinition> =\n string | ((field: TField, resolve: TsTypeResolver) => string)\n\n/**\n * How one per-type option is edited in the Schema Builder.\n *\n * A control, not a field type: `\"choice\"` is a fixed list of values, `\"content-type\"` is a\n * slug picked from the live registry, `\"field-key\"` is a sibling field of the same level.\n * The field editor implements this small vocabulary once, and every field type describes\n * its own options in it, which is what keeps `switch (field.type)` out of the screen.\n */\nexport type FieldOptionControl =\n | \"text\"\n | \"number\"\n | \"boolean\"\n | \"choice\"\n | \"select-options\"\n | \"content-type\"\n | \"field-key\"\n | \"string-list\"\n | \"fields\"\n\nexport interface FieldOptionDescriptor {\n /** The property this option writes on the field definition. */\n key: string\n label: string\n control: FieldOptionControl\n help?: string\n /** A field of this type is incomplete until the option has a value. */\n required?: boolean\n /** The values a `\"choice\"` control offers. */\n choices?: readonly { value: string; label: string }[]\n}\n\n/**\n * Layer 7: what the Schema Builder may configure on this field type, and what it starts as.\n *\n * It exists so that the field editor is generated from the registry exactly as every other\n * admin screen is. Without it, one screen would hold a list of which options belong to\n * which field type, and a field type added later would render as a bare label until\n * somebody remembered to edit that list.\n */\nexport interface FieldEditorSpec {\n /** Name shown in the field type picker. */\n label: string\n /** One line, shown under the name in the picker. */\n description: string\n /** The shared validation rules this field type honours. See VALIDATION_RULE_CONTROLS. */\n rules: readonly (keyof ValidationRules)[]\n /** The per-type options, in the order the editor shows them. */\n options: readonly FieldOptionDescriptor[]\n /**\n * Everything a new field of this type needs besides `type` and `label`.\n *\n * A select with no options and a repeater with no nested fields are both invalid, so a\n * newly added field cannot start as `{ type, label }` alone.\n */\n defaults: Record<string, unknown>\n}\n\n/**\n * The seven-layer contract. A field type is not \"added\" until every member here is\n * implemented; a partial field type is a field type that behaves differently in the form\n * than it does on the server.\n *\n * 1. `name` schema definition: the discriminator a content type writes\n * 2. `toStored` /\n * `fromStored` stored shape: the round trip through Firestore\n * 3. `Input` admin input component\n * 4. `validate` validation, run identically by the form and by the adapter\n * 5. `tsType` SDK-facing type, used by codegen and by the Developer area\n * 6. `defaultValue` registration data: what a new entry starts with\n * 7. `editor` Schema Builder data: the options, rules and defaults of the type\n */\nexport interface FieldTypeSpec<TValue, TField extends FieldDefinition = FieldDefinition> {\n name: FieldType\n toStored(value: TValue | null, field: TField, nested?: NestedConverter): unknown\n fromStored(raw: unknown, field: TField, nested?: NestedConverter): TValue | null\n Input: FieldInputComponent<TValue, TField>\n validate(\n value: TValue | null,\n rules: ValidationRules,\n ctx: ValidateCtx,\n field: TField,\n ): FieldError[]\n tsType: FieldTsType<TField>\n defaultValue(field: TField): TValue | null\n editor: FieldEditorSpec\n}\n\n/**\n * A spec whose value and field types have been erased, for storage in the registry.\n *\n * The registry is heterogeneous by construction: one spec per field type, each with a\n * different value type. The erasure stops at this alias; every consumer goes back through\n * the typed `FieldTypeSpec` for the field it is actually handling.\n */\n/* eslint-disable-next-line @typescript-eslint/no-explicit-any */\nexport type AnyFieldTypeSpec = FieldTypeSpec<any, any>\n\nexport interface FieldTypeRegistry {\n get(name: string): AnyFieldTypeSpec | undefined\n require(name: string): AnyFieldTypeSpec\n has(name: string): boolean\n readonly names: readonly FieldType[]\n}\n\n/**\n * Builds the single registration point for field types.\n *\n * The admin never switches on a field name: it looks the spec up here and renders\n * `spec.Input`. If a screen needs `switch (field.type)`, the abstraction has failed.\n */\nexport function registerFieldTypes(\n specs: readonly AnyFieldTypeSpec[],\n): FieldTypeRegistry {\n const byName = new Map<string, AnyFieldTypeSpec>()\n\n for (const spec of specs) {\n if (byName.has(spec.name)) {\n throw new SchemaError(\n `Field type \"${spec.name}\" is registered twice. Each field type has exactly one spec.`,\n )\n }\n byName.set(spec.name, spec)\n }\n\n return {\n get: (name) => byName.get(name),\n has: (name) => byName.has(name),\n require(name) {\n const spec = byName.get(name)\n if (!spec) {\n throw new SchemaError(\n `Unknown field type \"${name}\". Registered types: ${[...byName.keys()].join(\", \") || \"none\"}.`,\n )\n }\n return spec\n },\n names: [...byName.keys()] as FieldType[],\n }\n}\n\n/** What codegen writes for a field whose type nothing has registered. */\nexport const UNKNOWN_TS_TYPE = \"unknown\"\n\n/**\n * Layer 5 for one field definition, resolved through the registry.\n *\n * The one place that knows a `tsType` may be a function, so no caller has to branch on it,\n * and the one place that supplies the recursion a nesting field type asks for. An\n * unregistered field type resolves to `unknown` rather than throwing: codegen for a\n * database-defined content type must not fail on one field it does not recognise.\n */\nexport function resolveTsType(\n registry: FieldTypeRegistry,\n field: FieldDefinition,\n): string {\n const spec = registry.get(field.type)\n if (spec === undefined) return UNKNOWN_TS_TYPE\n\n const declared: FieldTsType = spec.tsType\n if (typeof declared === \"string\") return declared\n\n return declared(field, (nested) => resolveTsType(registry, nested))\n}\n"]}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { ROLES } from './chunk-TS7Y7GEZ.js';
|
|
2
|
+
import { Timestamp, getDoc, doc, setDoc, serverTimestamp, updateDoc } from 'firebase/firestore';
|
|
3
|
+
import { onAuthStateChanged, signOut, createUserWithEmailAndPassword, updateProfile, signInWithEmailAndPassword } from 'firebase/auth';
|
|
4
|
+
|
|
5
|
+
var META_KEYS = [
|
|
6
|
+
"id",
|
|
7
|
+
"status",
|
|
8
|
+
"createdAt",
|
|
9
|
+
"createdBy",
|
|
10
|
+
"updatedAt",
|
|
11
|
+
"updatedBy",
|
|
12
|
+
"publishedAt",
|
|
13
|
+
"schemaVersion"
|
|
14
|
+
];
|
|
15
|
+
function toIso(value) {
|
|
16
|
+
if (value === null || value === void 0) return null;
|
|
17
|
+
if (value instanceof Timestamp) return value.toDate().toISOString();
|
|
18
|
+
if (value instanceof Date) return value.toISOString();
|
|
19
|
+
if (typeof value === "string") return value;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function defaultToStored(value, field) {
|
|
23
|
+
if (value === null || value === void 0) return null;
|
|
24
|
+
switch (field.type) {
|
|
25
|
+
case "repeater": {
|
|
26
|
+
if (!Array.isArray(value)) return null;
|
|
27
|
+
return value.map(
|
|
28
|
+
(row) => typeof row === "object" && row !== null && !Array.isArray(row) ? valuesToStored(field.fields, row, void 0) : null
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
// A date is stored as the string the editor typed, not as a Timestamp. It is a calendar
|
|
32
|
+
// value chosen by a human, not an instant, and converting it would move it across a
|
|
33
|
+
// time-zone boundary on the way back out.
|
|
34
|
+
default:
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function defaultFromStored(raw, field) {
|
|
39
|
+
if (raw === null || raw === void 0) return null;
|
|
40
|
+
switch (field.type) {
|
|
41
|
+
case "repeater": {
|
|
42
|
+
if (!Array.isArray(raw)) return null;
|
|
43
|
+
return raw.map(
|
|
44
|
+
(row) => typeof row === "object" && row !== null && !Array.isArray(row) ? valuesFromStored(field.fields, row, void 0) : null
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
case "date":
|
|
48
|
+
return raw instanceof Timestamp ? toIso(raw) : raw;
|
|
49
|
+
default:
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function nestedConverter(fieldTypes) {
|
|
54
|
+
return {
|
|
55
|
+
toStored: (fields, values) => valuesToStored(fields, values, fieldTypes),
|
|
56
|
+
fromStored: (fields, data) => valuesFromStored(fields, data, fieldTypes)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function valuesToStored(fields, values, fieldTypes) {
|
|
60
|
+
const stored = {};
|
|
61
|
+
const nested = nestedConverter(fieldTypes);
|
|
62
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
63
|
+
const value = values[key] ?? null;
|
|
64
|
+
const spec = fieldTypes?.get(field.type);
|
|
65
|
+
stored[key] = spec ? spec.toStored(value, field, nested) : defaultToStored(value, field);
|
|
66
|
+
}
|
|
67
|
+
for (const [key, value] of Object.entries(values)) {
|
|
68
|
+
if (key in fields) continue;
|
|
69
|
+
if (META_KEYS.includes(key)) continue;
|
|
70
|
+
stored[key] = value;
|
|
71
|
+
}
|
|
72
|
+
return stored;
|
|
73
|
+
}
|
|
74
|
+
function valuesFromStored(fields, data, fieldTypes) {
|
|
75
|
+
const values = {};
|
|
76
|
+
const nested = nestedConverter(fieldTypes);
|
|
77
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
78
|
+
const raw = data[key] ?? null;
|
|
79
|
+
const spec = fieldTypes?.get(field.type);
|
|
80
|
+
values[key] = spec ? spec.fromStored(raw, field, nested) : defaultFromStored(raw, field);
|
|
81
|
+
}
|
|
82
|
+
return values;
|
|
83
|
+
}
|
|
84
|
+
function metaFromStored(entryId, data) {
|
|
85
|
+
const status = data["status"];
|
|
86
|
+
return {
|
|
87
|
+
id: entryId,
|
|
88
|
+
status: status === "published" || status === "archived" || status === "draft" ? status : "draft",
|
|
89
|
+
createdAt: toIso(data["createdAt"]) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
90
|
+
createdBy: typeof data["createdBy"] === "string" ? data["createdBy"] : "",
|
|
91
|
+
updatedAt: toIso(data["updatedAt"]) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
92
|
+
updatedBy: typeof data["updatedBy"] === "string" ? data["updatedBy"] : "",
|
|
93
|
+
publishedAt: toIso(data["publishedAt"]),
|
|
94
|
+
schemaVersion: typeof data["schemaVersion"] === "number" ? data["schemaVersion"] : 1
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function entryFromStored(type, entryId, data, fieldTypes) {
|
|
98
|
+
return {
|
|
99
|
+
...metaFromStored(entryId, data),
|
|
100
|
+
...valuesFromStored(type.fields, data, fieldTypes)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/firebase/paths.ts
|
|
105
|
+
var SCHEMAS = "schemas";
|
|
106
|
+
var CONTENT = "content";
|
|
107
|
+
var ENTRIES = "entries";
|
|
108
|
+
var MEDIA = "media";
|
|
109
|
+
var USERS = "users";
|
|
110
|
+
var SETTINGS = "settings";
|
|
111
|
+
function schemaPath(slug) {
|
|
112
|
+
return `${SCHEMAS}/${slug}`;
|
|
113
|
+
}
|
|
114
|
+
function contentPath(slug) {
|
|
115
|
+
return `${CONTENT}/${slug}`;
|
|
116
|
+
}
|
|
117
|
+
function entriesPath(slug) {
|
|
118
|
+
return `${CONTENT}/${slug}/${ENTRIES}`;
|
|
119
|
+
}
|
|
120
|
+
function entryPath(slug, entryId) {
|
|
121
|
+
return `${entriesPath(slug)}/${entryId}`;
|
|
122
|
+
}
|
|
123
|
+
function mediaPath(mediaId) {
|
|
124
|
+
return `${MEDIA}/${mediaId}`;
|
|
125
|
+
}
|
|
126
|
+
function userPath(uid) {
|
|
127
|
+
return `${USERS}/${uid}`;
|
|
128
|
+
}
|
|
129
|
+
function settingsPath(key) {
|
|
130
|
+
return `${SETTINGS}/${key}`;
|
|
131
|
+
}
|
|
132
|
+
var BOOTSTRAP_KEY = "bootstrap";
|
|
133
|
+
function bootstrapPath() {
|
|
134
|
+
return settingsPath(BOOTSTRAP_KEY);
|
|
135
|
+
}
|
|
136
|
+
function storageObjectPath(mediaId, fileName) {
|
|
137
|
+
return `${MEDIA}/${mediaId}/${fileName}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/firebase/users.ts
|
|
141
|
+
function isRole(value) {
|
|
142
|
+
return typeof value === "string" && ROLES.includes(value);
|
|
143
|
+
}
|
|
144
|
+
function userFromStored(uid, data) {
|
|
145
|
+
return {
|
|
146
|
+
uid,
|
|
147
|
+
email: typeof data["email"] === "string" ? data["email"] : "",
|
|
148
|
+
displayName: typeof data["displayName"] === "string" ? data["displayName"] : null,
|
|
149
|
+
role: isRole(data["role"]) ? data["role"] : "author",
|
|
150
|
+
createdAt: toIso(data["createdAt"]) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
151
|
+
disabled: data["disabled"] === true
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function toAuthUser(stored) {
|
|
155
|
+
return {
|
|
156
|
+
uid: stored.uid,
|
|
157
|
+
email: stored.email,
|
|
158
|
+
displayName: stored.displayName,
|
|
159
|
+
role: stored.role,
|
|
160
|
+
disabled: stored.disabled
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async function readUser(db, uid) {
|
|
164
|
+
const snapshot = await getDoc(doc(db, userPath(uid)));
|
|
165
|
+
return snapshot.exists() ? userFromStored(uid, snapshot.data()) : null;
|
|
166
|
+
}
|
|
167
|
+
async function writeUser(db, user) {
|
|
168
|
+
const reference = doc(db, userPath(user.uid));
|
|
169
|
+
await setDoc(reference, {
|
|
170
|
+
email: user.email,
|
|
171
|
+
displayName: user.displayName ?? null,
|
|
172
|
+
role: user.role,
|
|
173
|
+
disabled: false,
|
|
174
|
+
createdAt: serverTimestamp()
|
|
175
|
+
});
|
|
176
|
+
const written = await getDoc(reference);
|
|
177
|
+
return userFromStored(user.uid, written.data() ?? {});
|
|
178
|
+
}
|
|
179
|
+
async function patchUser(db, uid, patch) {
|
|
180
|
+
const reference = doc(db, userPath(uid));
|
|
181
|
+
const changes = {};
|
|
182
|
+
if (patch.displayName !== void 0) changes["displayName"] = patch.displayName;
|
|
183
|
+
if (patch.role !== void 0) changes["role"] = patch.role;
|
|
184
|
+
if (patch.disabled !== void 0) changes["disabled"] = patch.disabled;
|
|
185
|
+
if (Object.keys(changes).length > 0) await updateDoc(reference, changes);
|
|
186
|
+
const written = await getDoc(reference);
|
|
187
|
+
return userFromStored(uid, written.data() ?? {});
|
|
188
|
+
}
|
|
189
|
+
var AuthError = class extends Error {
|
|
190
|
+
name = "AuthError";
|
|
191
|
+
code;
|
|
192
|
+
constructor(code, message) {
|
|
193
|
+
super(message);
|
|
194
|
+
this.code = code;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
var CREDENTIAL_CODES = /* @__PURE__ */ new Set([
|
|
198
|
+
"auth/invalid-credential",
|
|
199
|
+
"auth/wrong-password",
|
|
200
|
+
"auth/user-not-found",
|
|
201
|
+
"auth/invalid-email"
|
|
202
|
+
]);
|
|
203
|
+
function errorCode(thrown) {
|
|
204
|
+
return thrown.code;
|
|
205
|
+
}
|
|
206
|
+
function createHuskAuth(deps) {
|
|
207
|
+
const { auth, db, claimSync } = deps;
|
|
208
|
+
const defaultRole = deps.defaultRole ?? "author";
|
|
209
|
+
async function claimFirstAdmin(uid) {
|
|
210
|
+
try {
|
|
211
|
+
await setDoc(doc(db, bootstrapPath()), {
|
|
212
|
+
firstAdminUid: uid,
|
|
213
|
+
createdAt: serverTimestamp()
|
|
214
|
+
});
|
|
215
|
+
return true;
|
|
216
|
+
} catch (thrown) {
|
|
217
|
+
const code = errorCode(thrown);
|
|
218
|
+
if (code === "permission-denied" || code === "already-exists") {
|
|
219
|
+
return await markerNames(uid);
|
|
220
|
+
}
|
|
221
|
+
throw thrown;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function markerNames(uid) {
|
|
225
|
+
try {
|
|
226
|
+
const marker = await getDoc(doc(db, bootstrapPath()));
|
|
227
|
+
return marker.exists() && marker.data()["firstAdminUid"] === uid;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function tokenClaims(user) {
|
|
233
|
+
const result = await user.getIdTokenResult();
|
|
234
|
+
const role = result.claims["role"];
|
|
235
|
+
const disabled = result.claims["disabled"];
|
|
236
|
+
return {
|
|
237
|
+
...typeof role === "string" ? { role } : {},
|
|
238
|
+
...typeof disabled === "boolean" ? { disabled } : {}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function syncClaims(uid, claims) {
|
|
242
|
+
await claimSync.sync(uid, claims);
|
|
243
|
+
if (auth.currentUser?.uid === uid) await auth.currentUser.getIdToken(true);
|
|
244
|
+
}
|
|
245
|
+
async function reconcile(user, stored) {
|
|
246
|
+
const claims = await tokenClaims(user);
|
|
247
|
+
if (claims.role === stored.role && claims.disabled === stored.disabled) return;
|
|
248
|
+
await syncClaims(stored.uid, { role: stored.role, disabled: stored.disabled });
|
|
249
|
+
}
|
|
250
|
+
async function provision(user) {
|
|
251
|
+
const role = await claimFirstAdmin(user.uid) ? "admin" : defaultRole;
|
|
252
|
+
await syncClaims(user.uid, { role, disabled: false });
|
|
253
|
+
const stored = await writeUser(db, {
|
|
254
|
+
uid: user.uid,
|
|
255
|
+
email: user.email ?? "",
|
|
256
|
+
displayName: user.displayName,
|
|
257
|
+
role
|
|
258
|
+
});
|
|
259
|
+
return toAuthUser(stored);
|
|
260
|
+
}
|
|
261
|
+
async function resolve(user) {
|
|
262
|
+
const existing = await readUser(db, user.uid);
|
|
263
|
+
if (existing === null) return provision(user);
|
|
264
|
+
if (existing.disabled) {
|
|
265
|
+
await signOut(auth);
|
|
266
|
+
throw new AuthError("disabled", "This account has been disabled.");
|
|
267
|
+
}
|
|
268
|
+
await reconcile(user, existing);
|
|
269
|
+
return toAuthUser(existing);
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
async signIn(email, password) {
|
|
273
|
+
try {
|
|
274
|
+
const credential = await signInWithEmailAndPassword(auth, email, password);
|
|
275
|
+
return await resolve(credential.user);
|
|
276
|
+
} catch (thrown) {
|
|
277
|
+
const code = errorCode(thrown);
|
|
278
|
+
if (code !== void 0 && CREDENTIAL_CODES.has(code)) {
|
|
279
|
+
throw new AuthError("invalid-credentials", "Email or password is incorrect.");
|
|
280
|
+
}
|
|
281
|
+
throw thrown;
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
async signUp(input) {
|
|
285
|
+
const credential = await createUserWithEmailAndPassword(
|
|
286
|
+
auth,
|
|
287
|
+
input.email,
|
|
288
|
+
input.password
|
|
289
|
+
);
|
|
290
|
+
if (input.displayName !== void 0) {
|
|
291
|
+
await updateProfile(credential.user, { displayName: input.displayName });
|
|
292
|
+
}
|
|
293
|
+
return resolve(credential.user);
|
|
294
|
+
},
|
|
295
|
+
async signOut() {
|
|
296
|
+
await signOut(auth);
|
|
297
|
+
},
|
|
298
|
+
async currentUser() {
|
|
299
|
+
const user = auth.currentUser;
|
|
300
|
+
return user === null ? null : await resolve(user);
|
|
301
|
+
},
|
|
302
|
+
onChange(listener, onError) {
|
|
303
|
+
return onAuthStateChanged(auth, (user) => {
|
|
304
|
+
if (user === null) {
|
|
305
|
+
listener(null);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
resolve(user).then(listener, (error) => {
|
|
309
|
+
listener(null);
|
|
310
|
+
onError?.(error);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
getUser(uid) {
|
|
315
|
+
return readUser(db, uid);
|
|
316
|
+
},
|
|
317
|
+
updateProfile(uid, patch) {
|
|
318
|
+
return patchUser(db, uid, patch);
|
|
319
|
+
},
|
|
320
|
+
async setRole(uid, role) {
|
|
321
|
+
const stored = await patchUser(db, uid, { role });
|
|
322
|
+
await syncClaims(uid, { role: stored.role, disabled: stored.disabled });
|
|
323
|
+
return stored;
|
|
324
|
+
},
|
|
325
|
+
async setDisabled(uid, disabled) {
|
|
326
|
+
const stored = await patchUser(db, uid, { disabled });
|
|
327
|
+
await syncClaims(uid, { role: stored.role, disabled: stored.disabled });
|
|
328
|
+
return stored;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/firebase/claims.ts
|
|
334
|
+
var ClaimSyncError = class extends Error {
|
|
335
|
+
name = "ClaimSyncError";
|
|
336
|
+
uid;
|
|
337
|
+
constructor(uid, message) {
|
|
338
|
+
super(message);
|
|
339
|
+
this.uid = uid;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
343
|
+
function isLoopback(host) {
|
|
344
|
+
const [hostname] = host.split(":");
|
|
345
|
+
return hostname !== void 0 && LOOPBACK.has(hostname);
|
|
346
|
+
}
|
|
347
|
+
function createEmulatorClaimSync(options) {
|
|
348
|
+
const { authHost, projectId } = options;
|
|
349
|
+
if (!isLoopback(authHost)) {
|
|
350
|
+
throw new ClaimSyncError(
|
|
351
|
+
"*",
|
|
352
|
+
`Emulator claim sync refuses the host "${authHost}". The bearer token "owner" is an emulator affordance and is not accepted by the real Identity Toolkit, so this implementation is loopback-only.`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
async sync(uid, claims) {
|
|
357
|
+
const response = await fetch(
|
|
358
|
+
`http://${authHost}/identitytoolkit.googleapis.com/v1/projects/${projectId}/accounts:update`,
|
|
359
|
+
{
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: { "Content-Type": "application/json", Authorization: "Bearer owner" },
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
localId: uid,
|
|
364
|
+
customAttributes: JSON.stringify(claims)
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
if (!response.ok) {
|
|
369
|
+
throw new ClaimSyncError(
|
|
370
|
+
uid,
|
|
371
|
+
`Could not set custom claims on ${uid}: ${response.status} ${await response.text()}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function createUnavailableClaimSync(reason) {
|
|
378
|
+
return {
|
|
379
|
+
sync(uid) {
|
|
380
|
+
return Promise.reject(
|
|
381
|
+
new ClaimSyncError(
|
|
382
|
+
uid,
|
|
383
|
+
reason ?? "No custom claim writer is configured. Setting a custom claim needs a privileged context (Admin SDK, Cloud Function, or the Auth emulator), which the client SDK cannot provide. Pass a ClaimSync implementation to createHuskAuth."
|
|
384
|
+
)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export { AuthError, BOOTSTRAP_KEY, ClaimSyncError, ENTRIES, MEDIA, SCHEMAS, contentPath, createEmulatorClaimSync, createHuskAuth, createUnavailableClaimSync, entriesPath, entryFromStored, entryPath, mediaPath, metaFromStored, patchUser, readUser, schemaPath, settingsPath, storageObjectPath, toAuthUser, toIso, valuesToStored, writeUser };
|
|
391
|
+
//# sourceMappingURL=chunk-ZYNBMYJ6.js.map
|
|
392
|
+
//# sourceMappingURL=chunk-ZYNBMYJ6.js.map
|