@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,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"]}
|