@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/LICENSE +166 -0
  3. package/README.md +140 -0
  4. package/api/public-api.md +1070 -0
  5. package/dist/adapter-C_0mhENy.d.ts +320 -0
  6. package/dist/admin/admin.css +5 -0
  7. package/dist/admin/index.d.ts +265 -0
  8. package/dist/admin/index.js +393 -0
  9. package/dist/admin/index.js.map +1 -0
  10. package/dist/admin/tokens.css +597 -0
  11. package/dist/admin/ui.d.ts +3013 -0
  12. package/dist/admin/ui.js +9783 -0
  13. package/dist/admin/ui.js.map +1 -0
  14. package/dist/auth/index.d.ts +4 -0
  15. package/dist/auth/index.js +9 -0
  16. package/dist/auth/index.js.map +1 -0
  17. package/dist/auth-B2gBtHl5.d.ts +28 -0
  18. package/dist/can-Chy7zmx7.d.ts +69 -0
  19. package/dist/chunk-3I6337WJ.js +83 -0
  20. package/dist/chunk-3I6337WJ.js.map +1 -0
  21. package/dist/chunk-4Z6P3XCE.js +62 -0
  22. package/dist/chunk-4Z6P3XCE.js.map +1 -0
  23. package/dist/chunk-6E3ZS5Z5.js +1007 -0
  24. package/dist/chunk-6E3ZS5Z5.js.map +1 -0
  25. package/dist/chunk-77JDRXXU.js +3 -0
  26. package/dist/chunk-77JDRXXU.js.map +1 -0
  27. package/dist/chunk-FHYWJCG6.js +171 -0
  28. package/dist/chunk-FHYWJCG6.js.map +1 -0
  29. package/dist/chunk-JAE7HHMM.js +506 -0
  30. package/dist/chunk-JAE7HHMM.js.map +1 -0
  31. package/dist/chunk-NPDBQBLW.js +68 -0
  32. package/dist/chunk-NPDBQBLW.js.map +1 -0
  33. package/dist/chunk-ORMYARXD.js +6 -0
  34. package/dist/chunk-ORMYARXD.js.map +1 -0
  35. package/dist/chunk-R4O7APIX.js +51 -0
  36. package/dist/chunk-R4O7APIX.js.map +1 -0
  37. package/dist/chunk-ROLKRJWI.js +187 -0
  38. package/dist/chunk-ROLKRJWI.js.map +1 -0
  39. package/dist/chunk-SOTQ3WP5.js +31 -0
  40. package/dist/chunk-SOTQ3WP5.js.map +1 -0
  41. package/dist/chunk-TCFFHTY3.js +97 -0
  42. package/dist/chunk-TCFFHTY3.js.map +1 -0
  43. package/dist/chunk-TS7Y7GEZ.js +7 -0
  44. package/dist/chunk-TS7Y7GEZ.js.map +1 -0
  45. package/dist/chunk-VLGR5PZX.js +3 -0
  46. package/dist/chunk-VLGR5PZX.js.map +1 -0
  47. package/dist/chunk-ZW2PUSVS.js +40 -0
  48. package/dist/chunk-ZW2PUSVS.js.map +1 -0
  49. package/dist/chunk-ZYNBMYJ6.js +392 -0
  50. package/dist/chunk-ZYNBMYJ6.js.map +1 -0
  51. package/dist/cli/nonext-husk.js +3360 -0
  52. package/dist/cli/nonext-husk.js.map +1 -0
  53. package/dist/cli/templates/firestore.rules +393 -0
  54. package/dist/cli/templates/storage.rules +165 -0
  55. package/dist/client/index.d.ts +27 -0
  56. package/dist/client/index.js +15 -0
  57. package/dist/client/index.js.map +1 -0
  58. package/dist/cms-9fSA3uQV.d.ts +158 -0
  59. package/dist/core/index.d.ts +1267 -0
  60. package/dist/core/index.js +12 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/entry-D3kiE8T1.d.ts +163 -0
  63. package/dist/field-Diyf5siF.d.ts +158 -0
  64. package/dist/field-type-D25Vojb0.d.ts +157 -0
  65. package/dist/firebase/index.d.ts +251 -0
  66. package/dist/firebase/index.js +991 -0
  67. package/dist/firebase/index.js.map +1 -0
  68. package/dist/index-5ylaMBUH.d.ts +155 -0
  69. package/dist/index.d.ts +14 -0
  70. package/dist/index.js +17 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/permissions/index.d.ts +101 -0
  73. package/dist/permissions/index.js +7 -0
  74. package/dist/permissions/index.js.map +1 -0
  75. package/dist/registry-CUC25qay.d.ts +59 -0
  76. package/dist/richtext/index.d.ts +85 -0
  77. package/dist/richtext/index.js +127 -0
  78. package/dist/richtext/index.js.map +1 -0
  79. package/dist/schema/index.d.ts +50 -0
  80. package/dist/schema/index.js +5 -0
  81. package/dist/schema/index.js.map +1 -0
  82. package/dist/server/index.d.ts +39 -0
  83. package/dist/server/index.js +25 -0
  84. package/dist/server/index.js.map +1 -0
  85. package/dist/types/index.d.ts +36 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/write-limits-Djmm6L3R.d.ts +215 -0
  89. package/package.json +136 -0
