@mulmoclaude/core 3.12.0 → 3.14.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 +1 -1
- package/dist/collection/registry/server/index.js +1 -1
- package/dist/collection/server/appViews.d.ts +97 -0
- package/dist/collection/server/index.cjs +13 -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 +45 -1
- package/dist/collection/server/publishProject.d.ts +49 -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-DkT40KgR.js → server-CQSF5fHe.js} +631 -16
- package/dist/server-CQSF5fHe.js.map +1 -0
- package/dist/{server-BIKy55wo.cjs → server-DknYbXBo.cjs} +702 -15
- package/dist/server-DknYbXBo.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/server-BIKy55wo.cjs.map +0 -1
- package/dist/server-DkT40KgR.js.map +0 -1
|
@@ -5,6 +5,7 @@ const require_relPath = require("./relPath-CTAjGdCL.cjs");
|
|
|
5
5
|
const require_itemId = require("./itemId-Dzrr3vs5.cjs");
|
|
6
6
|
const require_promptSafety = require("./promptSafety-CWqYq-NS.cjs");
|
|
7
7
|
const require_discovery = require("./discovery-BsXiDZBR.cjs");
|
|
8
|
+
const require_templatePath = require("./templatePath-27vUZowm.cjs");
|
|
8
9
|
const require_feeds_paths = require("./feeds/paths.cjs");
|
|
9
10
|
const require_skill_bridge_index = require("./skill-bridge/index.cjs");
|
|
10
11
|
let node_path = require("node:path");
|
|
@@ -12,6 +13,166 @@ node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
|
12
13
|
let node_crypto = require("node:crypto");
|
|
13
14
|
let node_fs_promises = require("node:fs/promises");
|
|
14
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
|
+
//#endregion
|
|
15
176
|
//#region src/collection/server/publishManifest.ts
|
|
16
177
|
/** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
|
|
17
178
|
* that `sharedCollectionKey` applies. Stated once so a path built later
|
|
@@ -85,6 +246,14 @@ var CollectionConfigZ = zod.z.object({
|
|
|
85
246
|
* declaration with the role and no field is refused (`assigneeProblems`),
|
|
86
247
|
* because that member would silently hold nothing. */
|
|
87
248
|
assigneeField: zod.z.string().trim().min(1).optional(),
|
|
249
|
+
/** This collection is the public projection of `mirrorOf` — the other
|
|
250
|
+
* half of `public.submit[...].mirror`, declared here because the rules
|
|
251
|
+
* read it when the PROJECTION is written rather than when the record is.
|
|
252
|
+
*
|
|
253
|
+
* What it buys: `state` may be written by anybody, and only to the value
|
|
254
|
+
* the authority actually says, so a visitor who was refused a slot can
|
|
255
|
+
* repair the stale row that offered it to them. */
|
|
256
|
+
mirrorOf: NameZ.optional(),
|
|
88
257
|
peerVisibility: zod.z.enum(["public", "hidden"]).optional(),
|
|
89
258
|
revealGated: zod.z.boolean().optional(),
|
|
90
259
|
gatedFrom: NameZ.optional(),
|
|
@@ -125,10 +294,38 @@ var WindowRefZ = zod.z.object({
|
|
|
125
294
|
collection: NameZ,
|
|
126
295
|
field: zod.z.string().trim().min(1)
|
|
127
296
|
}).strict();
|
|
297
|
+
/** The closing bound's per-record twin, and it ships WITH `fromField` rather
|
|
298
|
+
* than as a symmetric extra: a booking desk that opens per slot and never
|
|
299
|
+
* closes is not a booking desk. Same shape, opposite comparison — and
|
|
300
|
+
* EXCLUSIVE where `fromField` is inclusive, so one slot's closing instant and
|
|
301
|
+
* the next one's opening instant may be the same number. */
|
|
128
302
|
var WindowZ = zod.z.object({
|
|
129
303
|
from: zod.z.iso.datetime().optional(),
|
|
130
304
|
until: zod.z.iso.datetime().optional(),
|
|
131
|
-
fromField: WindowRefZ.optional()
|
|
305
|
+
fromField: WindowRefZ.optional(),
|
|
306
|
+
untilField: WindowRefZ.optional()
|
|
307
|
+
}).strict();
|
|
308
|
+
/** Which record a `field` document id must name, and what state it must be in.
|
|
309
|
+
*
|
|
310
|
+
* `idFrom: "field"` alone only stops the same string being written twice —
|
|
311
|
+
* nothing stops a client bypassing the page and inventing a slot, so the
|
|
312
|
+
* rules check the referenced record themselves. `exists()` is a FLOOR: a
|
|
313
|
+
* cancelled slot and a slot nobody may book any more exist too, which is what
|
|
314
|
+
* `where` is for.
|
|
315
|
+
*
|
|
316
|
+
* Always the object form, never a bare collection name. Two shapes for one
|
|
317
|
+
* key is the kind of thing a generator gets right once and wrong afterwards,
|
|
318
|
+
* and the rules read `s.idIn.collection` either way. */
|
|
319
|
+
var IdInZ = zod.z.object({
|
|
320
|
+
collection: NameZ,
|
|
321
|
+
where: zod.z.object({
|
|
322
|
+
field: zod.z.string().trim().min(1),
|
|
323
|
+
equals: zod.z.union([
|
|
324
|
+
zod.z.string(),
|
|
325
|
+
zod.z.number(),
|
|
326
|
+
zod.z.boolean()
|
|
327
|
+
])
|
|
328
|
+
}).strict().optional()
|
|
132
329
|
}).strict();
|
|
133
330
|
var ValidateZ = zod.z.object({
|
|
134
331
|
required: zod.z.array(zod.z.string().trim().min(1)).optional(),
|
|
@@ -153,12 +350,31 @@ var SubmitZ = zod.z.object({
|
|
|
153
350
|
emailField: zod.z.string().trim().min(1).optional(),
|
|
154
351
|
createFields: zod.z.array(zod.z.string().trim().min(1)).min(1),
|
|
155
352
|
initialStatus: zod.z.string().trim().min(1).optional(),
|
|
353
|
+
/** `field` is the mode that makes a CONTESTED resource exclusive: the
|
|
354
|
+
* booking's document id IS the slot's id, so the second person to want
|
|
355
|
+
* that slot is writing a document that already exists — an update, which
|
|
356
|
+
* the public submission path never allows. Firestore decides that
|
|
357
|
+
* atomically, so unlike a countable capacity (see `stampField`) this is
|
|
358
|
+
* first-come ENFORCED rather than first-come read off a rank. */
|
|
156
359
|
idFrom: zod.z.enum([
|
|
157
360
|
"auto",
|
|
158
361
|
"auth.uid",
|
|
159
|
-
"auth.uid+field"
|
|
362
|
+
"auth.uid+field",
|
|
363
|
+
"field"
|
|
160
364
|
]).optional(),
|
|
161
365
|
idField: zod.z.string().trim().min(1).optional(),
|
|
366
|
+
/** Required by `idFrom: "field"` — see {@link IdInZ}. */
|
|
367
|
+
idIn: IdInZ.optional(),
|
|
368
|
+
/** The collection holding this record's PUBLIC PROJECTION, one row per
|
|
369
|
+
* contested thing, sharing its document id.
|
|
370
|
+
*
|
|
371
|
+
* A booking carries a name, an address and a phone number, and Firestore
|
|
372
|
+
* rules cannot hide a field, so the public page must not read bookings at
|
|
373
|
+
* all. It reads the projection instead, whose `state` is a copy of "does
|
|
374
|
+
* a booking with this id exist" — and the rules accept the two writes
|
|
375
|
+
* only as one batch, in both directions, so the copy cannot drift into
|
|
376
|
+
* advertising a slot that is gone. */
|
|
377
|
+
mirror: NameZ.optional(),
|
|
162
378
|
validate: ValidateZ.optional(),
|
|
163
379
|
window: WindowZ.optional(),
|
|
164
380
|
/** A field the rules PIN to the server clock on create: the record must
|
|
@@ -190,8 +406,48 @@ var PublicZ = zod.z.object({
|
|
|
190
406
|
* well as its own declaration. */
|
|
191
407
|
enabled: zod.z.boolean().optional(),
|
|
192
408
|
read: zod.z.array(NameZ).optional(),
|
|
409
|
+
/** The page the public sees, instead of the generated form.
|
|
410
|
+
*
|
|
411
|
+
* A form is enough to ANSWER something and not enough to CHOOSE from
|
|
412
|
+
* what is available — a stylist-by-hour grid is not the far end of a
|
|
413
|
+
* table. So the app may name one HTML file, which the host publishes to
|
|
414
|
+
* `config/view` and the public page renders in a sandboxed iframe.
|
|
415
|
+
*
|
|
416
|
+
* `submit` stays declared alongside: the view sends an INTENT, and the
|
|
417
|
+
* page it is embedded in performs the write against these rules.
|
|
418
|
+
*
|
|
419
|
+
* `collections` is declared rather than inferred from `read`. Inferring
|
|
420
|
+
* it produces the worst failure this feature has — the view renders, the
|
|
421
|
+
* data it wanted was never sent, and it draws an empty grid with no error
|
|
422
|
+
* anywhere. */
|
|
423
|
+
view: zod.z.object({
|
|
424
|
+
path: zod.z.string().trim().min(1),
|
|
425
|
+
collections: zod.z.array(NameZ).min(1)
|
|
426
|
+
}).strict().optional(),
|
|
193
427
|
submit: zod.z.record(NameZ, SubmitZ).optional()
|
|
194
428
|
}).strict();
|
|
429
|
+
/** One page the app shows, and who it is for.
|
|
430
|
+
*
|
|
431
|
+
* Generalised from `public.view` (which is still accepted, and normalizes
|
|
432
|
+
* into this — see `appViews.ts`). The audience is what decides which document
|
|
433
|
+
* the HTML is published to, and therefore who may read it: a rule cannot hide
|
|
434
|
+
* a field, so "the front desk sees this" is a place, not a filter.
|
|
435
|
+
*
|
|
436
|
+
* `id` is not decoration. It becomes the document id the page is published
|
|
437
|
+
* at, which is what lets one audience have more than one page — the front
|
|
438
|
+
* desk and the stock room — and what lets a withdrawn view be found and
|
|
439
|
+
* deleted. Its grammar is enforced at the gate, not here, so the refusal can
|
|
440
|
+
* say what the value is used for.
|
|
441
|
+
*
|
|
442
|
+
* `collections` is declared rather than inferred, for the reason `public.view`
|
|
443
|
+
* gives: an inferred list renders a perfect page with no data in it, and
|
|
444
|
+
* nothing anywhere says why. */
|
|
445
|
+
var ViewZ = zod.z.object({
|
|
446
|
+
id: zod.z.string().trim().min(1),
|
|
447
|
+
audience: zod.z.enum(VIEW_AUDIENCES),
|
|
448
|
+
path: zod.z.string().trim().min(1),
|
|
449
|
+
collections: zod.z.array(NameZ).min(1)
|
|
450
|
+
}).strict();
|
|
195
451
|
/** The URL name an app is handed out under: `https://<host>/{slug}`.
|
|
196
452
|
*
|
|
197
453
|
* A SEPARATE name from the `aid`, and that separation is the point (design
|
|
@@ -240,7 +496,10 @@ var AuthoredAppZ = zod.z.object({
|
|
|
240
496
|
members: MembersZ,
|
|
241
497
|
collections: zod.z.record(NameZ, CollectionConfigZ).optional(),
|
|
242
498
|
participantRead: zod.z.array(NameZ).optional(),
|
|
243
|
-
public: PublicZ.optional()
|
|
499
|
+
public: PublicZ.optional(),
|
|
500
|
+
/** The app's pages, per audience. See {@link ViewZ}; `public.view` is the
|
|
501
|
+
* older spelling of the `public` one and normalizes into this list. */
|
|
502
|
+
views: zod.z.array(ViewZ).optional()
|
|
244
503
|
}).strict();
|
|
245
504
|
/** Parse the authored declaration out of `app.json`'s text.
|
|
246
505
|
*
|
|
@@ -296,7 +555,8 @@ function windowMillis(window) {
|
|
|
296
555
|
if (Object.values(out).some((value) => !Number.isFinite(value))) throw new Error(`publish: window bound is not a parseable timestamp (${JSON.stringify(window)})`);
|
|
297
556
|
const projected = {
|
|
298
557
|
...out,
|
|
299
|
-
...window.fromField === void 0 ? {} : { fromField: window.fromField }
|
|
558
|
+
...window.fromField === void 0 ? {} : { fromField: window.fromField },
|
|
559
|
+
...window.untilField === void 0 ? {} : { untilField: window.untilField }
|
|
300
560
|
};
|
|
301
561
|
return Object.keys(projected).length > 0 ? projected : void 0;
|
|
302
562
|
}
|
|
@@ -368,10 +628,14 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
368
628
|
publishedDirty: stamp.dirty === true ? true : void 0,
|
|
369
629
|
previousPublished: previousOf(existing)
|
|
370
630
|
});
|
|
631
|
+
const normalized = normalizeViews(authored);
|
|
632
|
+
if (!normalized.ok) throw new Error(`publish: views declaration is not publishable (${normalized.problems.join(" ")})`);
|
|
633
|
+
const publicView = normalized.views.find((view) => view.audience === "public");
|
|
371
634
|
const config = {
|
|
372
635
|
enabled: authored.public?.enabled === true,
|
|
373
636
|
read: authored.public?.read ?? [],
|
|
374
637
|
submit,
|
|
638
|
+
...publicView === void 0 ? {} : { view: { collections: publicView.collections } },
|
|
375
639
|
publishedAt: stamp.publishedAt
|
|
376
640
|
};
|
|
377
641
|
if (authored.name !== void 0) config.name = authored.name;
|
|
@@ -522,6 +786,64 @@ var appSlugDoc = (aid, published) => ({
|
|
|
522
786
|
var appStagingPath = (aid) => `apps/${aid}/staging`;
|
|
523
787
|
/** The public-config documents' parent path. */
|
|
524
788
|
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
789
|
+
/** Where one audience's pages live. `member` is read by anyone holding a role;
|
|
790
|
+
* `roster` by anyone on the roster, participants included. */
|
|
791
|
+
var appViewTierPath = (aid, tier) => `apps/${aid}/${tier}`;
|
|
792
|
+
/** What one audience may see of one collection.
|
|
793
|
+
*
|
|
794
|
+
* For `member` this is always the whole collection: every read branch a role
|
|
795
|
+
* opens (`readerOf`) is unscoped. Whether THIS member holds the role is not
|
|
796
|
+
* knowable here — one projection is read by every member of the tier — and is
|
|
797
|
+
* settled where it can be, by the entrance trying the read.
|
|
798
|
+
*
|
|
799
|
+
* For `participant` it is the rules' own answer, which is why it can be null:
|
|
800
|
+
* a participant with neither `participantRead` nor an own-row submit path
|
|
801
|
+
* cannot read the collection at all, and a page handed it would fail rather
|
|
802
|
+
* than render less. */
|
|
803
|
+
function scopeFor(authored, audience, cid, participantRead) {
|
|
804
|
+
return audience === "member" ? {
|
|
805
|
+
cid,
|
|
806
|
+
scope: "all"
|
|
807
|
+
} : participantScope(authored, cid, participantRead);
|
|
808
|
+
}
|
|
809
|
+
/** Project the declaration into the per-audience documents.
|
|
810
|
+
*
|
|
811
|
+
* Pure, like `projectApp`: the HTML is not here (the host reads the files),
|
|
812
|
+
* and neither is the clock. What is here is the answer to "what may this
|
|
813
|
+
* audience read, and how" — computed once, so the page never has to guess and
|
|
814
|
+
* never has to discover it from a denial. */
|
|
815
|
+
function projectAppViews(authored, stamp, promoted) {
|
|
816
|
+
const participantRead = promoted?.participantRead ?? authored.participantRead ?? [];
|
|
817
|
+
const normalized = normalizeViews(authored);
|
|
818
|
+
if (!normalized.ok) throw new Error(`publish: views declaration is not publishable (${normalized.problems.join(" ")})`);
|
|
819
|
+
return ["member", "participant"].map((audience) => {
|
|
820
|
+
const views = normalized.views.filter((view) => view.audience === audience);
|
|
821
|
+
const cids = [...new Set(views.flatMap((view) => view.collections))];
|
|
822
|
+
const declaredSubmit = authored.public?.submit ?? {};
|
|
823
|
+
const submit = Object.fromEntries(cids.flatMap((cid) => {
|
|
824
|
+
const spec = declaredSubmit[cid];
|
|
825
|
+
return spec === void 0 ? [] : [[cid, projectSubmit(spec)]];
|
|
826
|
+
}));
|
|
827
|
+
const config = {
|
|
828
|
+
views: views.map((view) => ({
|
|
829
|
+
id: view.id,
|
|
830
|
+
collections: view.collections.map((cid) => scopeFor(authored, audience, cid, participantRead)).filter((scope) => scope !== null)
|
|
831
|
+
})),
|
|
832
|
+
submit,
|
|
833
|
+
publishedAt: stamp.publishedAt
|
|
834
|
+
};
|
|
835
|
+
if (authored.name !== void 0) config.name = authored.name;
|
|
836
|
+
return {
|
|
837
|
+
tier: VIEW_TIER[audience],
|
|
838
|
+
audience,
|
|
839
|
+
config,
|
|
840
|
+
views
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
/** The document a tier's projection is published at. Beside the views
|
|
845
|
+
* themselves, under one `match` — see `firestore.rules`. */
|
|
846
|
+
var viewConfigDocId = (stage) => viewDocId(stage, VIEW_CONFIG_ID);
|
|
525
847
|
//#endregion
|
|
526
848
|
//#region src/collection/server/publishChecks.ts
|
|
527
849
|
/** Does this submit declaration bind a record to the submitter's identity?
|
|
@@ -669,7 +991,7 @@ function ruleReadFields(submit) {
|
|
|
669
991
|
field: submit.emailField,
|
|
670
992
|
why: `public.submit.<cid>.emailField — the rules compare it to the submitter's verified address`
|
|
671
993
|
});
|
|
672
|
-
if (submit.idFrom === "auth.uid+field" && submit.idField !== void 0) fields.push({
|
|
994
|
+
if ((submit.idFrom === "auth.uid+field" || submit.idFrom === "field") && submit.idField !== void 0) fields.push({
|
|
673
995
|
field: submit.idField,
|
|
674
996
|
why: `public.submit.<cid>.idField — the rules rebuild the document id from it`
|
|
675
997
|
});
|
|
@@ -692,10 +1014,58 @@ function submitCoherenceProblems(app, cid, submit) {
|
|
|
692
1014
|
const collection = app.collections?.[cid];
|
|
693
1015
|
const problems = [...statusCoherenceProblems(cid, submit, collection), ...createFieldProblems(cid, submit)];
|
|
694
1016
|
if (submit.idFrom === "auth.uid+field" && submit.idField === void 0) problems.push(`public.submit.${cid}.idFrom is "auth.uid+field" but no idField is declared: the rules rebuild the document id from that field and refuse every create.`);
|
|
1017
|
+
problems.push(...fieldIdProblems(cid, submit));
|
|
695
1018
|
if ((submit.selfUpdate !== void 0 || submit.selfTransitions !== void 0) && !collection?.statusField) problems.push(`public.submit.${cid}.selfUpdate / selfTransitions are declared per CURRENT STATUS, but collections.${cid} declares no statusField: the rules read the current status first and refuse every self-edit without it.`);
|
|
696
1019
|
if (submit.audience === "participant" && Object.keys(app.members).length === 0) problems.push(`public.submit.${cid}.audience is "participant" but the roster is empty: the rules resolve the submitter's role from members, so every submission is refused.`);
|
|
697
1020
|
return problems;
|
|
698
1021
|
}
|
|
1022
|
+
/** `idFrom: "field"` makes the document id a CLAIM ABOUT ANOTHER RECORD, and
|
|
1023
|
+
* the claim is only worth what is checked.
|
|
1024
|
+
*
|
|
1025
|
+
* `idIn` is required rather than optional, and that is the whole point of
|
|
1026
|
+
* refusing here: without it the id is any string a stranger likes, so the app
|
|
1027
|
+
* quietly accepts bookings for slots that do not exist. Nothing downstream
|
|
1028
|
+
* ever notices — the booking is real, its slot is not — which is exactly the
|
|
1029
|
+
* kind of hole a gate is for and a rule cannot state.
|
|
1030
|
+
*
|
|
1031
|
+
* `idIn` without the mode is refused for the opposite reason: the rules read
|
|
1032
|
+
* it only in that branch, so an author who wrote it believes a check is
|
|
1033
|
+
* running that is not. */
|
|
1034
|
+
function fieldIdProblems(cid, submit) {
|
|
1035
|
+
const problems = [];
|
|
1036
|
+
if (submit.idFrom === "field") {
|
|
1037
|
+
if (submit.idField === void 0) problems.push(`public.submit.${cid}.idFrom is "field" but no idField is declared: the rules take the document id from that field and refuse every create.`);
|
|
1038
|
+
if (submit.idIn === void 0) problems.push(`public.submit.${cid}.idFrom is "field" but no idIn is declared: the document id is then any string a submitter chooses, so the app accepts records pointing at things that do not exist. Name the collection the id must be found in — and, when only some of those records may be claimed, the state they must be in: "idIn": { "collection": "slots", "where": { "field": "state", "equals": "open" } }.`);
|
|
1039
|
+
} else if (submit.idIn !== void 0) {
|
|
1040
|
+
const mode = submit.idFrom === void 0 ? "absent" : JSON.stringify(submit.idFrom);
|
|
1041
|
+
problems.push(`public.submit.${cid}.idIn is declared but idFrom is ${mode}: the rules read idIn only for idFrom "field", so as written nothing checks the referenced record and the declaration promises a check it does not perform.`);
|
|
1042
|
+
}
|
|
1043
|
+
return problems;
|
|
1044
|
+
}
|
|
1045
|
+
/** Every `idIn` target, checked against the collections this repository has.
|
|
1046
|
+
*
|
|
1047
|
+
* Separate from {@link fieldIdProblems} for the reason the file is split at
|
|
1048
|
+
* all: that one reads the declaration alone, this one needs to know what
|
|
1049
|
+
* exists. */
|
|
1050
|
+
function idTargetProblems(app, collections) {
|
|
1051
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
1052
|
+
const names = known.size > 0 ? [...known].sort().join(", ") : "(none)";
|
|
1053
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => idInTargetProblems(cid, submit, known, names));
|
|
1054
|
+
}
|
|
1055
|
+
/** Where a `field` id says its record must be found.
|
|
1056
|
+
*
|
|
1057
|
+
* A typo passes every other check: the rules look the record up in a
|
|
1058
|
+
* collection that does not exist, the lookup can never succeed, and every
|
|
1059
|
+
* submission is refused with no explanation anywhere. A collection pointing
|
|
1060
|
+
* at ITSELF is worse than a typo — on a create the document being written
|
|
1061
|
+
* does not exist yet, so it is a declaration that can never accept anything. */
|
|
1062
|
+
function idInTargetProblems(cid, submit, known, names) {
|
|
1063
|
+
const target = submit.idIn?.collection;
|
|
1064
|
+
if (target === void 0) return [];
|
|
1065
|
+
if (target === cid) return [`public.submit.${cid}.idIn.collection names '${cid}' itself: a create writes a document that does not exist yet, so the record can never be found and every submission is refused. Name the collection of the thing being claimed (the slots, the seats, the assets).`];
|
|
1066
|
+
if (!known.has(target)) return [`public.submit.${cid}.idIn.collection names '${target}', which is not a shared collection in this repository. The rules look the record up there, so nothing can ever be submitted. Shared collections here: ${names}.`];
|
|
1067
|
+
return [];
|
|
1068
|
+
}
|
|
699
1069
|
/** The staged reveal reads its flag off the PARENT record, so the path to that
|
|
700
1070
|
* parent is not optional decoration — without it the gate never opens. */
|
|
701
1071
|
function gateCoherenceProblems(cid, collection) {
|
|
@@ -746,7 +1116,10 @@ function publishProblems(app, collections, publisherEmail) {
|
|
|
746
1116
|
...primaryKeyProblems(app, collections),
|
|
747
1117
|
...assigneeProblems(app),
|
|
748
1118
|
...stampProblems(app),
|
|
749
|
-
...windowRefProblems(app, collections)
|
|
1119
|
+
...windowRefProblems(app, collections),
|
|
1120
|
+
...idTargetProblems(app, collections),
|
|
1121
|
+
...mirrorProblems(app, collections),
|
|
1122
|
+
...viewProblems(app, collections)
|
|
750
1123
|
];
|
|
751
1124
|
}
|
|
752
1125
|
/** A public submission must NOT be allowed to name its own primary key.
|
|
@@ -827,14 +1200,106 @@ function stampProblems(app) {
|
|
|
827
1200
|
* good. */
|
|
828
1201
|
function windowRefProblems(app, collections) {
|
|
829
1202
|
const known = new Set(collections.map((collection) => collection.cid));
|
|
830
|
-
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) =>
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1203
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => [...windowBoundProblems(cid, submit, known, "fromField", submit.window?.fromField, "opening"), ...windowBoundProblems(cid, submit, known, "untilField", submit.window?.untilField, "closing")]);
|
|
1204
|
+
}
|
|
1205
|
+
/** Both bounds, checked identically. `untilField` arrived with the booking
|
|
1206
|
+
* desk and reads exactly like its twin, so a check that knew only about
|
|
1207
|
+
* `fromField` would let the closing half through unchecked — and a closing
|
|
1208
|
+
* bound that names nothing does not leave the door ajar, it refuses every
|
|
1209
|
+
* submission with no explanation. */
|
|
1210
|
+
function windowBoundProblems(cid, submit, known, key, ref, which) {
|
|
1211
|
+
if (ref === void 0) return [];
|
|
1212
|
+
const problems = [];
|
|
1213
|
+
if (!known.has(ref.collection)) problems.push(`public.submit.${cid}.window.${key}.collection names '${ref.collection}', which is not a shared collection in this repository. The rules read the ${which} time off a record there, so nothing can ever be submitted. Shared collections here: ${known.size > 0 ? [...known].sort().join(", ") : "(none)"}.`);
|
|
1214
|
+
if (!submit.createFields.includes(ref.ref)) problems.push(`public.submit.${cid}.window.${key}.ref names '${ref.ref}', which is not in createFields. The rules take the target record's id from that field ON THE SUBMISSION — if the submitter never writes it, there is nothing to look up and every submission is refused.`);
|
|
1215
|
+
return problems;
|
|
1216
|
+
}
|
|
1217
|
+
/** The two halves of a mirror, checked as the pair they only work as.
|
|
1218
|
+
*
|
|
1219
|
+
* `mirror` on the submission and `mirrorOf` on the projection are separate
|
|
1220
|
+
* keys in separate places, and each is inert without the other: a booking
|
|
1221
|
+
* whose slot declares no `mirrorOf` can never be created (the rules demand a
|
|
1222
|
+
* paired write that the projection's own rule will refuse), and a projection
|
|
1223
|
+
* whose authority declares no `mirror` drifts unbounded because nothing makes
|
|
1224
|
+
* the two move together. Both failures are silent, and one of them —
|
|
1225
|
+
* advertising a slot somebody already holds — is the exact thing the mirror
|
|
1226
|
+
* exists to prevent.
|
|
1227
|
+
*
|
|
1228
|
+
* Also refuses a collection mirroring ITSELF, which reads as a typo and
|
|
1229
|
+
* behaves as an unwritable collection: every create would have to prove its
|
|
1230
|
+
* own document is simultaneously taken and open. */
|
|
1231
|
+
function mirrorProblems(app, collections) {
|
|
1232
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
1233
|
+
const names = known.size > 0 ? [...known].sort().join(", ") : "(none)";
|
|
1234
|
+
return [...Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => mirrorClaimProblems(app, cid, submit, known, names)), ...Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => mirrorOfProblems(app, cid, collection, known, names))];
|
|
1235
|
+
}
|
|
1236
|
+
/** The submission side: `public.submit[cid].mirror`. */
|
|
1237
|
+
function mirrorClaimProblems(app, cid, submit, known, names) {
|
|
1238
|
+
const { mirror } = submit;
|
|
1239
|
+
if (mirror === void 0) return [];
|
|
1240
|
+
if (mirror === cid) return [`public.submit.${cid}.mirror names its own collection: the projection is a SEPARATE record, and as written no create can satisfy the rules.`];
|
|
1241
|
+
if (!known.has(mirror)) return [`public.submit.${cid}.mirror names '${mirror}', which is not a shared collection in this repository. The rules require the projection to move in the same write, so every submission is refused. Shared collections here: ${names}.`];
|
|
1242
|
+
if (app.collections?.[mirror]?.mirrorOf !== cid) return [`public.submit.${cid}.mirror names '${mirror}', but collections.${mirror} does not declare mirrorOf: "${cid}". The two halves only work as a pair — the submission side demands the projection move with it, and the projection side is what allows that move — so as written every submission is refused.`];
|
|
1243
|
+
return [];
|
|
1244
|
+
}
|
|
1245
|
+
/** The projection side: `collections[cid].mirrorOf`. */
|
|
1246
|
+
function mirrorOfProblems(app, cid, collection, known, names) {
|
|
1247
|
+
const authority = collection.mirrorOf;
|
|
1248
|
+
if (authority === void 0) return [];
|
|
1249
|
+
if (!known.has(authority)) return [`collections.${cid}.mirrorOf names '${authority}', which is not a shared collection in this repository. Nothing can then be true of it, so the projection's state may never be written. Shared collections here: ${names}.`];
|
|
1250
|
+
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.`];
|
|
1251
|
+
return [];
|
|
1252
|
+
}
|
|
1253
|
+
/** What each view is handed, and whether its audience can actually read it.
|
|
1254
|
+
*
|
|
1255
|
+
* Declared, never inferred — a view whose datasets were guessed from
|
|
1256
|
+
* `public.read` renders perfectly and draws an empty grid, with nothing in
|
|
1257
|
+
* the page, the rules or the log to say why.
|
|
1258
|
+
*
|
|
1259
|
+
* The reachability check below is that same failure, once per audience. A
|
|
1260
|
+
* `public` view naming a collection outside `public.read` draws nothing; a
|
|
1261
|
+
* `participant` view naming one the participant reaches by neither
|
|
1262
|
+
* `participantRead` nor their own row is worse, because an unscoped list on
|
|
1263
|
+
* an own-row collection is DENIED rather than narrowed — the page does not
|
|
1264
|
+
* render less, it fails.
|
|
1265
|
+
*
|
|
1266
|
+
* There is deliberately no such check for `member`. Every read a role opens
|
|
1267
|
+
* is unscoped, and WHICH role a given member holds is not a property of the
|
|
1268
|
+
* declaration: a stylist scoped to `bookings` and an owner read the same
|
|
1269
|
+
* projection. That one is settled at the entrance, by trying the read. */
|
|
1270
|
+
/** The path, for one view. The SAME validator the host's own custom views use,
|
|
1271
|
+
* rather than a second opinion about what a safe view path is.
|
|
1272
|
+
*
|
|
1273
|
+
* Two ad-hoc attempts were wrong here in the same afternoon: a prefix-and-
|
|
1274
|
+
* suffix test let `views/../../secrets.html` through, and `views/[^/]+\.html`
|
|
1275
|
+
* still let `views/..\..\secrets.html` through, because a backslash is not a
|
|
1276
|
+
* slash on this side of the check and IS a separator on Windows. This one
|
|
1277
|
+
* rejects `..`, backslashes, leading slashes and anything outside
|
|
1278
|
+
* `[A-Za-z0-9._-]` per segment.
|
|
1279
|
+
*
|
|
1280
|
+
* It matters more here than for a host view: the host reads this path to
|
|
1281
|
+
* decide which file to copy onto a document other people read — for a public
|
|
1282
|
+
* view, a document whose rule is `allow read: if true`, so the blast radius of
|
|
1283
|
+
* a bad path is the world rather than the author's own iframe. Nested paths
|
|
1284
|
+
* ARE allowed by the shared validator; the extra `views/<one name>.html` shape
|
|
1285
|
+
* is this publisher's own narrowing, kept because there is no reason for a
|
|
1286
|
+
* published view to live in a subdirectory. */
|
|
1287
|
+
function viewPathProblems(view) {
|
|
1288
|
+
if (require_templatePath.isSafeCustomViewPath(view.path) && view.path.split("/").length === 2) return [];
|
|
1289
|
+
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.`];
|
|
1290
|
+
}
|
|
1291
|
+
/** One dataset, for one view: does it exist here, and can the audience it is
|
|
1292
|
+
* handed to actually read it? */
|
|
1293
|
+
function viewCollectionProblems(app, view, cid, known) {
|
|
1294
|
+
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)"}.`];
|
|
1295
|
+
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.`];
|
|
1296
|
+
return [];
|
|
1297
|
+
}
|
|
1298
|
+
function viewProblems(app, collections) {
|
|
1299
|
+
const normalized = normalizeViews(app);
|
|
1300
|
+
if (!normalized.ok) return normalized.problems;
|
|
1301
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
1302
|
+
return normalized.views.flatMap((view) => [...viewPathProblems(view), ...view.collections.flatMap((cid) => viewCollectionProblems(app, view, cid, known))]);
|
|
838
1303
|
}
|
|
839
1304
|
/** What publish will actually promote, checked as the PAIR it becomes.
|
|
840
1305
|
*
|
|
@@ -863,12 +1328,162 @@ function windowRefProblems(app, collections) {
|
|
|
863
1328
|
function promotedRoleProblems(app, staged) {
|
|
864
1329
|
const promoted = stagedRuleConfig(staged).collections ?? {};
|
|
865
1330
|
const stagedCids = new Set(staged.map((entry) => entry.cid));
|
|
1331
|
+
return [
|
|
1332
|
+
...promotedAssigneeProblems(app, promoted, stagedCids),
|
|
1333
|
+
...promotedMirrorProblems(app, promoted, stagedCids),
|
|
1334
|
+
...promotedRefFieldProblems(app, staged),
|
|
1335
|
+
...promotedParticipantViewProblems(app, staged)
|
|
1336
|
+
];
|
|
1337
|
+
}
|
|
1338
|
+
/** A participant's page, checked against the `participantRead` publish will
|
|
1339
|
+
* actually PROMOTE.
|
|
1340
|
+
*
|
|
1341
|
+
* Not against the manifest's. `projectPublish` overwrites `participantRead`
|
|
1342
|
+
* with what the staged schemas carry, so a cid added to the manifest since the
|
|
1343
|
+
* last deploy is not in the rules — and a page written for it would be
|
|
1344
|
+
* published, offered, and then refused the read. The manifest half of this
|
|
1345
|
+
* file cannot see that; the promoted half can, which is why the check lives
|
|
1346
|
+
* here rather than beside the other view checks. */
|
|
1347
|
+
function promotedParticipantViewProblems(app, staged) {
|
|
1348
|
+
const normalized = normalizeViews(app);
|
|
1349
|
+
if (!normalized.ok) return [];
|
|
1350
|
+
const participantRead = stagedRuleConfig(staged).participantRead ?? [];
|
|
1351
|
+
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.)`));
|
|
1352
|
+
}
|
|
1353
|
+
/** The FIELDS a rule reads off another record — `idIn.where.field` and the two
|
|
1354
|
+
* window bounds — checked against the schema publish is about to promote.
|
|
1355
|
+
*
|
|
1356
|
+
* These are checked here rather than in `publishProblems` because that gate
|
|
1357
|
+
* is given a cid and a primary key per collection and nothing else, on
|
|
1358
|
+
* purpose: it reads the DECLARATION. A field name can only be judged against
|
|
1359
|
+
* a schema, and the schema that matters is the STAGED one — the version
|
|
1360
|
+
* publish promotes — not whatever the working tree says now.
|
|
1361
|
+
*
|
|
1362
|
+
* What a typo costs: `where: { field: "staet" }` publishes cleanly, the
|
|
1363
|
+
* rules' comparison can never match, and every submission is denied with no
|
|
1364
|
+
* message. The author's own app looks broken with nothing to read.
|
|
1365
|
+
*
|
|
1366
|
+
* Only fields the schema DECLARES are accepted. A record may carry more than
|
|
1367
|
+
* its schema does, but a shared collection's records are written through it,
|
|
1368
|
+
* and "the field exists on some rows" is not something a gate can promise. */
|
|
1369
|
+
function promotedRefFieldProblems(app, staged) {
|
|
1370
|
+
const schemaOf = new Map(staged.map((entry) => [entry.cid, entry.doc.publishedSchema]));
|
|
1371
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitRefProblems(schemaOf, cid, submit));
|
|
1372
|
+
}
|
|
1373
|
+
function submitRefProblems(schemaOf, cid, submit) {
|
|
1374
|
+
return [
|
|
1375
|
+
...idInRefProblems(schemaOf, cid, submit),
|
|
1376
|
+
...boundRefProblems(schemaOf, cid, "fromField", submit.window?.fromField),
|
|
1377
|
+
...boundRefProblems(schemaOf, cid, "untilField", submit.window?.untilField)
|
|
1378
|
+
];
|
|
1379
|
+
}
|
|
1380
|
+
function idInRefProblems(schemaOf, cid, submit) {
|
|
1381
|
+
const where = submit.idIn?.where;
|
|
1382
|
+
if (where === void 0) return [];
|
|
1383
|
+
return [...refFieldProblem(schemaOf, cid, "idIn.where.field", submit.idIn?.collection, where.field), ...comparableProblem(schemaOf, cid, submit.idIn?.collection, where)];
|
|
1384
|
+
}
|
|
1385
|
+
function boundRefProblems(schemaOf, cid, key, ref) {
|
|
1386
|
+
if (ref === void 0) return [];
|
|
1387
|
+
return [...refFieldProblem(schemaOf, cid, `window.${key}.field`, ref.collection, ref.field), ...millisProblem(schemaOf, cid, `window.${key}.field`, ref)];
|
|
1388
|
+
}
|
|
1389
|
+
/** The field spec a reference points at, or undefined when there is nothing
|
|
1390
|
+
* staged to judge it against (the host refuses that separately, naming every
|
|
1391
|
+
* missing collection at once). */
|
|
1392
|
+
function referencedField(schemaOf, target, field) {
|
|
1393
|
+
if (target === void 0 || field === void 0) return void 0;
|
|
1394
|
+
return schemaOf.get(target)?.fields?.[field];
|
|
1395
|
+
}
|
|
1396
|
+
/** An enum's domain, or undefined for every other kind. Narrowed by the key
|
|
1397
|
+
* rather than asserted: `fields` is a discriminated union and only some of
|
|
1398
|
+
* its members carry `values`. */
|
|
1399
|
+
function enumValues(spec) {
|
|
1400
|
+
return spec.type === "enum" ? spec.values : void 0;
|
|
1401
|
+
}
|
|
1402
|
+
function refFieldProblem(schemaOf, cid, key, target, field) {
|
|
1403
|
+
if (target === void 0 || field === void 0) return [];
|
|
1404
|
+
const schema = schemaOf.get(target);
|
|
1405
|
+
if (schema === void 0 || referencedField(schemaOf, target, field) !== void 0) return [];
|
|
1406
|
+
const known = Object.keys(schema.fields ?? {}).sort().join(", ");
|
|
1407
|
+
return [`public.submit.${cid}.${key} names '${field}', which the STAGED schema of '${target}' — the one publish promotes — does not declare. The rules read that field off the record and compare it, so as written every submission is refused with nothing to explain it. Fields on '${target}': ${known.length > 0 ? known : "(none)"}.`];
|
|
1408
|
+
}
|
|
1409
|
+
/** A comparison the rules can never satisfy is as dead as a missing field, and
|
|
1410
|
+
* looks even more correct on the page: an `enum` whose domain does not contain
|
|
1411
|
+
* the value, or a boolean field compared with a string. */
|
|
1412
|
+
function comparableProblem(schemaOf, cid, target, where) {
|
|
1413
|
+
const spec = referencedField(schemaOf, target, where.field);
|
|
1414
|
+
if (spec === void 0) return [];
|
|
1415
|
+
const said = JSON.stringify(where.equals);
|
|
1416
|
+
const values = enumValues(spec);
|
|
1417
|
+
if (values !== void 0) {
|
|
1418
|
+
if (values.includes(String(where.equals))) return [];
|
|
1419
|
+
return [`public.submit.${cid}.idIn.where.equals is ${said}, which is not one of the values '${where.field}' can hold on '${String(target)}' (${values.join(", ") || "(none)"}). The comparison can never be true, so every submission is refused.`];
|
|
1420
|
+
}
|
|
1421
|
+
const wanted = spec.type === "number" ? "number" : spec.type === "boolean" ? "boolean" : "string";
|
|
1422
|
+
if (typeof where.equals === wanted) return [];
|
|
1423
|
+
return [`public.submit.${cid}.idIn.where.equals is ${said}, and '${where.field}' on '${String(target)}' is a ${spec.type} field. The rules compare the stored value with this one and never coerce, so the comparison can never be true and every submission is refused.`];
|
|
1424
|
+
}
|
|
1425
|
+
/** A per-record window bound is EPOCH MILLIS, because the rules have no date
|
|
1426
|
+
* arithmetic and do not coerce: they compare `request.time.toMillis()` with
|
|
1427
|
+
* whatever is stored. A `datetime` field holds an ISO string, which is a type
|
|
1428
|
+
* error that fails closed — the window never opens, and nothing says so. */
|
|
1429
|
+
function millisProblem(schemaOf, cid, key, ref) {
|
|
1430
|
+
const spec = referencedField(schemaOf, ref?.collection, ref?.field);
|
|
1431
|
+
if (spec === void 0 || spec.type === "number") return [];
|
|
1432
|
+
return [`public.submit.${cid}.${key} names '${String(ref?.field)}' on '${String(ref?.collection)}', which is a ${spec.type} field. A per-record bound is EPOCH MILLIS: the rules compare it with request.time.toMillis() and never coerce, so anything else is a type error that refuses every submission — the window simply never opens. Store the instant as a number.`];
|
|
1433
|
+
}
|
|
1434
|
+
function promotedAssigneeProblems(app, promoted, stagedCids) {
|
|
866
1435
|
return Object.entries(app.members).flatMap(([email, roles]) => Object.entries(roles).flatMap(([cid, role]) => {
|
|
867
1436
|
if (role !== "assignee" || cid === "*" || !stagedCids.has(cid)) return [];
|
|
868
1437
|
if (promoted[cid]?.assigneeField !== void 0) return [];
|
|
869
1438
|
return [`members["${email}"] holds "assignee" on '${cid}', and the STAGED version of '${cid}' — the one publish promotes — carries no assigneeField, even if app.json declares one now. Publish writes the roster from app.json and the collection configuration from the deploy, so that member would land with nothing to be compared against: refused every write, while the app keeps working for everybody else. Run deploy again, so the version being published is the one the declaration describes.`];
|
|
870
1439
|
}));
|
|
871
1440
|
}
|
|
1441
|
+
/** The mirror's other half, checked against what publish will actually
|
|
1442
|
+
* promote — the same trap as the assignee's field, reached by the same route.
|
|
1443
|
+
*
|
|
1444
|
+
* `mirror` is published from the MANIFEST (it lives in `public.submit`) while
|
|
1445
|
+
* `mirrorOf` is promoted from what DEPLOY staged. Add both halves to
|
|
1446
|
+
* `app.json` and publish without redeploying, and what lands is a submission
|
|
1447
|
+
* demanding a paired projection write beside a projection whose rule config
|
|
1448
|
+
* does not allow it: every booking is refused, and the declaration on disk
|
|
1449
|
+
* looks perfectly sound.
|
|
1450
|
+
*
|
|
1451
|
+
* Refused in the reverse direction too. Removing `mirrorOf` from a live app
|
|
1452
|
+
* and publishing without a deploy leaves the projection accepting nothing —
|
|
1453
|
+
* and removing it FROM the staged side while the submission still demands it
|
|
1454
|
+
* is the drift this pair exists to prevent. */
|
|
1455
|
+
function promotedMirrorProblems(app, promoted, stagedCids) {
|
|
1456
|
+
return [...addedMirrorProblems(app, promoted, stagedCids), ...strandedMirrorProblems(app, promoted)];
|
|
1457
|
+
}
|
|
1458
|
+
/** The manifest asks for a projection the promoted configuration will not
|
|
1459
|
+
* allow: every submission denied, and nothing on the page to say why. */
|
|
1460
|
+
function addedMirrorProblems(app, promoted, stagedCids) {
|
|
1461
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
1462
|
+
const { mirror } = submit;
|
|
1463
|
+
if (mirror === void 0 || !stagedCids.has(mirror)) return [];
|
|
1464
|
+
if (promoted[mirror]?.mirrorOf === cid) return [];
|
|
1465
|
+
return [`public.submit.${cid}.mirror names '${mirror}', and the STAGED version of '${mirror}' — the one publish promotes — does not declare mirrorOf: "${cid}", even if app.json declares it now. Publish writes the submission side from app.json and the collection side from the deploy, so what lands is a booking that must move its projection beside a projection that refuses to move: every submission is denied, with nothing on the page to say why. Run deploy again, so the version being published is the one the declaration describes.`];
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
/** The other direction, and the DANGEROUS one.
|
|
1469
|
+
*
|
|
1470
|
+
* Take a live pair, delete BOTH halves from `app.json`, and publish without
|
|
1471
|
+
* redeploying. The submission side comes from the manifest, so nothing
|
|
1472
|
+
* requires the projection to move any more; the collection side comes from
|
|
1473
|
+
* staging, which still says `mirrorOf`, so the projection stays writable.
|
|
1474
|
+
* Bookings are then created while the public row goes on saying `open` — the
|
|
1475
|
+
* precise failure the pair exists to prevent, arrived at by removing it.
|
|
1476
|
+
*
|
|
1477
|
+
* Refused rather than tolerated because the app keeps WORKING: submissions
|
|
1478
|
+
* succeed. Only the public page is wrong, and only to the people reading it. */
|
|
1479
|
+
function strandedMirrorProblems(app, promoted) {
|
|
1480
|
+
return Object.entries(promoted).flatMap(([cid, config]) => {
|
|
1481
|
+
const authority = config.mirrorOf;
|
|
1482
|
+
if (authority === void 0) return [];
|
|
1483
|
+
if (app.public?.submit?.[authority]?.mirror === cid) return [];
|
|
1484
|
+
return [`the STAGED version of '${cid}' — the one publish promotes — declares mirrorOf: "${authority}", and app.json no longer declares public.submit.${authority}.mirror: "${cid}". What lands is a projection that is still writable beside submissions that no longer have to move it, so records can be created while '${cid}' goes on advertising them as available. Nothing fails: the app works and the public page lies. Run deploy again, so the version being published is the one the declaration describes.`];
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
872
1487
|
//#endregion
|
|
873
1488
|
//#region src/collection/server/skillAssets.ts
|
|
874
1489
|
/** Read a collection's custom-view HTML, path-safely. `viewFile` is a
|
|
@@ -2993,12 +3608,48 @@ Object.defineProperty(exports, "PUBLIC_CONFIG_DOC", {
|
|
|
2993
3608
|
return PUBLIC_CONFIG_DOC;
|
|
2994
3609
|
}
|
|
2995
3610
|
});
|
|
3611
|
+
Object.defineProperty(exports, "PUBLIC_VIEW_ID", {
|
|
3612
|
+
enumerable: true,
|
|
3613
|
+
get: function() {
|
|
3614
|
+
return PUBLIC_VIEW_ID;
|
|
3615
|
+
}
|
|
3616
|
+
});
|
|
3617
|
+
Object.defineProperty(exports, "RESERVED_VIEW_IDS", {
|
|
3618
|
+
enumerable: true,
|
|
3619
|
+
get: function() {
|
|
3620
|
+
return RESERVED_VIEW_IDS;
|
|
3621
|
+
}
|
|
3622
|
+
});
|
|
2996
3623
|
Object.defineProperty(exports, "STORE_UNREADABLE", {
|
|
2997
3624
|
enumerable: true,
|
|
2998
3625
|
get: function() {
|
|
2999
3626
|
return STORE_UNREADABLE;
|
|
3000
3627
|
}
|
|
3001
3628
|
});
|
|
3629
|
+
Object.defineProperty(exports, "VIEW_AUDIENCES", {
|
|
3630
|
+
enumerable: true,
|
|
3631
|
+
get: function() {
|
|
3632
|
+
return VIEW_AUDIENCES;
|
|
3633
|
+
}
|
|
3634
|
+
});
|
|
3635
|
+
Object.defineProperty(exports, "VIEW_CONFIG_ID", {
|
|
3636
|
+
enumerable: true,
|
|
3637
|
+
get: function() {
|
|
3638
|
+
return VIEW_CONFIG_ID;
|
|
3639
|
+
}
|
|
3640
|
+
});
|
|
3641
|
+
Object.defineProperty(exports, "VIEW_ID_PATTERN", {
|
|
3642
|
+
enumerable: true,
|
|
3643
|
+
get: function() {
|
|
3644
|
+
return VIEW_ID_PATTERN;
|
|
3645
|
+
}
|
|
3646
|
+
});
|
|
3647
|
+
Object.defineProperty(exports, "VIEW_TIER", {
|
|
3648
|
+
enumerable: true,
|
|
3649
|
+
get: function() {
|
|
3650
|
+
return VIEW_TIER;
|
|
3651
|
+
}
|
|
3652
|
+
});
|
|
3002
3653
|
Object.defineProperty(exports, "advanceTriggerDate", {
|
|
3003
3654
|
enumerable: true,
|
|
3004
3655
|
get: function() {
|
|
@@ -3029,6 +3680,12 @@ Object.defineProperty(exports, "appStagingPath", {
|
|
|
3029
3680
|
return appStagingPath;
|
|
3030
3681
|
}
|
|
3031
3682
|
});
|
|
3683
|
+
Object.defineProperty(exports, "appViewTierPath", {
|
|
3684
|
+
enumerable: true,
|
|
3685
|
+
get: function() {
|
|
3686
|
+
return appViewTierPath;
|
|
3687
|
+
}
|
|
3688
|
+
});
|
|
3032
3689
|
Object.defineProperty(exports, "applyMutateAction", {
|
|
3033
3690
|
enumerable: true,
|
|
3034
3691
|
get: function() {
|
|
@@ -3137,6 +3794,12 @@ Object.defineProperty(exports, "maybeSpawnSuccessor", {
|
|
|
3137
3794
|
return maybeSpawnSuccessor;
|
|
3138
3795
|
}
|
|
3139
3796
|
});
|
|
3797
|
+
Object.defineProperty(exports, "normalizeViews", {
|
|
3798
|
+
enumerable: true,
|
|
3799
|
+
get: function() {
|
|
3800
|
+
return normalizeViews;
|
|
3801
|
+
}
|
|
3802
|
+
});
|
|
3140
3803
|
Object.defineProperty(exports, "parseAuthoredApp", {
|
|
3141
3804
|
enumerable: true,
|
|
3142
3805
|
get: function() {
|
|
@@ -3149,12 +3812,24 @@ Object.defineProperty(exports, "parseCivil", {
|
|
|
3149
3812
|
return parseCivil;
|
|
3150
3813
|
}
|
|
3151
3814
|
});
|
|
3815
|
+
Object.defineProperty(exports, "participantScope", {
|
|
3816
|
+
enumerable: true,
|
|
3817
|
+
get: function() {
|
|
3818
|
+
return participantScope;
|
|
3819
|
+
}
|
|
3820
|
+
});
|
|
3152
3821
|
Object.defineProperty(exports, "projectApp", {
|
|
3153
3822
|
enumerable: true,
|
|
3154
3823
|
get: function() {
|
|
3155
3824
|
return projectApp;
|
|
3156
3825
|
}
|
|
3157
3826
|
});
|
|
3827
|
+
Object.defineProperty(exports, "projectAppViews", {
|
|
3828
|
+
enumerable: true,
|
|
3829
|
+
get: function() {
|
|
3830
|
+
return projectAppViews;
|
|
3831
|
+
}
|
|
3832
|
+
});
|
|
3158
3833
|
Object.defineProperty(exports, "projectDeploy", {
|
|
3159
3834
|
enumerable: true,
|
|
3160
3835
|
get: function() {
|
|
@@ -3263,5 +3938,17 @@ Object.defineProperty(exports, "validateRecordObject", {
|
|
|
3263
3938
|
return validateRecordObject;
|
|
3264
3939
|
}
|
|
3265
3940
|
});
|
|
3941
|
+
Object.defineProperty(exports, "viewConfigDocId", {
|
|
3942
|
+
enumerable: true,
|
|
3943
|
+
get: function() {
|
|
3944
|
+
return viewConfigDocId;
|
|
3945
|
+
}
|
|
3946
|
+
});
|
|
3947
|
+
Object.defineProperty(exports, "viewDocId", {
|
|
3948
|
+
enumerable: true,
|
|
3949
|
+
get: function() {
|
|
3950
|
+
return viewDocId;
|
|
3951
|
+
}
|
|
3952
|
+
});
|
|
3266
3953
|
|
|
3267
|
-
//# sourceMappingURL=server-
|
|
3954
|
+
//# sourceMappingURL=server-DknYbXBo.cjs.map
|