@mulmoclaude/core 3.11.0 → 3.13.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/index.cjs +2 -1
- package/dist/collection/server/index.d.ts +1 -1
- package/dist/collection/server/index.js +2 -2
- package/dist/collection/server/publishChecks.d.ts +29 -0
- package/dist/collection/server/publishManifest.d.ts +68 -3
- package/dist/collection/server/publishProject.d.ts +16 -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-zcM1ViCE.js → server-DGjmK9vU.js} +501 -11
- package/dist/server-DGjmK9vU.js.map +1 -0
- package/dist/{server-X_magqUa.cjs → server-_g1rW-Uc.cjs} +506 -10
- package/dist/server-_g1rW-Uc.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/server-X_magqUa.cjs.map +0 -1
- package/dist/server-zcM1ViCE.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");
|
|
@@ -21,13 +22,30 @@ var NameZ = zod.z.string().refine(require_itemId.isValidCollectionName, { messag
|
|
|
21
22
|
* the rules compare it to `request.auth.token.email` verbatim, so any
|
|
22
23
|
* narrowing here would refuse addresses Firebase itself accepts. */
|
|
23
24
|
var EmailZ = zod.z.string().trim().min(3).includes("@");
|
|
24
|
-
/** The
|
|
25
|
-
*
|
|
25
|
+
/** The roles the deployed rules understand.
|
|
26
|
+
*
|
|
27
|
+
* Two of them are row-scoped, in opposite directions, and the pair is what
|
|
28
|
+
* the four-way split could not express:
|
|
29
|
+
*
|
|
30
|
+
* `participant` — the layer that is NAMED but reads only its OWN rows (the
|
|
31
|
+
* rows it submitted). See `readerOf` vs `listedIn`.
|
|
32
|
+
*
|
|
33
|
+
* `assignee` — reads EVERY row and writes only the rows ASSIGNED to it. The
|
|
34
|
+
* stylist who approves their own bookings and not a colleague's; the marker
|
|
35
|
+
* who grades their own students. Which rows are theirs is
|
|
36
|
+
* `collections[cid].assigneeField`, a field on the record holding the
|
|
37
|
+
* member's address. Reads are deliberately unscoped: a stylist needs the
|
|
38
|
+
* whole day's schedule, and scoping the read makes the app unusable.
|
|
39
|
+
*
|
|
40
|
+
* The names are permanent. The deployed rules compare these strings directly
|
|
41
|
+
* and they are written into `app.json` files people commit, so a rename is a
|
|
42
|
+
* migration over published apps rather than an edit. */
|
|
26
43
|
var APP_ROLES = [
|
|
27
44
|
"owner",
|
|
28
45
|
"editor",
|
|
29
46
|
"viewer",
|
|
30
|
-
"participant"
|
|
47
|
+
"participant",
|
|
48
|
+
"assignee"
|
|
31
49
|
];
|
|
32
50
|
var RoleZ = zod.z.enum(APP_ROLES);
|
|
33
51
|
/** `{ email: { "*" | cid: role } }`. The `"*"` key is the app-wide role; a
|
|
@@ -53,6 +71,29 @@ var CollectionConfigZ = zod.z.object({
|
|
|
53
71
|
transitions: zod.z.record(zod.z.string().trim().min(1), zod.z.array(zod.z.string().trim().min(1))).optional(),
|
|
54
72
|
immutable: zod.z.boolean().optional(),
|
|
55
73
|
submitOnly: zod.z.boolean().optional(),
|
|
74
|
+
/** The field naming the member a row belongs to, for the `assignee` role.
|
|
75
|
+
*
|
|
76
|
+
* Holds an ADDRESS, because that is the only thing the rules can compare
|
|
77
|
+
* a member against (`request.auth.token.email`). A `ref` to a staff
|
|
78
|
+
* collection stores the target's primary-key slug, not an address, so it
|
|
79
|
+
* cannot be this field — declare a plain field beside the ref and let the
|
|
80
|
+
* ref stay the thing the UI renders. The alternative, having the rules
|
|
81
|
+
* `get()` the staff record to read an address off it, costs a document
|
|
82
|
+
* access on every write and puts a second document between an
|
|
83
|
+
* authorization decision and its answer.
|
|
84
|
+
*
|
|
85
|
+
* Only meaningful with a member holding `assignee` on this cid; a
|
|
86
|
+
* declaration with the role and no field is refused (`assigneeProblems`),
|
|
87
|
+
* because that member would silently hold nothing. */
|
|
88
|
+
assigneeField: zod.z.string().trim().min(1).optional(),
|
|
89
|
+
/** This collection is the public projection of `mirrorOf` — the other
|
|
90
|
+
* half of `public.submit[...].mirror`, declared here because the rules
|
|
91
|
+
* read it when the PROJECTION is written rather than when the record is.
|
|
92
|
+
*
|
|
93
|
+
* What it buys: `state` may be written by anybody, and only to the value
|
|
94
|
+
* the authority actually says, so a visitor who was refused a slot can
|
|
95
|
+
* repair the stale row that offered it to them. */
|
|
96
|
+
mirrorOf: NameZ.optional(),
|
|
56
97
|
peerVisibility: zod.z.enum(["public", "hidden"]).optional(),
|
|
57
98
|
revealGated: zod.z.boolean().optional(),
|
|
58
99
|
gatedFrom: NameZ.optional(),
|
|
@@ -71,9 +112,60 @@ var CollectionConfigZ = zod.z.object({
|
|
|
71
112
|
* the rules do not coerce strings, so an ISO string reaching Firestore is a
|
|
72
113
|
* type error that fails CLOSED (`inWindow` refuses every submission and the
|
|
73
114
|
* author sees "nobody can submit", not an error). */
|
|
115
|
+
/** A window bound that lives on ANOTHER record, read at write time.
|
|
116
|
+
*
|
|
117
|
+
* `window.from` is one absolute instant for the whole collection, which is
|
|
118
|
+
* enough for a survey and useless for anything recurring: "each class opens
|
|
119
|
+
* three days before it starts, at 08:00" is a bound PER RECORD. So the bound
|
|
120
|
+
* is not computed in the rules — they have no usable date arithmetic and
|
|
121
|
+
* `request.time` is UTC, which is the wrong answer for "08:00" — it is
|
|
122
|
+
* computed by whoever schedules the class, stored on the class record as
|
|
123
|
+
* epoch millis, and merely COMPARED here.
|
|
124
|
+
*
|
|
125
|
+
* `ref` is the field on the record being written that names the target
|
|
126
|
+
* (`classId`); `collection` is the cid the target lives in, fixed in the
|
|
127
|
+
* declaration so that a path is never built out of a value a submitter wrote;
|
|
128
|
+
* `field` is the epoch-millis field on the target.
|
|
129
|
+
*
|
|
130
|
+
* Not spelled `in` — that is an operator in the rules language, and
|
|
131
|
+
* `w.fromField.in` does not parse there. */
|
|
132
|
+
var WindowRefZ = zod.z.object({
|
|
133
|
+
ref: zod.z.string().trim().min(1),
|
|
134
|
+
collection: NameZ,
|
|
135
|
+
field: zod.z.string().trim().min(1)
|
|
136
|
+
}).strict();
|
|
137
|
+
/** The closing bound's per-record twin, and it ships WITH `fromField` rather
|
|
138
|
+
* than as a symmetric extra: a booking desk that opens per slot and never
|
|
139
|
+
* closes is not a booking desk. Same shape, opposite comparison — and
|
|
140
|
+
* EXCLUSIVE where `fromField` is inclusive, so one slot's closing instant and
|
|
141
|
+
* the next one's opening instant may be the same number. */
|
|
74
142
|
var WindowZ = zod.z.object({
|
|
75
143
|
from: zod.z.iso.datetime().optional(),
|
|
76
|
-
until: zod.z.iso.datetime().optional()
|
|
144
|
+
until: zod.z.iso.datetime().optional(),
|
|
145
|
+
fromField: WindowRefZ.optional(),
|
|
146
|
+
untilField: WindowRefZ.optional()
|
|
147
|
+
}).strict();
|
|
148
|
+
/** Which record a `field` document id must name, and what state it must be in.
|
|
149
|
+
*
|
|
150
|
+
* `idFrom: "field"` alone only stops the same string being written twice —
|
|
151
|
+
* nothing stops a client bypassing the page and inventing a slot, so the
|
|
152
|
+
* rules check the referenced record themselves. `exists()` is a FLOOR: a
|
|
153
|
+
* cancelled slot and a slot nobody may book any more exist too, which is what
|
|
154
|
+
* `where` is for.
|
|
155
|
+
*
|
|
156
|
+
* Always the object form, never a bare collection name. Two shapes for one
|
|
157
|
+
* key is the kind of thing a generator gets right once and wrong afterwards,
|
|
158
|
+
* and the rules read `s.idIn.collection` either way. */
|
|
159
|
+
var IdInZ = zod.z.object({
|
|
160
|
+
collection: NameZ,
|
|
161
|
+
where: zod.z.object({
|
|
162
|
+
field: zod.z.string().trim().min(1),
|
|
163
|
+
equals: zod.z.union([
|
|
164
|
+
zod.z.string(),
|
|
165
|
+
zod.z.number(),
|
|
166
|
+
zod.z.boolean()
|
|
167
|
+
])
|
|
168
|
+
}).strict().optional()
|
|
77
169
|
}).strict();
|
|
78
170
|
var ValidateZ = zod.z.object({
|
|
79
171
|
required: zod.z.array(zod.z.string().trim().min(1)).optional(),
|
|
@@ -98,14 +190,46 @@ var SubmitZ = zod.z.object({
|
|
|
98
190
|
emailField: zod.z.string().trim().min(1).optional(),
|
|
99
191
|
createFields: zod.z.array(zod.z.string().trim().min(1)).min(1),
|
|
100
192
|
initialStatus: zod.z.string().trim().min(1).optional(),
|
|
193
|
+
/** `field` is the mode that makes a CONTESTED resource exclusive: the
|
|
194
|
+
* booking's document id IS the slot's id, so the second person to want
|
|
195
|
+
* that slot is writing a document that already exists — an update, which
|
|
196
|
+
* the public submission path never allows. Firestore decides that
|
|
197
|
+
* atomically, so unlike a countable capacity (see `stampField`) this is
|
|
198
|
+
* first-come ENFORCED rather than first-come read off a rank. */
|
|
101
199
|
idFrom: zod.z.enum([
|
|
102
200
|
"auto",
|
|
103
201
|
"auth.uid",
|
|
104
|
-
"auth.uid+field"
|
|
202
|
+
"auth.uid+field",
|
|
203
|
+
"field"
|
|
105
204
|
]).optional(),
|
|
106
205
|
idField: zod.z.string().trim().min(1).optional(),
|
|
206
|
+
/** Required by `idFrom: "field"` — see {@link IdInZ}. */
|
|
207
|
+
idIn: IdInZ.optional(),
|
|
208
|
+
/** The collection holding this record's PUBLIC PROJECTION, one row per
|
|
209
|
+
* contested thing, sharing its document id.
|
|
210
|
+
*
|
|
211
|
+
* A booking carries a name, an address and a phone number, and Firestore
|
|
212
|
+
* rules cannot hide a field, so the public page must not read bookings at
|
|
213
|
+
* all. It reads the projection instead, whose `state` is a copy of "does
|
|
214
|
+
* a booking with this id exist" — and the rules accept the two writes
|
|
215
|
+
* only as one batch, in both directions, so the copy cannot drift into
|
|
216
|
+
* advertising a slot that is gone. */
|
|
217
|
+
mirror: NameZ.optional(),
|
|
107
218
|
validate: ValidateZ.optional(),
|
|
108
219
|
window: WindowZ.optional(),
|
|
220
|
+
/** A field the rules PIN to the server clock on create: the record must
|
|
221
|
+
* carry `request.time` in it, and may never change it afterwards.
|
|
222
|
+
*
|
|
223
|
+
* What it buys is an order nobody can jump. A first-come app takes its
|
|
224
|
+
* capacity from rank rather than from a count — the rules cannot count
|
|
225
|
+
* documents, so "the first 8" can only ever be a reading of the rows —
|
|
226
|
+
* and a rank is only as honest as the timestamp it sorts by. `idFrom`
|
|
227
|
+
* stops a person holding two places; nothing else stops them writing
|
|
228
|
+
* yesterday's date into the field that decides who got there first.
|
|
229
|
+
*
|
|
230
|
+
* Binds EVERY create, the writer branch included, so a staff-entered row
|
|
231
|
+
* cannot be back-dated into the queue either. */
|
|
232
|
+
stampField: zod.z.string().trim().min(1).optional(),
|
|
109
233
|
/** Per CURRENT STATUS, never a flat list: a flat list lets a customer move
|
|
110
234
|
* an approved booking's `startAt` without anyone re-approving it. */
|
|
111
235
|
selfUpdate: zod.z.record(zod.z.string().trim().min(1), zod.z.array(zod.z.string().trim().min(1))).optional(),
|
|
@@ -122,6 +246,24 @@ var PublicZ = zod.z.object({
|
|
|
122
246
|
* well as its own declaration. */
|
|
123
247
|
enabled: zod.z.boolean().optional(),
|
|
124
248
|
read: zod.z.array(NameZ).optional(),
|
|
249
|
+
/** The page the public sees, instead of the generated form.
|
|
250
|
+
*
|
|
251
|
+
* A form is enough to ANSWER something and not enough to CHOOSE from
|
|
252
|
+
* what is available — a stylist-by-hour grid is not the far end of a
|
|
253
|
+
* table. So the app may name one HTML file, which the host publishes to
|
|
254
|
+
* `config/view` and the public page renders in a sandboxed iframe.
|
|
255
|
+
*
|
|
256
|
+
* `submit` stays declared alongside: the view sends an INTENT, and the
|
|
257
|
+
* page it is embedded in performs the write against these rules.
|
|
258
|
+
*
|
|
259
|
+
* `collections` is declared rather than inferred from `read`. Inferring
|
|
260
|
+
* it produces the worst failure this feature has — the view renders, the
|
|
261
|
+
* data it wanted was never sent, and it draws an empty grid with no error
|
|
262
|
+
* anywhere. */
|
|
263
|
+
view: zod.z.object({
|
|
264
|
+
path: zod.z.string().trim().min(1),
|
|
265
|
+
collections: zod.z.array(NameZ).min(1)
|
|
266
|
+
}).strict().optional(),
|
|
125
267
|
submit: zod.z.record(NameZ, SubmitZ).optional()
|
|
126
268
|
}).strict();
|
|
127
269
|
/** The URL name an app is handed out under: `https://<host>/{slug}`.
|
|
@@ -226,7 +368,12 @@ function windowMillis(window) {
|
|
|
226
368
|
if (window.from !== void 0) out.fromMs = Date.parse(window.from);
|
|
227
369
|
if (window.until !== void 0) out.untilMs = Date.parse(window.until);
|
|
228
370
|
if (Object.values(out).some((value) => !Number.isFinite(value))) throw new Error(`publish: window bound is not a parseable timestamp (${JSON.stringify(window)})`);
|
|
229
|
-
|
|
371
|
+
const projected = {
|
|
372
|
+
...out,
|
|
373
|
+
...window.fromField === void 0 ? {} : { fromField: window.fromField },
|
|
374
|
+
...window.untilField === void 0 ? {} : { untilField: window.untilField }
|
|
375
|
+
};
|
|
376
|
+
return Object.keys(projected).length > 0 ? projected : void 0;
|
|
230
377
|
}
|
|
231
378
|
/** One `public.submit[cid]`, with its window lowered. Everything else passes
|
|
232
379
|
* through: the rules read these keys by the names the author wrote. */
|
|
@@ -300,6 +447,7 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
300
447
|
enabled: authored.public?.enabled === true,
|
|
301
448
|
read: authored.public?.read ?? [],
|
|
302
449
|
submit,
|
|
450
|
+
...authored.public?.view === void 0 ? {} : { view: { collections: authored.public.view.collections } },
|
|
303
451
|
publishedAt: stamp.publishedAt
|
|
304
452
|
};
|
|
305
453
|
if (authored.name !== void 0) config.name = authored.name;
|
|
@@ -597,7 +745,7 @@ function ruleReadFields(submit) {
|
|
|
597
745
|
field: submit.emailField,
|
|
598
746
|
why: `public.submit.<cid>.emailField — the rules compare it to the submitter's verified address`
|
|
599
747
|
});
|
|
600
|
-
if (submit.idFrom === "auth.uid+field" && submit.idField !== void 0) fields.push({
|
|
748
|
+
if ((submit.idFrom === "auth.uid+field" || submit.idFrom === "field") && submit.idField !== void 0) fields.push({
|
|
601
749
|
field: submit.idField,
|
|
602
750
|
why: `public.submit.<cid>.idField — the rules rebuild the document id from it`
|
|
603
751
|
});
|
|
@@ -620,10 +768,58 @@ function submitCoherenceProblems(app, cid, submit) {
|
|
|
620
768
|
const collection = app.collections?.[cid];
|
|
621
769
|
const problems = [...statusCoherenceProblems(cid, submit, collection), ...createFieldProblems(cid, submit)];
|
|
622
770
|
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.`);
|
|
771
|
+
problems.push(...fieldIdProblems(cid, submit));
|
|
623
772
|
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.`);
|
|
624
773
|
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.`);
|
|
625
774
|
return problems;
|
|
626
775
|
}
|
|
776
|
+
/** `idFrom: "field"` makes the document id a CLAIM ABOUT ANOTHER RECORD, and
|
|
777
|
+
* the claim is only worth what is checked.
|
|
778
|
+
*
|
|
779
|
+
* `idIn` is required rather than optional, and that is the whole point of
|
|
780
|
+
* refusing here: without it the id is any string a stranger likes, so the app
|
|
781
|
+
* quietly accepts bookings for slots that do not exist. Nothing downstream
|
|
782
|
+
* ever notices — the booking is real, its slot is not — which is exactly the
|
|
783
|
+
* kind of hole a gate is for and a rule cannot state.
|
|
784
|
+
*
|
|
785
|
+
* `idIn` without the mode is refused for the opposite reason: the rules read
|
|
786
|
+
* it only in that branch, so an author who wrote it believes a check is
|
|
787
|
+
* running that is not. */
|
|
788
|
+
function fieldIdProblems(cid, submit) {
|
|
789
|
+
const problems = [];
|
|
790
|
+
if (submit.idFrom === "field") {
|
|
791
|
+
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.`);
|
|
792
|
+
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" } }.`);
|
|
793
|
+
} else if (submit.idIn !== void 0) {
|
|
794
|
+
const mode = submit.idFrom === void 0 ? "absent" : JSON.stringify(submit.idFrom);
|
|
795
|
+
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.`);
|
|
796
|
+
}
|
|
797
|
+
return problems;
|
|
798
|
+
}
|
|
799
|
+
/** Every `idIn` target, checked against the collections this repository has.
|
|
800
|
+
*
|
|
801
|
+
* Separate from {@link fieldIdProblems} for the reason the file is split at
|
|
802
|
+
* all: that one reads the declaration alone, this one needs to know what
|
|
803
|
+
* exists. */
|
|
804
|
+
function idTargetProblems(app, collections) {
|
|
805
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
806
|
+
const names = known.size > 0 ? [...known].sort().join(", ") : "(none)";
|
|
807
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => idInTargetProblems(cid, submit, known, names));
|
|
808
|
+
}
|
|
809
|
+
/** Where a `field` id says its record must be found.
|
|
810
|
+
*
|
|
811
|
+
* A typo passes every other check: the rules look the record up in a
|
|
812
|
+
* collection that does not exist, the lookup can never succeed, and every
|
|
813
|
+
* submission is refused with no explanation anywhere. A collection pointing
|
|
814
|
+
* at ITSELF is worse than a typo — on a create the document being written
|
|
815
|
+
* does not exist yet, so it is a declaration that can never accept anything. */
|
|
816
|
+
function idInTargetProblems(cid, submit, known, names) {
|
|
817
|
+
const target = submit.idIn?.collection;
|
|
818
|
+
if (target === void 0) return [];
|
|
819
|
+
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).`];
|
|
820
|
+
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}.`];
|
|
821
|
+
return [];
|
|
822
|
+
}
|
|
627
823
|
/** The staged reveal reads its flag off the PARENT record, so the path to that
|
|
628
824
|
* parent is not optional decoration — without it the gate never opens. */
|
|
629
825
|
function gateCoherenceProblems(cid, collection) {
|
|
@@ -653,7 +849,8 @@ function unknownCidProblems(app, collections) {
|
|
|
653
849
|
["collections", Object.keys(app.collections ?? {})],
|
|
654
850
|
["public.read", app.public?.read ?? []],
|
|
655
851
|
["public.submit", Object.keys(app.public?.submit ?? {})],
|
|
656
|
-
["participantRead", app.participantRead ?? []]
|
|
852
|
+
["participantRead", app.participantRead ?? []],
|
|
853
|
+
["members", [...new Set(Object.values(app.members).flatMap((roles) => Object.keys(roles)))].filter((key) => key !== "*")]
|
|
657
854
|
].flatMap(([where, cids]) => cids.filter((cid) => !known.has(cid)).map((cid) => `${where} names '${cid}', which is not a shared collection in this repository. Shared collections here: ${known.size > 0 ? [...known].sort().join(", ") : "(none - a schema needs storage.type \"firestore\")"}.`));
|
|
658
855
|
}
|
|
659
856
|
/** Everything publish refuses, as lines the author can act on.
|
|
@@ -670,7 +867,13 @@ function publishProblems(app, collections, publisherEmail) {
|
|
|
670
867
|
...mailProblems(app),
|
|
671
868
|
...submitShapeProblems(app),
|
|
672
869
|
...coherenceProblems(app),
|
|
673
|
-
...primaryKeyProblems(app, collections)
|
|
870
|
+
...primaryKeyProblems(app, collections),
|
|
871
|
+
...assigneeProblems(app),
|
|
872
|
+
...stampProblems(app),
|
|
873
|
+
...windowRefProblems(app, collections),
|
|
874
|
+
...idTargetProblems(app, collections),
|
|
875
|
+
...mirrorProblems(app, collections),
|
|
876
|
+
...publicViewProblems(app, collections)
|
|
674
877
|
];
|
|
675
878
|
}
|
|
676
879
|
/** A public submission must NOT be allowed to name its own primary key.
|
|
@@ -700,6 +903,293 @@ function primaryKeyProblems(app, collections) {
|
|
|
700
903
|
return [`public.submit.${cid}.createFields must NOT include "${primaryKey}", the schema's primaryKey: the rules can pin the document id but not the value of a field, so a submitter could write at their own id while claiming another record's. A shared record's identity is its document id — the store fills the field from it, and a submitted value is either the same thing or a lie that is thrown away.`];
|
|
701
904
|
});
|
|
702
905
|
}
|
|
906
|
+
/** `assignee` without the field that says which rows are theirs.
|
|
907
|
+
*
|
|
908
|
+
* A FAIL-CLOSED trap of the worst kind, because it fails closed for one
|
|
909
|
+
* person and nobody else: the rules ask `collections[cid].assigneeField` for
|
|
910
|
+
* the field to compare, find nothing, and refuse every write that member
|
|
911
|
+
* makes. The app works for the owner who set it up, and the member it was set
|
|
912
|
+
* up for is told only "permission denied".
|
|
913
|
+
*
|
|
914
|
+
* `'*': "assignee"` is refused outright rather than checked against every
|
|
915
|
+
* collection. The role means "the rows assigned to you", and what counts as
|
|
916
|
+
* assigned is per collection — an app-wide one would need the same field name
|
|
917
|
+
* to be right everywhere, and where it is missing it silently means "no
|
|
918
|
+
* access to this collection" rather than "no scoping here".
|
|
919
|
+
*/
|
|
920
|
+
function assigneeProblems(app) {
|
|
921
|
+
return Object.entries(app.members).flatMap(([email, roles]) => Object.entries(roles).flatMap(([cid, role]) => {
|
|
922
|
+
if (role !== "assignee") return [];
|
|
923
|
+
if (cid === "*") return [`members["${email}"] holds "assignee" under "*", and the role cannot be app-wide: which rows are yours is declared per collection (\`collections.<cid>.assigneeField\`). Name the collections instead — { "bookings": "assignee" }.`];
|
|
924
|
+
if (app.collections?.[cid]?.assigneeField !== void 0) return [];
|
|
925
|
+
return [`members["${email}"] holds "assignee" on '${cid}', but collections.${cid}.assigneeField does not say which field names the member a row belongs to. Add it (assigneeField: "<a field holding an address>"), or give a role that is not row-scoped. Without it the rules have nothing to compare and refuse every write that member makes, while the app keeps working for everybody else.`];
|
|
926
|
+
}));
|
|
927
|
+
}
|
|
928
|
+
/** A server-stamped field the submitter cannot write, or can rewrite later.
|
|
929
|
+
*
|
|
930
|
+
* Both failures are silent in opposite directions. Left out of
|
|
931
|
+
* `createFields`, the rules refuse every submission (`hasOnly(createFields)`
|
|
932
|
+
* rejects the key the stamp check requires) — an app nobody can use. Left IN
|
|
933
|
+
* a `selfUpdate` list, the field the queue is ordered by becomes editable by
|
|
934
|
+
* the person standing in the queue. */
|
|
935
|
+
function stampProblems(app) {
|
|
936
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
937
|
+
const stamp = submit.stampField;
|
|
938
|
+
if (stamp === void 0) return [];
|
|
939
|
+
const problems = [];
|
|
940
|
+
if (!submit.createFields.includes(stamp)) problems.push(`public.submit.${cid}.stampField names '${stamp}', which is not in createFields. The rules require the record to CARRY the server time in that field, and refuse any key outside createFields — so every submission is denied. Add it to createFields; the page fills it in, not the person.`);
|
|
941
|
+
for (const [status, fields] of Object.entries(submit.selfUpdate ?? {})) {
|
|
942
|
+
if (!fields.includes(stamp)) continue;
|
|
943
|
+
problems.push(`public.submit.${cid}.selfUpdate.${status} lets the submitter write '${stamp}', which is the field stampField pins to the server clock. Whatever that field orders — a first-come queue, an audit trail — could then be rewritten by the person it ranks. Remove it from selfUpdate.`);
|
|
944
|
+
}
|
|
945
|
+
return problems;
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
/** A per-record window bound pointing at a collection or a field the submitter
|
|
949
|
+
* never writes.
|
|
950
|
+
*
|
|
951
|
+
* `fromField` makes the rules read another record, and every part of that
|
|
952
|
+
* read is fail-closed: an unknown collection, or a `ref` the submission does
|
|
953
|
+
* not carry, means the bound can never be satisfied and the form is shut for
|
|
954
|
+
* good. */
|
|
955
|
+
function windowRefProblems(app, collections) {
|
|
956
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
957
|
+
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")]);
|
|
958
|
+
}
|
|
959
|
+
/** Both bounds, checked identically. `untilField` arrived with the booking
|
|
960
|
+
* desk and reads exactly like its twin, so a check that knew only about
|
|
961
|
+
* `fromField` would let the closing half through unchecked — and a closing
|
|
962
|
+
* bound that names nothing does not leave the door ajar, it refuses every
|
|
963
|
+
* submission with no explanation. */
|
|
964
|
+
function windowBoundProblems(cid, submit, known, key, ref, which) {
|
|
965
|
+
if (ref === void 0) return [];
|
|
966
|
+
const problems = [];
|
|
967
|
+
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)"}.`);
|
|
968
|
+
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.`);
|
|
969
|
+
return problems;
|
|
970
|
+
}
|
|
971
|
+
/** The two halves of a mirror, checked as the pair they only work as.
|
|
972
|
+
*
|
|
973
|
+
* `mirror` on the submission and `mirrorOf` on the projection are separate
|
|
974
|
+
* keys in separate places, and each is inert without the other: a booking
|
|
975
|
+
* whose slot declares no `mirrorOf` can never be created (the rules demand a
|
|
976
|
+
* paired write that the projection's own rule will refuse), and a projection
|
|
977
|
+
* whose authority declares no `mirror` drifts unbounded because nothing makes
|
|
978
|
+
* the two move together. Both failures are silent, and one of them —
|
|
979
|
+
* advertising a slot somebody already holds — is the exact thing the mirror
|
|
980
|
+
* exists to prevent.
|
|
981
|
+
*
|
|
982
|
+
* Also refuses a collection mirroring ITSELF, which reads as a typo and
|
|
983
|
+
* behaves as an unwritable collection: every create would have to prove its
|
|
984
|
+
* own document is simultaneously taken and open. */
|
|
985
|
+
function mirrorProblems(app, collections) {
|
|
986
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
987
|
+
const names = known.size > 0 ? [...known].sort().join(", ") : "(none)";
|
|
988
|
+
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))];
|
|
989
|
+
}
|
|
990
|
+
/** The submission side: `public.submit[cid].mirror`. */
|
|
991
|
+
function mirrorClaimProblems(app, cid, submit, known, names) {
|
|
992
|
+
const { mirror } = submit;
|
|
993
|
+
if (mirror === void 0) return [];
|
|
994
|
+
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.`];
|
|
995
|
+
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}.`];
|
|
996
|
+
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.`];
|
|
997
|
+
return [];
|
|
998
|
+
}
|
|
999
|
+
/** The projection side: `collections[cid].mirrorOf`. */
|
|
1000
|
+
function mirrorOfProblems(app, cid, collection, known, names) {
|
|
1001
|
+
const authority = collection.mirrorOf;
|
|
1002
|
+
if (authority === void 0) return [];
|
|
1003
|
+
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}.`];
|
|
1004
|
+
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
|
+
return [];
|
|
1006
|
+
}
|
|
1007
|
+
/** What the public view is handed. Declared, never inferred — a view whose
|
|
1008
|
+
* datasets were guessed from `public.read` renders perfectly and draws an
|
|
1009
|
+
* empty grid, with nothing in the page, the rules or the log to say why. */
|
|
1010
|
+
function publicViewProblems(app, collections) {
|
|
1011
|
+
const view = app.public?.view;
|
|
1012
|
+
if (view === void 0) return [];
|
|
1013
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
1014
|
+
const readable = new Set(app.public?.read ?? []);
|
|
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;
|
|
1025
|
+
}
|
|
1026
|
+
/** What publish will actually promote, checked as the PAIR it becomes.
|
|
1027
|
+
*
|
|
1028
|
+
* `publishProblems` reads the manifest, where `members` and `collections` sit
|
|
1029
|
+
* side by side and agree. Publish does not write that pair. It writes the
|
|
1030
|
+
* roster from the manifest and the collection configuration from what DEPLOY
|
|
1031
|
+
* staged, so the app that lands is one half of each — and no check has ever
|
|
1032
|
+
* looked at that combination.
|
|
1033
|
+
*
|
|
1034
|
+
* The sequence that gets through: deploy revision A with no `assigneeField`,
|
|
1035
|
+
* add the field AND the member in revision B, publish without redeploying.
|
|
1036
|
+
* Every manifest-level check passes on a declaration that is internally sound,
|
|
1037
|
+
* while what lands is A's field-less configuration beside B's roster — an
|
|
1038
|
+
* assignee with nothing to be compared against, refused every write, in an app
|
|
1039
|
+
* that keeps working for everybody else. That is the precise trap
|
|
1040
|
+
* `assigneeProblems` exists to prevent, reached by the one route it cannot
|
|
1041
|
+
* see.
|
|
1042
|
+
*
|
|
1043
|
+
* Separate from `publishProblems` because it needs what deploy staged, which
|
|
1044
|
+
* is a Firestore read the host makes and this package does not. It is checked
|
|
1045
|
+
* against `stagedRuleConfig` — the same function the projection uses — rather
|
|
1046
|
+
* than against a re-derivation, so the value validated is the value written.
|
|
1047
|
+
*
|
|
1048
|
+
* Only the staged half can be stale, so only that half is named and the fix is
|
|
1049
|
+
* "deploy again" rather than "fix the declaration". */
|
|
1050
|
+
function promotedRoleProblems(app, staged) {
|
|
1051
|
+
const promoted = stagedRuleConfig(staged).collections ?? {};
|
|
1052
|
+
const stagedCids = new Set(staged.map((entry) => entry.cid));
|
|
1053
|
+
return [
|
|
1054
|
+
...promotedAssigneeProblems(app, promoted, stagedCids),
|
|
1055
|
+
...promotedMirrorProblems(app, promoted, stagedCids),
|
|
1056
|
+
...promotedRefFieldProblems(app, staged)
|
|
1057
|
+
];
|
|
1058
|
+
}
|
|
1059
|
+
/** The FIELDS a rule reads off another record — `idIn.where.field` and the two
|
|
1060
|
+
* window bounds — checked against the schema publish is about to promote.
|
|
1061
|
+
*
|
|
1062
|
+
* These are checked here rather than in `publishProblems` because that gate
|
|
1063
|
+
* is given a cid and a primary key per collection and nothing else, on
|
|
1064
|
+
* purpose: it reads the DECLARATION. A field name can only be judged against
|
|
1065
|
+
* a schema, and the schema that matters is the STAGED one — the version
|
|
1066
|
+
* publish promotes — not whatever the working tree says now.
|
|
1067
|
+
*
|
|
1068
|
+
* What a typo costs: `where: { field: "staet" }` publishes cleanly, the
|
|
1069
|
+
* rules' comparison can never match, and every submission is denied with no
|
|
1070
|
+
* message. The author's own app looks broken with nothing to read.
|
|
1071
|
+
*
|
|
1072
|
+
* Only fields the schema DECLARES are accepted. A record may carry more than
|
|
1073
|
+
* its schema does, but a shared collection's records are written through it,
|
|
1074
|
+
* and "the field exists on some rows" is not something a gate can promise. */
|
|
1075
|
+
function promotedRefFieldProblems(app, staged) {
|
|
1076
|
+
const schemaOf = new Map(staged.map((entry) => [entry.cid, entry.doc.publishedSchema]));
|
|
1077
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitRefProblems(schemaOf, cid, submit));
|
|
1078
|
+
}
|
|
1079
|
+
function submitRefProblems(schemaOf, cid, submit) {
|
|
1080
|
+
return [
|
|
1081
|
+
...idInRefProblems(schemaOf, cid, submit),
|
|
1082
|
+
...boundRefProblems(schemaOf, cid, "fromField", submit.window?.fromField),
|
|
1083
|
+
...boundRefProblems(schemaOf, cid, "untilField", submit.window?.untilField)
|
|
1084
|
+
];
|
|
1085
|
+
}
|
|
1086
|
+
function idInRefProblems(schemaOf, cid, submit) {
|
|
1087
|
+
const where = submit.idIn?.where;
|
|
1088
|
+
if (where === void 0) return [];
|
|
1089
|
+
return [...refFieldProblem(schemaOf, cid, "idIn.where.field", submit.idIn?.collection, where.field), ...comparableProblem(schemaOf, cid, submit.idIn?.collection, where)];
|
|
1090
|
+
}
|
|
1091
|
+
function boundRefProblems(schemaOf, cid, key, ref) {
|
|
1092
|
+
if (ref === void 0) return [];
|
|
1093
|
+
return [...refFieldProblem(schemaOf, cid, `window.${key}.field`, ref.collection, ref.field), ...millisProblem(schemaOf, cid, `window.${key}.field`, ref)];
|
|
1094
|
+
}
|
|
1095
|
+
/** The field spec a reference points at, or undefined when there is nothing
|
|
1096
|
+
* staged to judge it against (the host refuses that separately, naming every
|
|
1097
|
+
* missing collection at once). */
|
|
1098
|
+
function referencedField(schemaOf, target, field) {
|
|
1099
|
+
if (target === void 0 || field === void 0) return void 0;
|
|
1100
|
+
return schemaOf.get(target)?.fields?.[field];
|
|
1101
|
+
}
|
|
1102
|
+
/** An enum's domain, or undefined for every other kind. Narrowed by the key
|
|
1103
|
+
* rather than asserted: `fields` is a discriminated union and only some of
|
|
1104
|
+
* its members carry `values`. */
|
|
1105
|
+
function enumValues(spec) {
|
|
1106
|
+
return spec.type === "enum" ? spec.values : void 0;
|
|
1107
|
+
}
|
|
1108
|
+
function refFieldProblem(schemaOf, cid, key, target, field) {
|
|
1109
|
+
if (target === void 0 || field === void 0) return [];
|
|
1110
|
+
const schema = schemaOf.get(target);
|
|
1111
|
+
if (schema === void 0 || referencedField(schemaOf, target, field) !== void 0) return [];
|
|
1112
|
+
const known = Object.keys(schema.fields ?? {}).sort().join(", ");
|
|
1113
|
+
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)"}.`];
|
|
1114
|
+
}
|
|
1115
|
+
/** A comparison the rules can never satisfy is as dead as a missing field, and
|
|
1116
|
+
* looks even more correct on the page: an `enum` whose domain does not contain
|
|
1117
|
+
* the value, or a boolean field compared with a string. */
|
|
1118
|
+
function comparableProblem(schemaOf, cid, target, where) {
|
|
1119
|
+
const spec = referencedField(schemaOf, target, where.field);
|
|
1120
|
+
if (spec === void 0) return [];
|
|
1121
|
+
const said = JSON.stringify(where.equals);
|
|
1122
|
+
const values = enumValues(spec);
|
|
1123
|
+
if (values !== void 0) {
|
|
1124
|
+
if (values.includes(String(where.equals))) return [];
|
|
1125
|
+
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.`];
|
|
1126
|
+
}
|
|
1127
|
+
const wanted = spec.type === "number" ? "number" : spec.type === "boolean" ? "boolean" : "string";
|
|
1128
|
+
if (typeof where.equals === wanted) return [];
|
|
1129
|
+
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.`];
|
|
1130
|
+
}
|
|
1131
|
+
/** A per-record window bound is EPOCH MILLIS, because the rules have no date
|
|
1132
|
+
* arithmetic and do not coerce: they compare `request.time.toMillis()` with
|
|
1133
|
+
* whatever is stored. A `datetime` field holds an ISO string, which is a type
|
|
1134
|
+
* error that fails closed — the window never opens, and nothing says so. */
|
|
1135
|
+
function millisProblem(schemaOf, cid, key, ref) {
|
|
1136
|
+
const spec = referencedField(schemaOf, ref?.collection, ref?.field);
|
|
1137
|
+
if (spec === void 0 || spec.type === "number") return [];
|
|
1138
|
+
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.`];
|
|
1139
|
+
}
|
|
1140
|
+
function promotedAssigneeProblems(app, promoted, stagedCids) {
|
|
1141
|
+
return Object.entries(app.members).flatMap(([email, roles]) => Object.entries(roles).flatMap(([cid, role]) => {
|
|
1142
|
+
if (role !== "assignee" || cid === "*" || !stagedCids.has(cid)) return [];
|
|
1143
|
+
if (promoted[cid]?.assigneeField !== void 0) return [];
|
|
1144
|
+
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.`];
|
|
1145
|
+
}));
|
|
1146
|
+
}
|
|
1147
|
+
/** The mirror's other half, checked against what publish will actually
|
|
1148
|
+
* promote — the same trap as the assignee's field, reached by the same route.
|
|
1149
|
+
*
|
|
1150
|
+
* `mirror` is published from the MANIFEST (it lives in `public.submit`) while
|
|
1151
|
+
* `mirrorOf` is promoted from what DEPLOY staged. Add both halves to
|
|
1152
|
+
* `app.json` and publish without redeploying, and what lands is a submission
|
|
1153
|
+
* demanding a paired projection write beside a projection whose rule config
|
|
1154
|
+
* does not allow it: every booking is refused, and the declaration on disk
|
|
1155
|
+
* looks perfectly sound.
|
|
1156
|
+
*
|
|
1157
|
+
* Refused in the reverse direction too. Removing `mirrorOf` from a live app
|
|
1158
|
+
* and publishing without a deploy leaves the projection accepting nothing —
|
|
1159
|
+
* and removing it FROM the staged side while the submission still demands it
|
|
1160
|
+
* is the drift this pair exists to prevent. */
|
|
1161
|
+
function promotedMirrorProblems(app, promoted, stagedCids) {
|
|
1162
|
+
return [...addedMirrorProblems(app, promoted, stagedCids), ...strandedMirrorProblems(app, promoted)];
|
|
1163
|
+
}
|
|
1164
|
+
/** The manifest asks for a projection the promoted configuration will not
|
|
1165
|
+
* allow: every submission denied, and nothing on the page to say why. */
|
|
1166
|
+
function addedMirrorProblems(app, promoted, stagedCids) {
|
|
1167
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
1168
|
+
const { mirror } = submit;
|
|
1169
|
+
if (mirror === void 0 || !stagedCids.has(mirror)) return [];
|
|
1170
|
+
if (promoted[mirror]?.mirrorOf === cid) return [];
|
|
1171
|
+
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.`];
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
/** The other direction, and the DANGEROUS one.
|
|
1175
|
+
*
|
|
1176
|
+
* Take a live pair, delete BOTH halves from `app.json`, and publish without
|
|
1177
|
+
* redeploying. The submission side comes from the manifest, so nothing
|
|
1178
|
+
* requires the projection to move any more; the collection side comes from
|
|
1179
|
+
* staging, which still says `mirrorOf`, so the projection stays writable.
|
|
1180
|
+
* Bookings are then created while the public row goes on saying `open` — the
|
|
1181
|
+
* precise failure the pair exists to prevent, arrived at by removing it.
|
|
1182
|
+
*
|
|
1183
|
+
* Refused rather than tolerated because the app keeps WORKING: submissions
|
|
1184
|
+
* succeed. Only the public page is wrong, and only to the people reading it. */
|
|
1185
|
+
function strandedMirrorProblems(app, promoted) {
|
|
1186
|
+
return Object.entries(promoted).flatMap(([cid, config]) => {
|
|
1187
|
+
const authority = config.mirrorOf;
|
|
1188
|
+
if (authority === void 0) return [];
|
|
1189
|
+
if (app.public?.submit?.[authority]?.mirror === cid) return [];
|
|
1190
|
+
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.`];
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
703
1193
|
//#endregion
|
|
704
1194
|
//#region src/collection/server/skillAssets.ts
|
|
705
1195
|
/** Read a collection's custom-view HTML, path-safely. `viewFile` is a
|
|
@@ -3004,6 +3494,12 @@ Object.defineProperty(exports, "promoteSchema", {
|
|
|
3004
3494
|
return promoteSchema;
|
|
3005
3495
|
}
|
|
3006
3496
|
});
|
|
3497
|
+
Object.defineProperty(exports, "promotedRoleProblems", {
|
|
3498
|
+
enumerable: true,
|
|
3499
|
+
get: function() {
|
|
3500
|
+
return promotedRoleProblems;
|
|
3501
|
+
}
|
|
3502
|
+
});
|
|
3007
3503
|
Object.defineProperty(exports, "promptPathsFor", {
|
|
3008
3504
|
enumerable: true,
|
|
3009
3505
|
get: function() {
|
|
@@ -3089,4 +3585,4 @@ Object.defineProperty(exports, "validateRecordObject", {
|
|
|
3089
3585
|
}
|
|
3090
3586
|
});
|
|
3091
3587
|
|
|
3092
|
-
//# sourceMappingURL=server-
|
|
3588
|
+
//# sourceMappingURL=server-_g1rW-Uc.cjs.map
|