@mulmoclaude/core 3.13.0 → 3.15.0
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/dist/collection/registry/server/index.cjs +23 -6
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +24 -7
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/appViews.d.ts +136 -0
- package/dist/collection/server/index.cjs +14 -1
- package/dist/collection/server/index.d.ts +2 -1
- package/dist/collection/server/index.js +2 -2
- package/dist/collection/server/publishManifest.d.ts +11 -1
- package/dist/collection/server/publishProject.d.ts +61 -0
- package/dist/collection-watchers/index.cjs +1 -1
- package/dist/collection-watchers/index.js +1 -1
- package/dist/feeds/server/index.cjs +1 -1
- package/dist/feeds/server/index.js +1 -1
- package/dist/{server-_g1rW-Uc.cjs → server-DLzQ4jFu.cjs} +500 -22
- package/dist/server-DLzQ4jFu.cjs.map +1 -0
- package/dist/{server-DGjmK9vU.js → server-jm7aGA3g.js} +423 -23
- package/dist/server-jm7aGA3g.js.map +1 -0
- package/package.json +1 -1
- package/dist/server-DGjmK9vU.js.map +0 -1
- package/dist/server-_g1rW-Uc.cjs.map +0 -1
|
@@ -13,6 +13,250 @@ node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
|
13
13
|
let node_crypto = require("node:crypto");
|
|
14
14
|
let node_fs_promises = require("node:fs/promises");
|
|
15
15
|
let zod = require("zod");
|
|
16
|
+
//#region src/collection/server/appViews.ts
|
|
17
|
+
/** The audiences a view may be written for. A CLOSED set: each one names a
|
|
18
|
+
* tier with a rule behind it, so an unknown value has nowhere to be
|
|
19
|
+
* published to and is refused before it gets there. */
|
|
20
|
+
var VIEW_AUDIENCES = [
|
|
21
|
+
"public",
|
|
22
|
+
"member",
|
|
23
|
+
"participant"
|
|
24
|
+
];
|
|
25
|
+
/** Where each audience's documents live under `apps/{aid}`. `public` is not
|
|
26
|
+
* here: it keeps `config/public` + `config/view`, which are already published
|
|
27
|
+
* and already read by a deployed runtime. */
|
|
28
|
+
var VIEW_TIER = {
|
|
29
|
+
member: "member",
|
|
30
|
+
participant: "roster"
|
|
31
|
+
};
|
|
32
|
+
/** The id `public.view` normalizes to. Fixed rather than derived, so two
|
|
33
|
+
* implementations of the same normalization cannot pick different ones. */
|
|
34
|
+
var PUBLIC_VIEW_ID = "public";
|
|
35
|
+
/** `config` is the projection's own document in every tier (`live:config`),
|
|
36
|
+
* so a view may not be called that — the two would be the same document. */
|
|
37
|
+
var RESERVED_VIEW_IDS = ["config"];
|
|
38
|
+
/** What an id may be.
|
|
39
|
+
*
|
|
40
|
+
* Narrow on purpose: this value is written by the author, and it becomes a
|
|
41
|
+
* Firestore document id under a `live:` / `staged:` prefix. Excluding `:`
|
|
42
|
+
* keeps the prefix and the id from running together; excluding `/`, `.` and
|
|
43
|
+
* `__…__` keeps it a legal document id that addresses the path it says. */
|
|
44
|
+
var VIEW_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
45
|
+
var VIEW_ID_SHAPE = "must be lowercase letters, digits and hyphens, start with a letter or digit, and be at most 64 characters (e.g. front-desk)";
|
|
46
|
+
/** Both declarations of the same thing, in one file.
|
|
47
|
+
*
|
|
48
|
+
* Refused rather than merged or preferred: whichever way it were resolved,
|
|
49
|
+
* the author would have written two answers and been shown neither. */
|
|
50
|
+
var BOTH_FORMS = "app.json declares both `views` and `public.view`. These are the same thing — `public.view` is the older spelling — and publishing would have to choose one silently. Move the `public.view` entry into `views` as { id: \"public\", audience: \"public\", … } and delete it.";
|
|
51
|
+
/** The two declarations, as one list, or the refusal that they are both there.
|
|
52
|
+
*
|
|
53
|
+
* `public.view` becomes an entry under the reserved id, so everything
|
|
54
|
+
* downstream reads one shape and "which spelling was used" is decided once. */
|
|
55
|
+
function declaredViews(app) {
|
|
56
|
+
const legacy = app.public?.view;
|
|
57
|
+
const authored = app.views;
|
|
58
|
+
if (legacy !== void 0 && authored !== void 0) return {
|
|
59
|
+
ok: false,
|
|
60
|
+
problems: [BOTH_FORMS]
|
|
61
|
+
};
|
|
62
|
+
const views = (authored ?? []).map((view, index) => ({
|
|
63
|
+
id: view.id,
|
|
64
|
+
audience: view.audience,
|
|
65
|
+
path: view.path,
|
|
66
|
+
collections: view.collections,
|
|
67
|
+
where: `views[${index}]`
|
|
68
|
+
}));
|
|
69
|
+
if (legacy === void 0) return {
|
|
70
|
+
ok: true,
|
|
71
|
+
views
|
|
72
|
+
};
|
|
73
|
+
if (views.some((view) => view.id === "public")) return {
|
|
74
|
+
ok: false,
|
|
75
|
+
problems: [`views declares id '${PUBLIC_VIEW_ID}', which is reserved for the older \`public.view\` spelling. ${BOTH_FORMS}`]
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
views: [...views, {
|
|
80
|
+
id: PUBLIC_VIEW_ID,
|
|
81
|
+
audience: "public",
|
|
82
|
+
path: legacy.path,
|
|
83
|
+
collections: legacy.collections,
|
|
84
|
+
where: "public.view"
|
|
85
|
+
}]
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Whether one id may be used, and what to say when it may not. */
|
|
89
|
+
function viewIdProblems(view) {
|
|
90
|
+
if (RESERVED_VIEW_IDS.includes(view.id)) return [`${view.where}.id is '${view.id}', which is reserved: each audience's own declaration is published at that document id.`];
|
|
91
|
+
if (!VIEW_ID_PATTERN.test(view.id)) return [`${view.where}.id is '${view.id}': a view id ${VIEW_ID_SHAPE}. It becomes the document id this view is published at.`];
|
|
92
|
+
if (view.id === "public" && view.audience !== "public") return [`${view.where}.id is '${PUBLIC_VIEW_ID}' with audience '${view.audience}': that id belongs to the public page.`];
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
/** ONE public page per app, and the reason is the wire rather than taste.
|
|
96
|
+
*
|
|
97
|
+
* The public runtime reads a single `config/view` document and a single
|
|
98
|
+
* `config/public.view` declaration beside it. A second `audience: "public"`
|
|
99
|
+
* entry would pass every other check and then be published nowhere — and
|
|
100
|
+
* which of the two became the live page would depend on declaration order,
|
|
101
|
+
* silently. The member tiers have no such limit: `id` is their address, and
|
|
102
|
+
* each one gets its own document.
|
|
103
|
+
*
|
|
104
|
+
* The refusal is here rather than "one day we will support it" precisely
|
|
105
|
+
* because the failure is invisible: nothing errors, and the author sees a
|
|
106
|
+
* successful publish of a page nobody is served. */
|
|
107
|
+
function singlePublicProblems(views) {
|
|
108
|
+
const [first, ...rest] = views.filter((view) => view.audience === "public");
|
|
109
|
+
if (first === void 0) return [];
|
|
110
|
+
return rest.map((view) => `${view.where} is a second audience "public" view, after ${first.where}. The public page is published at ONE document (config/view), so only one of them could ever be served — and which, would depend on the order they were written in. Give the others audience "member" or "participant", which are addressed by id and may have as many as the app needs.`);
|
|
111
|
+
}
|
|
112
|
+
/** The one shape everything downstream reads.
|
|
113
|
+
*
|
|
114
|
+
* Every caller — the publish gate, the projection, the host that writes the
|
|
115
|
+
* documents — goes through this, so "which declaration was used" is decided
|
|
116
|
+
* exactly once. */
|
|
117
|
+
function normalizeViews(app) {
|
|
118
|
+
const declared = declaredViews(app);
|
|
119
|
+
if (!declared.ok) return declared;
|
|
120
|
+
const problems = [...singlePublicProblems(declared.views)];
|
|
121
|
+
const seen = /* @__PURE__ */ new Map();
|
|
122
|
+
for (const view of declared.views) {
|
|
123
|
+
problems.push(...viewIdProblems(view));
|
|
124
|
+
const first = seen.get(view.id);
|
|
125
|
+
if (first === void 0) {
|
|
126
|
+
seen.set(view.id, view.where);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
problems.push(`${view.where}.id is '${view.id}', which ${first} already uses. The id is the document a view is published at, so two of them are one page — whichever was written second would silently replace the first, in staging and again at publish.`);
|
|
130
|
+
}
|
|
131
|
+
return problems.length > 0 ? {
|
|
132
|
+
ok: false,
|
|
133
|
+
problems
|
|
134
|
+
} : {
|
|
135
|
+
ok: true,
|
|
136
|
+
views: declared.views
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** How a participant reaches `cid`, or null if they cannot.
|
|
140
|
+
*
|
|
141
|
+
* Mirrors the rules' read branches for someone holding no role:
|
|
142
|
+
* `partRead` (the whole collection) and `ownRow` (their own record, found
|
|
143
|
+
* by the submit declaration's `emailField` or by a uid-derived id).
|
|
144
|
+
*
|
|
145
|
+
* `participantRead` is a PARAMETER rather than read off the manifest, and
|
|
146
|
+
* that is the whole point of the signature. Publish does not promote the
|
|
147
|
+
* manifest's value: `projectPublish` overwrites `participantRead` with what
|
|
148
|
+
* the STAGED schemas carry, so a cid added since the last deploy is in the
|
|
149
|
+
* manifest and not in the rules. Deriving the scope from the manifest would
|
|
150
|
+
* publish `scope: "all"` for a collection the promoted rules then deny —
|
|
151
|
+
* and removing one gives the mirror-image false refusal. The caller passes
|
|
152
|
+
* the set that will actually be in force. */
|
|
153
|
+
function participantScope(app, cid, participantRead) {
|
|
154
|
+
if (participantRead.includes(cid)) return {
|
|
155
|
+
cid,
|
|
156
|
+
scope: "all"
|
|
157
|
+
};
|
|
158
|
+
const submit = app.public?.submit?.[cid];
|
|
159
|
+
if (submit?.emailField !== void 0) return {
|
|
160
|
+
cid,
|
|
161
|
+
scope: "own",
|
|
162
|
+
emailField: submit.emailField
|
|
163
|
+
};
|
|
164
|
+
if (submit?.idFrom === "auth.uid") return {
|
|
165
|
+
cid,
|
|
166
|
+
scope: "own",
|
|
167
|
+
ownDocId: "auth.uid"
|
|
168
|
+
};
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/** The document ids one tier uses. `live:` and `staged:` are the only two
|
|
172
|
+
* prefixes, so a single `match` covers the projection and every view. */
|
|
173
|
+
var viewDocId = (stage, viewId) => `${stage}:${viewId}`;
|
|
174
|
+
var VIEW_CONFIG_ID = "config";
|
|
175
|
+
/** The role a member holds on one collection, by the rules' own resolution:
|
|
176
|
+
* the per-collection entry, else the `*` fallback, else none. */
|
|
177
|
+
function roleOn(app, address, cid) {
|
|
178
|
+
const held = app.members[address];
|
|
179
|
+
if (held === void 0) return void 0;
|
|
180
|
+
return held[cid] ?? held["*"];
|
|
181
|
+
}
|
|
182
|
+
/** The addresses holding one of `roles` on `cid`.
|
|
183
|
+
*
|
|
184
|
+
* Sorted, for the same reason `memberEmails` is: a second publish of an
|
|
185
|
+
* unchanged declaration must produce an unchanged document. */
|
|
186
|
+
function holdersOf(app, cid, roles) {
|
|
187
|
+
return Object.keys(app.members).filter((address) => roles.includes(roleOn(app, address, cid) ?? "")).sort();
|
|
188
|
+
}
|
|
189
|
+
/** Who may write every row of `cid`, and who may write only their own.
|
|
190
|
+
*
|
|
191
|
+
* WHY ADDRESSES ARE PUBLISHED AT ALL. One `member/config` document is read by
|
|
192
|
+
* everyone the tier admits, and the tier only establishes that somebody holds
|
|
193
|
+
* SOME role SOMEWHERE — so a `viewer`, or a stylist scoped to another
|
|
194
|
+
* collection, reads the same entry as the front desk. Without these lists the
|
|
195
|
+
* page would draw approve and reassign for all of them and the rules would
|
|
196
|
+
* refuse when pressed, which is the declaration/enforcement mismatch this
|
|
197
|
+
* whole mechanism exists to prevent.
|
|
198
|
+
*
|
|
199
|
+
* It cannot be answered per principal instead: the document is written once
|
|
200
|
+
* at publish and read by many, and the reader cannot look their own role up —
|
|
201
|
+
* `apps/{aid}` is `readerOf(a, '*')`, and a stylist carrying only
|
|
202
|
+
* `{bookings: "editor"}` holds no `*` role. So the ROSTER'S ANSWER travels
|
|
203
|
+
* with the declaration and the page compares its own address to it.
|
|
204
|
+
*
|
|
205
|
+
* The cost is that staff addresses are visible to staff. That is already true
|
|
206
|
+
* of the approval mail they send each other, and participants read the
|
|
207
|
+
* `roster` tier, which never carries these.
|
|
208
|
+
*
|
|
209
|
+
* A SNAPSHOT, like everything else published: a member added since the last
|
|
210
|
+
* publish is absent until the next one. The rules are the authority either
|
|
211
|
+
* way — this only decides which buttons are drawn. */
|
|
212
|
+
function writersOf(app, cid) {
|
|
213
|
+
return holdersOf(app, cid, ["owner", "editor"]);
|
|
214
|
+
}
|
|
215
|
+
/** The transition half: which table applies, and the field it moves.
|
|
216
|
+
*
|
|
217
|
+
* Both halves or neither. A status field with no table would offer every
|
|
218
|
+
* value; a table with no field has nothing to write it to. */
|
|
219
|
+
function transitionPart(app, audience, cid) {
|
|
220
|
+
const config = app.collections?.[cid];
|
|
221
|
+
const transitions = audience === "member" ? config?.transitions : app.public?.submit?.[cid]?.selfTransitions;
|
|
222
|
+
if (config?.statusField === void 0 || transitions === void 0) return {};
|
|
223
|
+
const part = {
|
|
224
|
+
statusField: config.statusField,
|
|
225
|
+
transitions
|
|
226
|
+
};
|
|
227
|
+
if (audience === "member" && config.mail !== void 0) part.mail = config.mail;
|
|
228
|
+
return part;
|
|
229
|
+
}
|
|
230
|
+
/** The assignment half. `member` only — see {@link writersOf}.
|
|
231
|
+
*
|
|
232
|
+
* `rowWriters` rides here rather than beside `writers`, because the
|
|
233
|
+
* `assignee` role grants nothing at all without a field to compare against
|
|
234
|
+
* (`isAssigned` in the rules requires one, and publish refuses the pair). */
|
|
235
|
+
function assignPart(app, audience, cid) {
|
|
236
|
+
const assigneeField = app.collections?.[cid]?.assigneeField;
|
|
237
|
+
if (audience !== "member" || assigneeField === void 0) return {};
|
|
238
|
+
return {
|
|
239
|
+
assigneeField,
|
|
240
|
+
rowWriters: holdersOf(app, cid, ["assignee"])
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** What `audience` may change about `cid`, or null when the answer is nothing.
|
|
244
|
+
*
|
|
245
|
+
* The two audiences differ in WHICH transition table applies, in whether
|
|
246
|
+
* assignment exists at all, and in whether the roster's answer travels with
|
|
247
|
+
* it; they agree that the status field is the collection's, since the rules
|
|
248
|
+
* read one field either way. */
|
|
249
|
+
function writeFor(app, audience, cid) {
|
|
250
|
+
const write = {
|
|
251
|
+
cid,
|
|
252
|
+
...transitionPart(app, audience, cid),
|
|
253
|
+
...assignPart(app, audience, cid)
|
|
254
|
+
};
|
|
255
|
+
if (Object.keys(write).length === 1) return null;
|
|
256
|
+
if (audience === "member") write.writers = writersOf(app, cid);
|
|
257
|
+
return write;
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
16
260
|
//#region src/collection/server/publishManifest.ts
|
|
17
261
|
/** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
|
|
18
262
|
* that `sharedCollectionKey` applies. Stated once so a path built later
|
|
@@ -266,6 +510,28 @@ var PublicZ = zod.z.object({
|
|
|
266
510
|
}).strict().optional(),
|
|
267
511
|
submit: zod.z.record(NameZ, SubmitZ).optional()
|
|
268
512
|
}).strict();
|
|
513
|
+
/** One page the app shows, and who it is for.
|
|
514
|
+
*
|
|
515
|
+
* Generalised from `public.view` (which is still accepted, and normalizes
|
|
516
|
+
* into this — see `appViews.ts`). The audience is what decides which document
|
|
517
|
+
* the HTML is published to, and therefore who may read it: a rule cannot hide
|
|
518
|
+
* a field, so "the front desk sees this" is a place, not a filter.
|
|
519
|
+
*
|
|
520
|
+
* `id` is not decoration. It becomes the document id the page is published
|
|
521
|
+
* at, which is what lets one audience have more than one page — the front
|
|
522
|
+
* desk and the stock room — and what lets a withdrawn view be found and
|
|
523
|
+
* deleted. Its grammar is enforced at the gate, not here, so the refusal can
|
|
524
|
+
* say what the value is used for.
|
|
525
|
+
*
|
|
526
|
+
* `collections` is declared rather than inferred, for the reason `public.view`
|
|
527
|
+
* gives: an inferred list renders a perfect page with no data in it, and
|
|
528
|
+
* nothing anywhere says why. */
|
|
529
|
+
var ViewZ = zod.z.object({
|
|
530
|
+
id: zod.z.string().trim().min(1),
|
|
531
|
+
audience: zod.z.enum(VIEW_AUDIENCES),
|
|
532
|
+
path: zod.z.string().trim().min(1),
|
|
533
|
+
collections: zod.z.array(NameZ).min(1)
|
|
534
|
+
}).strict();
|
|
269
535
|
/** The URL name an app is handed out under: `https://<host>/{slug}`.
|
|
270
536
|
*
|
|
271
537
|
* A SEPARATE name from the `aid`, and that separation is the point (design
|
|
@@ -314,7 +580,10 @@ var AuthoredAppZ = zod.z.object({
|
|
|
314
580
|
members: MembersZ,
|
|
315
581
|
collections: zod.z.record(NameZ, CollectionConfigZ).optional(),
|
|
316
582
|
participantRead: zod.z.array(NameZ).optional(),
|
|
317
|
-
public: PublicZ.optional()
|
|
583
|
+
public: PublicZ.optional(),
|
|
584
|
+
/** The app's pages, per audience. See {@link ViewZ}; `public.view` is the
|
|
585
|
+
* older spelling of the `public` one and normalizes into this list. */
|
|
586
|
+
views: zod.z.array(ViewZ).optional()
|
|
318
587
|
}).strict();
|
|
319
588
|
/** Parse the authored declaration out of `app.json`'s text.
|
|
320
589
|
*
|
|
@@ -443,11 +712,14 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
443
712
|
publishedDirty: stamp.dirty === true ? true : void 0,
|
|
444
713
|
previousPublished: previousOf(existing)
|
|
445
714
|
});
|
|
715
|
+
const normalized = normalizeViews(authored);
|
|
716
|
+
if (!normalized.ok) throw new Error(`publish: views declaration is not publishable (${normalized.problems.join(" ")})`);
|
|
717
|
+
const publicView = normalized.views.find((view) => view.audience === "public");
|
|
446
718
|
const config = {
|
|
447
719
|
enabled: authored.public?.enabled === true,
|
|
448
720
|
read: authored.public?.read ?? [],
|
|
449
721
|
submit,
|
|
450
|
-
...
|
|
722
|
+
...publicView === void 0 ? {} : { view: { collections: publicView.collections } },
|
|
451
723
|
publishedAt: stamp.publishedAt
|
|
452
724
|
};
|
|
453
725
|
if (authored.name !== void 0) config.name = authored.name;
|
|
@@ -598,6 +870,86 @@ var appSlugDoc = (aid, published) => ({
|
|
|
598
870
|
var appStagingPath = (aid) => `apps/${aid}/staging`;
|
|
599
871
|
/** The public-config documents' parent path. */
|
|
600
872
|
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
873
|
+
/** Where one audience's pages live. `member` is read by anyone holding a role;
|
|
874
|
+
* `roster` by anyone on the roster, participants included. */
|
|
875
|
+
var appViewTierPath = (aid, tier) => `apps/${aid}/${tier}`;
|
|
876
|
+
/** What one audience may see of one collection.
|
|
877
|
+
*
|
|
878
|
+
* For `member` this is always the whole collection: every read branch a role
|
|
879
|
+
* opens (`readerOf`) is unscoped. Whether THIS member holds the role is not
|
|
880
|
+
* knowable here — one projection is read by every member of the tier — and is
|
|
881
|
+
* settled where it can be, by the entrance trying the read.
|
|
882
|
+
*
|
|
883
|
+
* For `participant` it is the rules' own answer, which is why it can be null:
|
|
884
|
+
* a participant with neither `participantRead` nor an own-row submit path
|
|
885
|
+
* cannot read the collection at all, and a page handed it would fail rather
|
|
886
|
+
* than render less. */
|
|
887
|
+
function scopeFor(authored, audience, cid, participantRead) {
|
|
888
|
+
return audience === "member" ? {
|
|
889
|
+
cid,
|
|
890
|
+
scope: "all"
|
|
891
|
+
} : participantScope(authored, cid, participantRead);
|
|
892
|
+
}
|
|
893
|
+
/** What this audience may CHANGE, per collection it draws.
|
|
894
|
+
*
|
|
895
|
+
* The `collections` config is the PROMOTED one where there is one: at publish
|
|
896
|
+
* the rules run against what deploy staged, so projecting the manifest's
|
|
897
|
+
* would advertise transitions the live rules deny. */
|
|
898
|
+
function tierWrites(authored, audience, cids, promoted) {
|
|
899
|
+
const effective = promoted.collections === void 0 ? authored : {
|
|
900
|
+
...authored,
|
|
901
|
+
collections: promoted.collections
|
|
902
|
+
};
|
|
903
|
+
return cids.map((cid) => writeFor(effective, audience, cid)).filter((entry) => entry !== null);
|
|
904
|
+
}
|
|
905
|
+
/** What this audience may READ, and how to query for it.
|
|
906
|
+
*
|
|
907
|
+
* A collection with no scope is dropped rather than published as unreachable:
|
|
908
|
+
* the gate has already refused the declaration, so reaching here with one is
|
|
909
|
+
* a programming error, and a page that queries it is denied. */
|
|
910
|
+
function tierViews(authored, audience, views, participantRead) {
|
|
911
|
+
return views.map((view) => ({
|
|
912
|
+
id: view.id,
|
|
913
|
+
collections: view.collections.map((cid) => scopeFor(authored, audience, cid, participantRead)).filter((scope) => scope !== null)
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
/** One tier's projection: what this audience may read, and what it may change. */
|
|
917
|
+
function tierConfig(authored, audience, views, stamp, promoted) {
|
|
918
|
+
const cids = [...new Set(views.flatMap((view) => view.collections))];
|
|
919
|
+
const config = {
|
|
920
|
+
write: tierWrites(authored, audience, cids, promoted),
|
|
921
|
+
views: tierViews(authored, audience, views, promoted.participantRead ?? authored.participantRead ?? []),
|
|
922
|
+
submit: tierSubmit(authored, cids),
|
|
923
|
+
publishedAt: stamp.publishedAt
|
|
924
|
+
};
|
|
925
|
+
if (authored.name !== void 0) config.name = authored.name;
|
|
926
|
+
return config;
|
|
927
|
+
}
|
|
928
|
+
/** The submit declarations for the collections these views draw, so a page can
|
|
929
|
+
* show what may be sent rather than discovering it from a denial. */
|
|
930
|
+
function tierSubmit(authored, cids) {
|
|
931
|
+
const declared = authored.public?.submit ?? {};
|
|
932
|
+
return Object.fromEntries(cids.flatMap((cid) => {
|
|
933
|
+
const spec = declared[cid];
|
|
934
|
+
return spec === void 0 ? [] : [[cid, projectSubmit(spec)]];
|
|
935
|
+
}));
|
|
936
|
+
}
|
|
937
|
+
function projectAppViews(authored, stamp, promoted = {}) {
|
|
938
|
+
const normalized = normalizeViews(authored);
|
|
939
|
+
if (!normalized.ok) throw new Error(`publish: views declaration is not publishable (${normalized.problems.join(" ")})`);
|
|
940
|
+
return ["member", "participant"].map((audience) => {
|
|
941
|
+
const views = normalized.views.filter((view) => view.audience === audience);
|
|
942
|
+
return {
|
|
943
|
+
tier: VIEW_TIER[audience],
|
|
944
|
+
audience,
|
|
945
|
+
config: tierConfig(authored, audience, views, stamp, promoted),
|
|
946
|
+
views
|
|
947
|
+
};
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
/** The document a tier's projection is published at. Beside the views
|
|
951
|
+
* themselves, under one `match` — see `firestore.rules`. */
|
|
952
|
+
var viewConfigDocId = (stage) => viewDocId(stage, VIEW_CONFIG_ID);
|
|
601
953
|
//#endregion
|
|
602
954
|
//#region src/collection/server/publishChecks.ts
|
|
603
955
|
/** Does this submit declaration bind a record to the submitter's identity?
|
|
@@ -873,7 +1225,7 @@ function publishProblems(app, collections, publisherEmail) {
|
|
|
873
1225
|
...windowRefProblems(app, collections),
|
|
874
1226
|
...idTargetProblems(app, collections),
|
|
875
1227
|
...mirrorProblems(app, collections),
|
|
876
|
-
...
|
|
1228
|
+
...viewProblems(app, collections)
|
|
877
1229
|
];
|
|
878
1230
|
}
|
|
879
1231
|
/** A public submission must NOT be allowed to name its own primary key.
|
|
@@ -1004,24 +1356,56 @@ function mirrorOfProblems(app, cid, collection, known, names) {
|
|
|
1004
1356
|
if (app.public?.submit?.[authority]?.mirror !== cid) return [`collections.${cid}.mirrorOf names '${authority}', but public.submit.${authority} does not declare mirror: "${cid}". Only the pair keeps the projection honest: without the other half a record can be created without moving this one, and the public page goes on offering something that is already taken.`];
|
|
1005
1357
|
return [];
|
|
1006
1358
|
}
|
|
1007
|
-
/** What
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1359
|
+
/** What each view is handed, and whether its audience can actually read it.
|
|
1360
|
+
*
|
|
1361
|
+
* Declared, never inferred — a view whose datasets were guessed from
|
|
1362
|
+
* `public.read` renders perfectly and draws an empty grid, with nothing in
|
|
1363
|
+
* the page, the rules or the log to say why.
|
|
1364
|
+
*
|
|
1365
|
+
* The reachability check below is that same failure, once per audience. A
|
|
1366
|
+
* `public` view naming a collection outside `public.read` draws nothing; a
|
|
1367
|
+
* `participant` view naming one the participant reaches by neither
|
|
1368
|
+
* `participantRead` nor their own row is worse, because an unscoped list on
|
|
1369
|
+
* an own-row collection is DENIED rather than narrowed — the page does not
|
|
1370
|
+
* render less, it fails.
|
|
1371
|
+
*
|
|
1372
|
+
* There is deliberately no such check for `member`. Every read a role opens
|
|
1373
|
+
* is unscoped, and WHICH role a given member holds is not a property of the
|
|
1374
|
+
* declaration: a stylist scoped to `bookings` and an owner read the same
|
|
1375
|
+
* projection. That one is settled at the entrance, by trying the read. */
|
|
1376
|
+
/** The path, for one view. The SAME validator the host's own custom views use,
|
|
1377
|
+
* rather than a second opinion about what a safe view path is.
|
|
1378
|
+
*
|
|
1379
|
+
* Two ad-hoc attempts were wrong here in the same afternoon: a prefix-and-
|
|
1380
|
+
* suffix test let `views/../../secrets.html` through, and `views/[^/]+\.html`
|
|
1381
|
+
* still let `views/..\..\secrets.html` through, because a backslash is not a
|
|
1382
|
+
* slash on this side of the check and IS a separator on Windows. This one
|
|
1383
|
+
* rejects `..`, backslashes, leading slashes and anything outside
|
|
1384
|
+
* `[A-Za-z0-9._-]` per segment.
|
|
1385
|
+
*
|
|
1386
|
+
* It matters more here than for a host view: the host reads this path to
|
|
1387
|
+
* decide which file to copy onto a document other people read — for a public
|
|
1388
|
+
* view, a document whose rule is `allow read: if true`, so the blast radius of
|
|
1389
|
+
* a bad path is the world rather than the author's own iframe. Nested paths
|
|
1390
|
+
* ARE allowed by the shared validator; the extra `views/<one name>.html` shape
|
|
1391
|
+
* is this publisher's own narrowing, kept because there is no reason for a
|
|
1392
|
+
* published view to live in a subdirectory. */
|
|
1393
|
+
function viewPathProblems(view) {
|
|
1394
|
+
if (require_templatePath.isSafeCustomViewPath(view.path) && view.path.split("/").length === 2) return [];
|
|
1395
|
+
return [`${view.where}.path is '${view.path}': a published view is exactly one HTML file directly inside the collection's own views/ directory (e.g. views/booking.html) — no sub-directories, and no segments that climb out of it. The host reads this as a file to publish.`];
|
|
1396
|
+
}
|
|
1397
|
+
/** One dataset, for one view: does it exist here, and can the audience it is
|
|
1398
|
+
* handed to actually read it? */
|
|
1399
|
+
function viewCollectionProblems(app, view, cid, known) {
|
|
1400
|
+
if (!known.has(cid)) return [`${view.where}.collections names '${cid}', which is not a shared collection in this repository. Shared collections here: ${known.size > 0 ? [...known].sort().join(", ") : "(none)"}.`];
|
|
1401
|
+
if (view.audience === "public" && !(app.public?.read ?? []).includes(cid)) return [`${view.where}.collections names '${cid}', which is not in public.read: the page reads these with the VISITOR's permissions, so the rules refuse the read and the view draws an empty page. Nothing errors — this is the failure that looks like a working view with no data.`];
|
|
1402
|
+
return [];
|
|
1403
|
+
}
|
|
1404
|
+
function viewProblems(app, collections) {
|
|
1405
|
+
const normalized = normalizeViews(app);
|
|
1406
|
+
if (!normalized.ok) return normalized.problems;
|
|
1013
1407
|
const known = new Set(collections.map((collection) => collection.cid));
|
|
1014
|
-
|
|
1015
|
-
const problems = [];
|
|
1016
|
-
if (!require_templatePath.isSafeCustomViewPath(view.path) || view.path.split("/").length !== 2) problems.push(`public.view.path is '${view.path}': a published view is exactly one HTML file directly inside the collection's own views/ directory (e.g. views/booking.html) — no sub-directories, and no segments that climb out of it. The host reads this as a file to publish, and what it publishes is world-readable.`);
|
|
1017
|
-
for (const cid of view.collections) {
|
|
1018
|
-
if (!known.has(cid)) {
|
|
1019
|
-
problems.push(`public.view.collections names '${cid}', which is not a shared collection in this repository. Shared collections here: ${known.size > 0 ? [...known].sort().join(", ") : "(none)"}.`);
|
|
1020
|
-
continue;
|
|
1021
|
-
}
|
|
1022
|
-
if (!readable.has(cid)) problems.push(`public.view.collections names '${cid}', which is not in public.read: the page reads these with the VISITOR's permissions, so the rules refuse the read and the view draws an empty page. Nothing errors — this is the failure that looks like a working view with no data.`);
|
|
1023
|
-
}
|
|
1024
|
-
return problems;
|
|
1408
|
+
return normalized.views.flatMap((view) => [...viewPathProblems(view), ...view.collections.flatMap((cid) => viewCollectionProblems(app, view, cid, known))]);
|
|
1025
1409
|
}
|
|
1026
1410
|
/** What publish will actually promote, checked as the PAIR it becomes.
|
|
1027
1411
|
*
|
|
@@ -1053,9 +1437,25 @@ function promotedRoleProblems(app, staged) {
|
|
|
1053
1437
|
return [
|
|
1054
1438
|
...promotedAssigneeProblems(app, promoted, stagedCids),
|
|
1055
1439
|
...promotedMirrorProblems(app, promoted, stagedCids),
|
|
1056
|
-
...promotedRefFieldProblems(app, staged)
|
|
1440
|
+
...promotedRefFieldProblems(app, staged),
|
|
1441
|
+
...promotedParticipantViewProblems(app, staged)
|
|
1057
1442
|
];
|
|
1058
1443
|
}
|
|
1444
|
+
/** A participant's page, checked against the `participantRead` publish will
|
|
1445
|
+
* actually PROMOTE.
|
|
1446
|
+
*
|
|
1447
|
+
* Not against the manifest's. `projectPublish` overwrites `participantRead`
|
|
1448
|
+
* with what the staged schemas carry, so a cid added to the manifest since the
|
|
1449
|
+
* last deploy is not in the rules — and a page written for it would be
|
|
1450
|
+
* published, offered, and then refused the read. The manifest half of this
|
|
1451
|
+
* file cannot see that; the promoted half can, which is why the check lives
|
|
1452
|
+
* here rather than beside the other view checks. */
|
|
1453
|
+
function promotedParticipantViewProblems(app, staged) {
|
|
1454
|
+
const normalized = normalizeViews(app);
|
|
1455
|
+
if (!normalized.ok) return [];
|
|
1456
|
+
const participantRead = stagedRuleConfig(staged).participantRead ?? [];
|
|
1457
|
+
return normalized.views.filter((view) => view.audience === "participant").flatMap((view) => view.collections.filter((cid) => participantScope(app, cid, participantRead) === null).map((cid) => `${view.where}.collections names '${cid}', which a participant cannot read once this publishes: it is not in the participantRead that DEPLOY staged, and public.submit.${cid} declares neither an emailField nor idFrom "auth.uid", so there is no row the rules would call theirs. The page would be refused the read, not handed fewer records. (Adding it to participantRead in app.json is not enough — deploy first.)`));
|
|
1458
|
+
}
|
|
1059
1459
|
/** The FIELDS a rule reads off another record — `idIn.where.field` and the two
|
|
1060
1460
|
* window bounds — checked against the schema publish is about to promote.
|
|
1061
1461
|
*
|
|
@@ -3314,12 +3714,48 @@ Object.defineProperty(exports, "PUBLIC_CONFIG_DOC", {
|
|
|
3314
3714
|
return PUBLIC_CONFIG_DOC;
|
|
3315
3715
|
}
|
|
3316
3716
|
});
|
|
3717
|
+
Object.defineProperty(exports, "PUBLIC_VIEW_ID", {
|
|
3718
|
+
enumerable: true,
|
|
3719
|
+
get: function() {
|
|
3720
|
+
return PUBLIC_VIEW_ID;
|
|
3721
|
+
}
|
|
3722
|
+
});
|
|
3723
|
+
Object.defineProperty(exports, "RESERVED_VIEW_IDS", {
|
|
3724
|
+
enumerable: true,
|
|
3725
|
+
get: function() {
|
|
3726
|
+
return RESERVED_VIEW_IDS;
|
|
3727
|
+
}
|
|
3728
|
+
});
|
|
3317
3729
|
Object.defineProperty(exports, "STORE_UNREADABLE", {
|
|
3318
3730
|
enumerable: true,
|
|
3319
3731
|
get: function() {
|
|
3320
3732
|
return STORE_UNREADABLE;
|
|
3321
3733
|
}
|
|
3322
3734
|
});
|
|
3735
|
+
Object.defineProperty(exports, "VIEW_AUDIENCES", {
|
|
3736
|
+
enumerable: true,
|
|
3737
|
+
get: function() {
|
|
3738
|
+
return VIEW_AUDIENCES;
|
|
3739
|
+
}
|
|
3740
|
+
});
|
|
3741
|
+
Object.defineProperty(exports, "VIEW_CONFIG_ID", {
|
|
3742
|
+
enumerable: true,
|
|
3743
|
+
get: function() {
|
|
3744
|
+
return VIEW_CONFIG_ID;
|
|
3745
|
+
}
|
|
3746
|
+
});
|
|
3747
|
+
Object.defineProperty(exports, "VIEW_ID_PATTERN", {
|
|
3748
|
+
enumerable: true,
|
|
3749
|
+
get: function() {
|
|
3750
|
+
return VIEW_ID_PATTERN;
|
|
3751
|
+
}
|
|
3752
|
+
});
|
|
3753
|
+
Object.defineProperty(exports, "VIEW_TIER", {
|
|
3754
|
+
enumerable: true,
|
|
3755
|
+
get: function() {
|
|
3756
|
+
return VIEW_TIER;
|
|
3757
|
+
}
|
|
3758
|
+
});
|
|
3323
3759
|
Object.defineProperty(exports, "advanceTriggerDate", {
|
|
3324
3760
|
enumerable: true,
|
|
3325
3761
|
get: function() {
|
|
@@ -3350,6 +3786,12 @@ Object.defineProperty(exports, "appStagingPath", {
|
|
|
3350
3786
|
return appStagingPath;
|
|
3351
3787
|
}
|
|
3352
3788
|
});
|
|
3789
|
+
Object.defineProperty(exports, "appViewTierPath", {
|
|
3790
|
+
enumerable: true,
|
|
3791
|
+
get: function() {
|
|
3792
|
+
return appViewTierPath;
|
|
3793
|
+
}
|
|
3794
|
+
});
|
|
3353
3795
|
Object.defineProperty(exports, "applyMutateAction", {
|
|
3354
3796
|
enumerable: true,
|
|
3355
3797
|
get: function() {
|
|
@@ -3458,6 +3900,12 @@ Object.defineProperty(exports, "maybeSpawnSuccessor", {
|
|
|
3458
3900
|
return maybeSpawnSuccessor;
|
|
3459
3901
|
}
|
|
3460
3902
|
});
|
|
3903
|
+
Object.defineProperty(exports, "normalizeViews", {
|
|
3904
|
+
enumerable: true,
|
|
3905
|
+
get: function() {
|
|
3906
|
+
return normalizeViews;
|
|
3907
|
+
}
|
|
3908
|
+
});
|
|
3461
3909
|
Object.defineProperty(exports, "parseAuthoredApp", {
|
|
3462
3910
|
enumerable: true,
|
|
3463
3911
|
get: function() {
|
|
@@ -3470,12 +3918,24 @@ Object.defineProperty(exports, "parseCivil", {
|
|
|
3470
3918
|
return parseCivil;
|
|
3471
3919
|
}
|
|
3472
3920
|
});
|
|
3921
|
+
Object.defineProperty(exports, "participantScope", {
|
|
3922
|
+
enumerable: true,
|
|
3923
|
+
get: function() {
|
|
3924
|
+
return participantScope;
|
|
3925
|
+
}
|
|
3926
|
+
});
|
|
3473
3927
|
Object.defineProperty(exports, "projectApp", {
|
|
3474
3928
|
enumerable: true,
|
|
3475
3929
|
get: function() {
|
|
3476
3930
|
return projectApp;
|
|
3477
3931
|
}
|
|
3478
3932
|
});
|
|
3933
|
+
Object.defineProperty(exports, "projectAppViews", {
|
|
3934
|
+
enumerable: true,
|
|
3935
|
+
get: function() {
|
|
3936
|
+
return projectAppViews;
|
|
3937
|
+
}
|
|
3938
|
+
});
|
|
3479
3939
|
Object.defineProperty(exports, "projectDeploy", {
|
|
3480
3940
|
enumerable: true,
|
|
3481
3941
|
get: function() {
|
|
@@ -3584,5 +4044,23 @@ Object.defineProperty(exports, "validateRecordObject", {
|
|
|
3584
4044
|
return validateRecordObject;
|
|
3585
4045
|
}
|
|
3586
4046
|
});
|
|
4047
|
+
Object.defineProperty(exports, "viewConfigDocId", {
|
|
4048
|
+
enumerable: true,
|
|
4049
|
+
get: function() {
|
|
4050
|
+
return viewConfigDocId;
|
|
4051
|
+
}
|
|
4052
|
+
});
|
|
4053
|
+
Object.defineProperty(exports, "viewDocId", {
|
|
4054
|
+
enumerable: true,
|
|
4055
|
+
get: function() {
|
|
4056
|
+
return viewDocId;
|
|
4057
|
+
}
|
|
4058
|
+
});
|
|
4059
|
+
Object.defineProperty(exports, "writeFor", {
|
|
4060
|
+
enumerable: true,
|
|
4061
|
+
get: function() {
|
|
4062
|
+
return writeFor;
|
|
4063
|
+
}
|
|
4064
|
+
});
|
|
3587
4065
|
|
|
3588
|
-
//# sourceMappingURL=server-
|
|
4066
|
+
//# sourceMappingURL=server-DLzQ4jFu.cjs.map
|