@@ -0,0 +1,393 @@
1
+ rules_version = '2';
2
+
3
+ // Husk security rules.
4
+ //
5
+ // These encode the same truth table as `can()` in the application and the checks in the
6
+ // adapter. Three layers, one matrix: the UI hides what is denied, the adapter refuses it,
7
+ // and these rules refuse it again for a client that skipped both.
8
+ //
9
+ // Roles come from a custom claim so a rule never has to read a user document. M3 syncs the
10
+ // claim when a user's role changes.
11
+
12
+ service cloud.firestore {
13
+ match /databases/{database}/documents {
14
+
15
+ function signedIn() {
16
+ return request.auth != null;
17
+ }
18
+
19
+ function role() {
20
+ return signedIn() ? request.auth.token.get('role', 'none') : 'none';
21
+ }
22
+
23
+ // A role the CMS knows. An account whose claim has not been written yet reports 'none',
24
+ // which is not one of these, so a session without a synced claim can read nothing. That
25
+ // is deliberate: claim sync is the only thing that grants access, so a failed sync has
26
+ // to fail closed.
27
+ function knownRole() {
28
+ return role() == 'admin' || role() == 'editor' || role() == 'author';
29
+ }
30
+
31
+ function enabled() {
32
+ return signedIn() && knownRole() && request.auth.token.get('disabled', false) != true;
33
+ }
34
+
35
+ function isAdmin() {
36
+ return enabled() && role() == 'admin';
37
+ }
38
+
39
+ function isEditor() {
40
+ return enabled() && (role() == 'admin' || role() == 'editor');
41
+ }
42
+
43
+ function isAuthor() {
44
+ return enabled() && (role() == 'admin' || role() == 'editor' || role() == 'author');
45
+ }
46
+
47
+ function typeIsLocked(slug) {
48
+ return exists(/databases/$(database)/documents/content/$(slug))
49
+ && get(/databases/$(database)/documents/content/$(slug)).data.get('locked', false) == true;
50
+ }
51
+
52
+ // Audit fields belong to the adapter. A client may not forge authorship, and may not
53
+ // rewrite the creation record of an entry it is updating.
54
+ function authorshipIsHonest() {
55
+ return request.resource.data.createdBy == request.auth.uid
56
+ && request.resource.data.updatedBy == request.auth.uid;
57
+ }
58
+
59
+ function preservesCreation() {
60
+ return request.resource.data.createdBy == resource.data.createdBy
61
+ && request.resource.data.createdAt == resource.data.createdAt
62
+ && request.resource.data.updatedBy == request.auth.uid;
63
+ }
64
+
65
+ function ownsEntry() {
66
+ return signedIn() && resource.data.createdBy == request.auth.uid;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------------
70
+ // The per-content-type permission matrix.
71
+ //
72
+ // `content/{slug}.permissions` is a map of entry action to the roles that keep it, for
73
+ // example `{ "delete": ["admin", "editor"] }`. It is the same map `can()` reads in the
74
+ // application and the same one the Developer area edits, and it is written only by an
75
+ // admin, because `content/{slug}` is an admin-only document.
76
+ //
77
+ // It narrows and never widens, which is the whole design. The role clauses below
78
+ // (isEditor, isAuthor, ownsEntry) are evaluated from the token exactly as before, and
79
+ // the matrix can only take an action away on top of them. A rule that read a privilege
80
+ // out of a document would be letting a document grant a privilege, and `can()` matches
81
+ // this by refusing to widen either: ticking `author` under `delete` changes nothing in
82
+ // either layer. See permissions/matrix.ts and .docs/decisions/2026-09-19-permission-matrix-enforcement.md.
83
+ //
84
+ // What it costs: one document read per authenticated entry operation on a type whose
85
+ // metadata document exists. The unauthenticated published-entry branch is evaluated
86
+ // first and never reaches this, so a customer's public website, which is the volume
87
+ // path, pays nothing.
88
+ // ---------------------------------------------------------------------------------
89
+
90
+ // Written as a chain of "no narrowing applies" escapes rather than with a map default,
91
+ // so that a missing metadata document, a document with no matrix, and a matrix that is
92
+ // silent about this action all mean the same thing: not narrowed. A project that never
93
+ // provisioned `content/{slug}` therefore behaves exactly as it did before the matrix
94
+ // existed rather than locking everybody out of a type whose metadata is missing.
95
+ //
96
+ // The path is repeated instead of held in a local because rules functions are single
97
+ // expressions; Firestore caches repeated access calls within one evaluation, so this
98
+ // is one document read and not four.
99
+ function matrixAllows(slug, action) {
100
+ return role() == 'admin'
101
+ || !exists(/databases/$(database)/documents/content/$(slug))
102
+ || !('permissions' in get(/databases/$(database)/documents/content/$(slug)).data)
103
+ || !(action in get(/databases/$(database)/documents/content/$(slug)).data.permissions)
104
+ || role() in get(/databases/$(database)/documents/content/$(slug)).data.permissions[action];
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------------
108
+ // Payload and write-rate guards.
109
+ //
110
+ // What Firestore rules can actually see, and nothing beyond it. A rule can count the
111
+ // top-level fields of the incoming document and can compare `request.time` against a
112
+ // timestamp already stored, so those two are enforced here. A rule cannot see the byte
113
+ // size of a Firestore document and cannot count a client's writes across documents, so
114
+ // neither is claimed here: the per-document byte ceiling is Firestore's own 1 MiB, and
115
+ // the cross-document burst check lives in the adapter, where it is a courtesy rather
116
+ // than a guarantee. core/write-limits.ts holds the numbers for both sides.
117
+ // ---------------------------------------------------------------------------------
118
+
119
+ function withinFieldCount(maximum) {
120
+ return request.resource.data.keys().size() <= maximum;
121
+ }
122
+
123
+ // At least this long between two writes to the same document. It throttles a runaway
124
+ // client to five writes a second on one document without being reachable by a human
125
+ // clicking save. A document with no stored `updatedAt` is not throttled, because there
126
+ // is nothing to measure from.
127
+ function notTooSoon() {
128
+ return !(resource.data.get('updatedAt', null) is timestamp)
129
+ || request.time > resource.data.updatedAt + duration.value(200, 'ms');
130
+ }
131
+
132
+ function entryStatusValid() {
133
+ return request.resource.data.status in ['draft', 'published', 'archived'];
134
+ }
135
+
136
+ // Checked only when present. An entry written before stamping existed is legitimate,
137
+ // and is exactly what the migration runner is for; an entry claiming a version that is
138
+ // not an integer is not.
139
+ function entryVersionValid() {
140
+ return !('schemaVersion' in request.resource.data)
141
+ || request.resource.data.schemaVersion is int;
142
+ }
143
+
144
+ // Schema definitions. Only an admin manages structure, and a type locked in code is
145
+ // immutable from any client, admin included: its definition lives in git.
146
+ //
147
+ // The field cap is the payload guard for a schema document. A content type with more
148
+ // than 100 fields is a spreadsheet, not a content type, and the cap is what stops a
149
+ // generated definition from growing until a write starts failing on Firestore's own
150
+ // document limit with an error nobody can act on.
151
+ match /schemas/{slug} {
152
+ allow read: if enabled();
153
+ allow create, update: if isAdmin()
154
+ && !typeIsLocked(slug)
155
+ && withinFieldCount(30)
156
+ && schemaFieldsValid()
157
+ && permissionsShapeValid(request.resource.data);
158
+ allow delete: if isAdmin() && !typeIsLocked(slug);
159
+ }
160
+
161
+ // Checked only when present, so a partially provisioned definition is still writable.
162
+ // An absent field map has no fields to cap.
163
+ function schemaFieldsValid() {
164
+ return !('fields' in request.resource.data)
165
+ || (request.resource.data.fields is map
166
+ && request.resource.data.fields.keys().size() <= 100);
167
+ }
168
+
169
+ // The matrix, wherever it is stored. Both `schemas/{slug}` and `content/{slug}` carry
170
+ // it: the first is the definition the Schema Builder edits, the second is the copy the
171
+ // rules above read, and a malformed one in either place would make `matrixAllows`
172
+ // deny without ever saying why. An absent matrix is valid and means "not narrowed".
173
+ function permissionsShapeValid(data) {
174
+ return !('permissions' in data)
175
+ || (data.permissions is map
176
+ && data.permissions.keys().hasOnly(['read', 'create', 'update', 'delete'])
177
+ && rolesListValid(data.permissions, 'read')
178
+ && rolesListValid(data.permissions, 'create')
179
+ && rolesListValid(data.permissions, 'update')
180
+ && rolesListValid(data.permissions, 'delete'));
181
+ }
182
+
183
+ function rolesListValid(matrix, action) {
184
+ return !(action in matrix)
185
+ || (matrix[action] is list
186
+ && matrix[action].hasOnly(['admin', 'editor', 'author']));
187
+ }
188
+
189
+ // Per-content-type metadata: schema version, kind, lock flag. Provisioned by an admin
190
+ // during setup, never by an editor.
191
+ //
192
+ // Creating the record is allowed even for a locked type, because the record has to come
193
+ // into existence somehow and a locked type is provisioned exactly like any other. Once
194
+ // it exists and says locked, it is frozen: no update, no delete, and the lock flag
195
+ // itself can never be flipped from a client.
196
+ //
197
+ // It also carries the permission matrix the entry rules below read. That it is an
198
+ // admin-only document is the reason the matrix can be trusted at all: an editor who
199
+ // could write here could write themselves a wider matrix, and then the rule would be
200
+ // reading its own permission from a document the caller controls.
201
+ match /content/{slug} {
202
+ allow read: if enabled();
203
+ allow create: if isAdmin() && permissionsShapeValid(request.resource.data);
204
+ allow update: if isAdmin()
205
+ && !typeIsLocked(slug)
206
+ && request.resource.data.get('locked', false) == resource.data.get('locked', false)
207
+ && permissionsShapeValid(request.resource.data);
208
+ allow delete: if isAdmin() && !typeIsLocked(slug);
209
+
210
+ // Entries. An author works on their own entries; an editor works on all of them.
211
+ //
212
+ // The public read is the carve-out, and it is what makes a customer's website work.
213
+ // A visitor is unauthenticated: the SDK reads through the same Firestore project with
214
+ // no session, so without this branch every public page would be a permission error.
215
+ // It is restricted to published entries, which is the same guarantee the SDK's
216
+ // published-only default gives, enforced where a client cannot argue with it.
217
+ //
218
+ // `get` and `list` are separated because they need different clauses. A `get` names
219
+ // one document and the rule can look at it; the `resource == null` branch is for a
220
+ // document that does not exist, which returns nothing either way and would otherwise
221
+ // fail as a permission error rather than as "not found". A `list` is checked against
222
+ // the query, so an unauthenticated query is allowed only when it says
223
+ // `status == "published"` itself: an unrestricted one is refused before a document is
224
+ // read. That is why `CmsAdapter.getBySlug` takes a status.
225
+ //
226
+ // Every clause is now three things in a row: the role default from the token, the
227
+ // matrix narrowing on top of it, and the document checks. The published branches are
228
+ // evaluated first so that a public read short-circuits before the matrix lookup and
229
+ // costs no extra document read.
230
+ //
231
+ // The one thing the read matrix genuinely cannot do, stated rather than implied: it
232
+ // cannot hide a published entry, because a published entry is readable by anyone who
233
+ // knows its address by design and that is what makes a customer's website work.
234
+ // Denying `read` hides a type inside the admin and hides its drafts; taking content
235
+ // off the public web is unpublishing it. MATRIX_LIMITS.publicRead says the same thing
236
+ // in the Developer area, in those words.
237
+ match /entries/{entryId} {
238
+ allow get: if resource == null
239
+ || resource.data.status == 'published'
240
+ || (enabled() && matrixAllows(slug, 'read'));
241
+ allow list: if resource.data.status == 'published'
242
+ || (enabled() && matrixAllows(slug, 'read'));
243
+ allow create: if isAuthor()
244
+ && matrixAllows(slug, 'create')
245
+ && authorshipIsHonest()
246
+ && entryStatusValid()
247
+ && entryVersionValid()
248
+ && withinFieldCount(200);
249
+ allow update: if (isEditor() || (isAuthor() && ownsEntry()))
250
+ && matrixAllows(slug, 'update')
251
+ && preservesCreation()
252
+ && entryStatusValid()
253
+ && entryVersionValid()
254
+ && withinFieldCount(200)
255
+ && notTooSoon();
256
+ allow delete: if isEditor() && matrixAllows(slug, 'delete');
257
+ }
258
+ }
259
+
260
+ // Media metadata. The bytes live in Storage and storage.rules polices them; this block
261
+ // polices the document, because the document is what the library lists, what the
262
+ // dashboard sums for its storage figure, and what an entry references. A document is
263
+ // cheap to write and can claim anything, so its shape is checked here rather than
264
+ // trusted from the adapter.
265
+ //
266
+ // The MIME allowlist is the same one as storage.rules and
267
+ // packages/cms/src/core/media-constraints.ts. The ceiling here is the global 25 MB
268
+ // rather than the per-type one: the tighter image limit is enforced where the bytes
269
+ // actually arrive, and repeating a second table here would be a third copy to keep in
270
+ // step for no additional guarantee.
271
+ function mediaFields() {
272
+ return ['name', 'type', 'size', 'url', 'path', 'alt', 'width', 'height',
273
+ 'createdAt', 'createdBy'];
274
+ }
275
+
276
+ function mediaTypeAllowed(type) {
277
+ return type in ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif',
278
+ 'application/pdf', 'video/mp4', 'video/webm',
279
+ 'audio/mpeg', 'audio/ogg', 'audio/wav'];
280
+ }
281
+
282
+ function mediaShapeValid(mediaId) {
283
+ return request.resource.data.keys().hasOnly(mediaFields())
284
+ && request.resource.data.keys().hasAll(mediaFields())
285
+ && request.resource.data.name is string
286
+ && request.resource.data.name.size() > 0
287
+ && mediaTypeAllowed(request.resource.data.type)
288
+ && request.resource.data.size is int
289
+ && request.resource.data.size > 0
290
+ && request.resource.data.size <= 25 * 1024 * 1024
291
+ && request.resource.data.url is string
292
+ && request.resource.data.path is string
293
+ && request.resource.data.path.matches('media/' + mediaId + '/.*')
294
+ && (request.resource.data.alt == null || request.resource.data.alt is string)
295
+ && (request.resource.data.width == null || request.resource.data.width is int)
296
+ && (request.resource.data.height == null || request.resource.data.height is int);
297
+ }
298
+
299
+ match /media/{mediaId} {
300
+ // A single media document is public, the library listing is not. A public page that
301
+ // renders an image needs its alt text and dimensions, and the id has to be known
302
+ // already: it comes from a published entry that references it. The bytes behind it
303
+ // are served by Storage from a download URL that is public in the same way. Listing
304
+ // the library is a different question, because that is every file a customer ever
305
+ // uploaded, referenced or not, and a visitor has no business enumerating it.
306
+ allow get: if true;
307
+ allow list: if enabled();
308
+ allow create: if isAuthor()
309
+ && request.resource.data.createdBy == request.auth.uid
310
+ && mediaShapeValid(mediaId);
311
+ // Alt text, name and, when M7's replace lands, url, size and type may change. The
312
+ // creation record and the object path may not: the path is what ties the document to
313
+ // its bytes, and a document that could be repointed at somebody else's object would
314
+ // make every reference in every entry unreliable.
315
+ //
316
+ // `mediaShapeValid` is already the payload guard for this document: it names every
317
+ // field that may be present and caps the recorded size, so there is no field count
318
+ // to add. The write-rate floor is the same one entries get.
319
+ allow update: if isAuthor()
320
+ && mediaShapeValid(mediaId)
321
+ && request.resource.data.createdBy == resource.data.createdBy
322
+ && request.resource.data.createdAt == resource.data.createdAt
323
+ && request.resource.data.path == resource.data.path
324
+ && notTooSoon();
325
+ allow delete: if isEditor();
326
+ }
327
+
328
+ // A user reads and edits their own profile. Role and disabled are managed by an admin,
329
+ // because a user who could set their own role would be an admin by definition.
330
+ //
331
+ // Self-read does not require a synced claim, so a session whose claim was lost can
332
+ // still read the role it is supposed to have and hand it to claim sync for repair.
333
+ // Reading your own profile grants nothing else.
334
+ //
335
+ // Self-create exists because a new account has to record itself somewhere and only an
336
+ // admin may create anyone else's profile. It is pinned to the lowest role and to
337
+ // enabled, so signing up can never be a way to write yourself a higher one. The first
338
+ // admin of a project does not go through this branch: that account already holds the
339
+ // admin claim it won at settings/bootstrap, so it passes isAdmin().
340
+ match /users/{uid} {
341
+ allow read: if isAdmin() || (signedIn() && request.auth.uid == uid);
342
+ allow create: if isAdmin()
343
+ || (signedIn()
344
+ && request.auth.uid == uid
345
+ && request.resource.data.role == 'author'
346
+ && request.resource.data.disabled == false);
347
+ allow update: if isAdmin()
348
+ || (enabled()
349
+ && request.auth.uid == uid
350
+ && request.resource.data.role == resource.data.role
351
+ && request.resource.data.disabled == resource.data.disabled);
352
+ allow delete: if isAdmin();
353
+ }
354
+
355
+ match /settings/{key} {
356
+ allow read: if enabled();
357
+ // The bootstrap marker is carved out: it is settings by storage only, and an editor
358
+ // who could overwrite it could reopen the first-run window.
359
+ //
360
+ // A settings document holds a handful of keys. The cap is what stops it being used
361
+ // as a general-purpose bucket by anything that can write one, and a delete carries
362
+ // no incoming document to measure, which is why it is spelled out separately.
363
+ allow create, update: if isEditor()
364
+ && key != 'bootstrap'
365
+ && withinFieldCount(100);
366
+ allow delete: if isEditor() && key != 'bootstrap';
367
+ }
368
+
369
+ // First-run marker. Its existence means "this project already has an admin", so the
370
+ // whole guarantee is that it can be written exactly once and then never again.
371
+ //
372
+ // Allows: any signed-in account, claim or no claim, to create it naming itself, once.
373
+ // Denies: every update and every delete, admin included, so the window cannot be
374
+ // reopened; any create naming a different uid; any extra field, so nothing else can be
375
+ // smuggled into a document that no one can later correct.
376
+ //
377
+ // Two simultaneous sign-ups both attempt the create and Firestore commits one of them;
378
+ // the other is evaluated against the now-existing document as an update and denied.
379
+ match /settings/bootstrap {
380
+ allow read: if signedIn();
381
+ allow create: if signedIn()
382
+ && request.resource.data.firstAdminUid == request.auth.uid
383
+ && request.resource.data.keys().hasOnly(['firstAdminUid', 'createdAt']);
384
+ allow update, delete: if false;
385
+ }
386
+
387
+ // Anything not matched above is denied. Adding a collection means adding a rule and a
388
+ // test for it, not relaxing this.
389
+ match /{document=**} {
390
+ allow read, write: if false;
391
+ }
392
+ }
393
+ }
@@ -0,0 +1,165 @@
1
+ rules_version = '2';
2
+
3
+ // Husk storage rules.
4
+ //
5
+ // Media bytes live under media/{mediaId}/{fileName}; the metadata document lives in
6
+ // Firestore at media/{mediaId}. The path is built by storageObjectPath() in
7
+ // packages/cms/src/firebase/paths.ts, so code and rules agree on one shape.
8
+ //
9
+ // The allowlist, the per-type size ceilings and the extensions below are the same set as
10
+ // packages/cms/src/core/media-constraints.ts. Neither file can import the other, so
11
+ // media-constraints.test.ts reads this file and fails when the two drift apart.
12
+ //
13
+ // What these rules actually enforce, stated plainly because a rule nobody can describe is
14
+ // a rule nobody can review:
15
+ //
16
+ // ALLOWED: a signed-in, enabled account with role author, editor or admin may write an
17
+ // object under media/{mediaId}/{fileName} when the declared content type is on the
18
+ // allowlist, the byte count is above zero and within that type's ceiling (10 MB for an
19
+ // image, 25 MB otherwise), the file name matches the extension that type requires, and the
20
+ // object's custom metadata names the writer as uploadedBy. Overwriting an existing object
21
+ // additionally requires being its uploader, or being an editor. An editor or admin may
22
+ // delete any object; an author may delete only their own, which is what lets a failed
23
+ // upload undo itself. Any enabled account may read.
24
+ //
25
+ // DENIED: anonymous access entirely; a disabled account or one with no role claim; every
26
+ // content type outside the allowlist, image/svg+xml among them; a zero-byte object; an
27
+ // oversized one; a name whose extension contradicts the declared type; an object claiming
28
+ // somebody else's uploadedBy; an author overwriting or deleting another account's object;
29
+ // and everything outside media/.
30
+ //
31
+ // WHAT A CLIENT CAN STILL LIE ABOUT: contentType is declared by the uploader and the bucket
32
+ // does not sniff the bytes, so a renamed executable sent as image/png with the name
33
+ // photo.png satisfies every rule here. The extension check does not prove content either;
34
+ // it only forces the lie to be consistent, and it keeps an honest client from writing a
35
+ // name the library would later mis-render. What cannot be faked is request.resource.size,
36
+ // which the service measures, and request.auth.uid, which comes from the verified token.
37
+ // Byte-level validation would need a Cloud Function, which this product does not have; the
38
+ // mitigation that does hold is that objects are served from the Storage host and never from
39
+ // the customer's own origin.
40
+
41
+ service firebase.storage {
42
+ match /b/{bucket}/o {
43
+
44
+ function signedIn() {
45
+ return request.auth != null;
46
+ }
47
+
48
+ function role() {
49
+ return signedIn() ? request.auth.token.get('role', 'none') : 'none';
50
+ }
51
+
52
+ // A role the CMS knows. An account whose claim has not been synced yet reports 'none'
53
+ // and can do nothing, the same fail-closed stance firestore.rules takes.
54
+ function knownRole() {
55
+ return role() == 'admin' || role() == 'editor' || role() == 'author';
56
+ }
57
+
58
+ function enabled() {
59
+ return signedIn() && knownRole() && request.auth.token.get('disabled', false) != true;
60
+ }
61
+
62
+ function isEditor() {
63
+ return enabled() && (role() == 'admin' || role() == 'editor');
64
+ }
65
+
66
+ function isAuthor() {
67
+ return enabled() && (role() == 'admin' || role() == 'editor' || role() == 'author');
68
+ }
69
+
70
+ // MIME type to size ceiling, in megabytes. Membership in this map is the allowlist.
71
+ function sizeLimits() {
72
+ return {
73
+ 'image/jpeg': 10,
74
+ 'image/png': 10,
75
+ 'image/webp': 10,
76
+ 'image/avif': 10,
77
+ 'image/gif': 10,
78
+ 'application/pdf': 25,
79
+ 'video/mp4': 25,
80
+ 'video/webm': 25,
81
+ 'audio/mpeg': 25,
82
+ 'audio/ogg': 25,
83
+ 'audio/wav': 25
84
+ };
85
+ }
86
+
87
+ // MIME type to the file name pattern it requires. matches() is anchored, hence the .*
88
+ function namePatterns() {
89
+ return {
90
+ 'image/jpeg': '.*[.]jpe?g',
91
+ 'image/png': '.*[.]png',
92
+ 'image/webp': '.*[.]webp',
93
+ 'image/avif': '.*[.]avif',
94
+ 'image/gif': '.*[.]gif',
95
+ 'application/pdf': '.*[.]pdf',
96
+ 'video/mp4': '.*[.]mp4',
97
+ 'video/webm': '.*[.]webm',
98
+ 'audio/mpeg': '.*[.]mp3',
99
+ 'audio/ogg': '.*[.]ogg',
100
+ 'audio/wav': '.*[.]wav'
101
+ };
102
+ }
103
+
104
+ function declaredType() {
105
+ return request.resource.contentType;
106
+ }
107
+
108
+ function typeAllowed() {
109
+ return declaredType() != null
110
+ && sizeLimits().keys().hasAny([declaredType()]);
111
+ }
112
+
113
+ // Only ever evaluated after typeAllowed(), because indexing the map with an unknown key
114
+ // is an error rather than a denial. && short-circuits, which is what makes that safe.
115
+ function withinSizeLimit() {
116
+ return request.resource.size > 0
117
+ && request.resource.size <= sizeLimits()[declaredType()] * 1024 * 1024;
118
+ }
119
+
120
+ function nameMatchesType(fileName) {
121
+ return fileName.lower().matches(namePatterns()[declaredType()]);
122
+ }
123
+
124
+ function validUpload(fileName) {
125
+ return typeAllowed() && withinSizeLimit() && nameMatchesType(fileName);
126
+ }
127
+
128
+ // The writer names themselves in the object's custom metadata. Set by
129
+ // createMediaUpload() in packages/cms/src/firebase/media-upload.ts.
130
+ function namesSelf() {
131
+ return request.resource.metadata != null
132
+ && request.resource.metadata.get('uploadedBy', '') == request.auth.uid;
133
+ }
134
+
135
+ function ownsExisting() {
136
+ return resource != null
137
+ && resource.metadata != null
138
+ && resource.metadata.get('uploadedBy', '') == request.auth.uid;
139
+ }
140
+
141
+ // create and update share one condition on purpose. A resumable upload over an object
142
+ // that already exists is not reliably classified as an update by the emulator, so the
143
+ // ownership check is written into both branches rather than relying on which one fires.
144
+ // A retry overwrites its own object, and M7's replace flow overwrites an editor's;
145
+ // nobody else's bytes can be replaced from under them either way.
146
+ function mayWrite(fileName) {
147
+ return isAuthor()
148
+ && namesSelf()
149
+ && validUpload(fileName)
150
+ && (resource == null || isEditor() || ownsExisting());
151
+ }
152
+
153
+ match /media/{mediaId}/{fileName} {
154
+ allow read: if enabled();
155
+ allow create, update: if mayWrite(fileName);
156
+ // An author deleting their own object is not a library delete; it is how a failed
157
+ // upload cleans up after itself before any document exists to delete.
158
+ allow delete: if isEditor() || (isAuthor() && ownsExisting());
159
+ }
160
+
161
+ match /{allPaths=**} {
162
+ allow read, write: if false;
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,27 @@
1
+ import { f as ContentTypeMap, d as CmsReadConfig, C as Cms } from '../cms-9fSA3uQV.js';
2
+ export { b as CmsCountQuery, c as CmsQuery, e as CollectionReader, E as EntryOf, M as MediaReader, S as SettingsReader, g as SingleReader } from '../cms-9fSA3uQV.js';
3
+ import '../entry-D3kiE8T1.js';
4
+ import '../field-Diyf5siF.js';
5
+ import '../auth-B2gBtHl5.js';
6
+ import '../adapter-C_0mhENy.js';
7
+
8
+ /**
9
+ * The `@nonext/husk/client` surface: the read SDK for browser code.
10
+ *
11
+ * Same verbs as the server entry, one deliberate omission: there is no preview. A browser
12
+ * instance reads published entries and nothing else.
13
+ *
14
+ * That is not a limitation the client happens to have, it is the point. Preview reads
15
+ * unpublished content, so whatever decides to enable it is a security control; a value that
16
+ * reaches the browser has been through the network and is whatever the visitor made it. The
17
+ * config type below has no `preview` key at all, so passing one is a compile error rather
18
+ * than a code review question, and the returned `Cms` has no method that could turn one on.
19
+ *
20
+ * The admin is not a consumer of this. It reads and writes through `CmsAdapter` directly,
21
+ * with a signed-in user and the whole permission layer, which is how it sees drafts without
22
+ * this entry needing to.
23
+ */
24
+ type ClientCmsConfig<TTypes extends ContentTypeMap = ContentTypeMap> = CmsReadConfig<TTypes>;
25
+ declare function createClientCMS<TTypes extends ContentTypeMap = ContentTypeMap>(config: ClientCmsConfig<TTypes>): Cms<TTypes>;
26
+
27
+ export { type ClientCmsConfig, Cms, ContentTypeMap, createClientCMS };
@@ -0,0 +1,15 @@
1
+ import { createCMS } from '../chunk-3I6337WJ.js';
2
+ import '../chunk-R4O7APIX.js';
3
+ import '../chunk-4Z6P3XCE.js';
4
+
5
+ // src/client/index.ts
6
+ function createClientCMS(config) {
7
+ const contentTypes = config.contentTypes;
8
+ return createCMS(
9
+ contentTypes === void 0 ? { adapter: config.adapter } : { adapter: config.adapter, contentTypes }
10
+ );
11
+ }
12
+
13
+ export { createClientCMS };
14
+ //# sourceMappingURL=index.js.map
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/index.ts"],"names":[],"mappings":";;;;;AAuBO,SAAS,gBACd,MAAA,EACa;AAGb,EAAA,MAAM,eAAe,MAAA,CAAO,YAAA;AAC5B,EAAA,OAAO,SAAA;AAAA,IACL,YAAA,KAAiB,MAAA,GACb,EAAE,OAAA,EAAS,MAAA,CAAO,OAAA,EAAQ,GAC1B,EAAE,OAAA,EAAS,MAAA,CAAO,OAAA,EAAS,YAAA;AAAa,GAC9C;AACF","file":"index.js","sourcesContent":["import { createCMS } from \"../core/cms.js\"\nimport type { Cms, CmsReadConfig, ContentTypeMap } from \"../core/cms.js\"\n\n/**\n * The `@nonext/husk/client` surface: the read SDK for browser code.\n *\n * Same verbs as the server entry, one deliberate omission: there is no preview. A browser\n * instance reads published entries and nothing else.\n *\n * That is not a limitation the client happens to have, it is the point. Preview reads\n * unpublished content, so whatever decides to enable it is a security control; a value that\n * reaches the browser has been through the network and is whatever the visitor made it. The\n * config type below has no `preview` key at all, so passing one is a compile error rather\n * than a code review question, and the returned `Cms` has no method that could turn one on.\n *\n * The admin is not a consumer of this. It reads and writes through `CmsAdapter` directly,\n * with a signed-in user and the whole permission layer, which is how it sees drafts without\n * this entry needing to.\n */\n\nexport type ClientCmsConfig<TTypes extends ContentTypeMap = ContentTypeMap> =\n CmsReadConfig<TTypes>\n\nexport function createClientCMS<TTypes extends ContentTypeMap = ContentTypeMap>(\n config: ClientCmsConfig<TTypes>,\n): Cms<TTypes> {\n // Never spreads the config. An extra `preview` that arrived through a widened type would\n // be ignored here rather than forwarded, so the omission survives a caller that lies.\n const contentTypes = config.contentTypes\n return createCMS<TTypes>(\n contentTypes === undefined\n ? { adapter: config.adapter }\n : { adapter: config.adapter, contentTypes },\n )\n}\n\nexport type {\n Cms,\n CmsCountQuery,\n CmsQuery,\n CollectionReader,\n ContentTypeMap,\n EntryOf,\n MediaReader,\n SettingsReader,\n SingleReader,\n} from \"../core/cms.js\"\n"]}