@mulmoclaude/core 3.15.0 → 4.0.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 +2 -2
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/index.cjs +2 -33
- package/dist/collection/server/index.d.ts +0 -4
- package/dist/collection/server/index.js +3 -3
- package/dist/collection-watchers/index.cjs +2 -2
- package/dist/collection-watchers/index.js +2 -2
- package/dist/{discovery-B9zdNkgW.js → discovery-CQp0wi-n.js} +2 -2
- package/dist/{discovery-B9zdNkgW.js.map → discovery-CQp0wi-n.js.map} +1 -1
- package/dist/{discovery-BsXiDZBR.cjs → discovery-C_3gXoin.cjs} +2 -2
- package/dist/{discovery-BsXiDZBR.cjs.map → discovery-C_3gXoin.cjs.map} +1 -1
- package/dist/feeds/server/index.cjs +2 -2
- package/dist/feeds/server/index.js +2 -2
- package/dist/google/index.cjs +1 -1
- package/dist/google/index.js +1 -1
- package/dist/{server-jm7aGA3g.js → server-BIewQYpC.js} +4 -1583
- package/dist/server-BIewQYpC.js.map +1 -0
- package/dist/{server-DLzQ4jFu.cjs → server-CF_tGCGP.cjs} +2 -1767
- package/dist/server-CF_tGCGP.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/collection/server/appViews.d.ts +0 -136
- package/dist/collection/server/publishChecks.d.ts +0 -58
- package/dist/collection/server/publishManifest.d.ts +0 -257
- package/dist/collection/server/publishProject.d.ts +0 -332
- package/dist/server-DLzQ4jFu.cjs.map +0 -1
- package/dist/server-jm7aGA3g.js.map +0 -1
|
@@ -1,1594 +1,15 @@
|
|
|
1
1
|
import { a as isErrorWithCode, c as isStringArray, l as isUnknownArray, s as isRecord, t as errorMessage } from "./dist-D8zokgGo.js";
|
|
2
2
|
import { n as writeFileAtomic } from "./root-BMroU_mB.js";
|
|
3
3
|
import { n as toPosixRelPath } from "./relPath-DW8MC8VO.js";
|
|
4
|
-
import { B as fieldText, I as embedTargetId, L as isFieldDrivenEvery, N as COMPUTED_TYPES, V as fieldTextOrNull, d as parseIsoDate, f as parseIsoDateTime,
|
|
4
|
+
import { B as fieldText, I as embedTargetId, L as isFieldDrivenEvery, N as COMPUTED_TYPES, V as fieldTextOrNull, d as parseIsoDate, f as parseIsoDateTime, z as storageKindFor } from "./itemId-DfTm08jm.js";
|
|
5
5
|
import { C as actionVisible, _ as uniqueRefTargets, b as projectBacklinkRow, c as selectDynamicRecord, f as itemIsDone, g as uniqueEmbedTargets, h as uniqueBacklinkSources, i as ownProp, n as deriveAll, o as firstDateField, s as resolveIcon, t as defangForPrompt, v as backlinkRows, x as rollupValue, y as coerceNumeric } from "./promptSafety-CSdyp7RF.js";
|
|
6
|
-
import { B as SCHEMA_FILE$1, C as CollectionQueryZ,
|
|
7
|
-
import { r as isSafeCustomViewPath } from "./templatePath-k_WNbL_Q.js";
|
|
6
|
+
import { B as SCHEMA_FILE$1, C as CollectionQueryZ, J as isBackendUnavailable, K as safeSlugName, O as isRegularFile, S as compileJsonlQuery, U as resolveDataDir, V as isContainedInRoot, W as resolveTemplatePath, X as archiveDir, b as queryCsv, c as resolveMutateSet, d as storeFor, f as checkpointSqliteDatabase, ht as stagingSkillDir, i as resolvePrimaryField, it as isPresetSlug$1, j as resolveCreateItemId, m as cacheDir, n as discoverCollections, nt as getWorkspaceRoot, ot as log, r as loadCollection, s as CollectionSchemaZ, u as readOnlyRefusal, y as normalizeCsvValue } from "./discovery-CQp0wi-n.js";
|
|
8
7
|
import { ingestStatePath } from "./feeds/paths.js";
|
|
9
8
|
import { mirrorSkillWrite } from "./skill-bridge/index.js";
|
|
10
9
|
import path from "node:path";
|
|
11
10
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
12
11
|
import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
13
12
|
import { z } from "zod";
|
|
14
|
-
//#region src/collection/server/appViews.ts
|
|
15
|
-
/** The audiences a view may be written for. A CLOSED set: each one names a
|
|
16
|
-
* tier with a rule behind it, so an unknown value has nowhere to be
|
|
17
|
-
* published to and is refused before it gets there. */
|
|
18
|
-
var VIEW_AUDIENCES = [
|
|
19
|
-
"public",
|
|
20
|
-
"member",
|
|
21
|
-
"participant"
|
|
22
|
-
];
|
|
23
|
-
/** Where each audience's documents live under `apps/{aid}`. `public` is not
|
|
24
|
-
* here: it keeps `config/public` + `config/view`, which are already published
|
|
25
|
-
* and already read by a deployed runtime. */
|
|
26
|
-
var VIEW_TIER = {
|
|
27
|
-
member: "member",
|
|
28
|
-
participant: "roster"
|
|
29
|
-
};
|
|
30
|
-
/** The id `public.view` normalizes to. Fixed rather than derived, so two
|
|
31
|
-
* implementations of the same normalization cannot pick different ones. */
|
|
32
|
-
var PUBLIC_VIEW_ID = "public";
|
|
33
|
-
/** `config` is the projection's own document in every tier (`live:config`),
|
|
34
|
-
* so a view may not be called that — the two would be the same document. */
|
|
35
|
-
var RESERVED_VIEW_IDS = ["config"];
|
|
36
|
-
/** What an id may be.
|
|
37
|
-
*
|
|
38
|
-
* Narrow on purpose: this value is written by the author, and it becomes a
|
|
39
|
-
* Firestore document id under a `live:` / `staged:` prefix. Excluding `:`
|
|
40
|
-
* keeps the prefix and the id from running together; excluding `/`, `.` and
|
|
41
|
-
* `__…__` keeps it a legal document id that addresses the path it says. */
|
|
42
|
-
var VIEW_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
43
|
-
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)";
|
|
44
|
-
/** Both declarations of the same thing, in one file.
|
|
45
|
-
*
|
|
46
|
-
* Refused rather than merged or preferred: whichever way it were resolved,
|
|
47
|
-
* the author would have written two answers and been shown neither. */
|
|
48
|
-
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.";
|
|
49
|
-
/** The two declarations, as one list, or the refusal that they are both there.
|
|
50
|
-
*
|
|
51
|
-
* `public.view` becomes an entry under the reserved id, so everything
|
|
52
|
-
* downstream reads one shape and "which spelling was used" is decided once. */
|
|
53
|
-
function declaredViews(app) {
|
|
54
|
-
const legacy = app.public?.view;
|
|
55
|
-
const authored = app.views;
|
|
56
|
-
if (legacy !== void 0 && authored !== void 0) return {
|
|
57
|
-
ok: false,
|
|
58
|
-
problems: [BOTH_FORMS]
|
|
59
|
-
};
|
|
60
|
-
const views = (authored ?? []).map((view, index) => ({
|
|
61
|
-
id: view.id,
|
|
62
|
-
audience: view.audience,
|
|
63
|
-
path: view.path,
|
|
64
|
-
collections: view.collections,
|
|
65
|
-
where: `views[${index}]`
|
|
66
|
-
}));
|
|
67
|
-
if (legacy === void 0) return {
|
|
68
|
-
ok: true,
|
|
69
|
-
views
|
|
70
|
-
};
|
|
71
|
-
if (views.some((view) => view.id === "public")) return {
|
|
72
|
-
ok: false,
|
|
73
|
-
problems: [`views declares id '${PUBLIC_VIEW_ID}', which is reserved for the older \`public.view\` spelling. ${BOTH_FORMS}`]
|
|
74
|
-
};
|
|
75
|
-
return {
|
|
76
|
-
ok: true,
|
|
77
|
-
views: [...views, {
|
|
78
|
-
id: PUBLIC_VIEW_ID,
|
|
79
|
-
audience: "public",
|
|
80
|
-
path: legacy.path,
|
|
81
|
-
collections: legacy.collections,
|
|
82
|
-
where: "public.view"
|
|
83
|
-
}]
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
/** Whether one id may be used, and what to say when it may not. */
|
|
87
|
-
function viewIdProblems(view) {
|
|
88
|
-
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.`];
|
|
89
|
-
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.`];
|
|
90
|
-
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.`];
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
/** ONE public page per app, and the reason is the wire rather than taste.
|
|
94
|
-
*
|
|
95
|
-
* The public runtime reads a single `config/view` document and a single
|
|
96
|
-
* `config/public.view` declaration beside it. A second `audience: "public"`
|
|
97
|
-
* entry would pass every other check and then be published nowhere — and
|
|
98
|
-
* which of the two became the live page would depend on declaration order,
|
|
99
|
-
* silently. The member tiers have no such limit: `id` is their address, and
|
|
100
|
-
* each one gets its own document.
|
|
101
|
-
*
|
|
102
|
-
* The refusal is here rather than "one day we will support it" precisely
|
|
103
|
-
* because the failure is invisible: nothing errors, and the author sees a
|
|
104
|
-
* successful publish of a page nobody is served. */
|
|
105
|
-
function singlePublicProblems(views) {
|
|
106
|
-
const [first, ...rest] = views.filter((view) => view.audience === "public");
|
|
107
|
-
if (first === void 0) return [];
|
|
108
|
-
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.`);
|
|
109
|
-
}
|
|
110
|
-
/** The one shape everything downstream reads.
|
|
111
|
-
*
|
|
112
|
-
* Every caller — the publish gate, the projection, the host that writes the
|
|
113
|
-
* documents — goes through this, so "which declaration was used" is decided
|
|
114
|
-
* exactly once. */
|
|
115
|
-
function normalizeViews(app) {
|
|
116
|
-
const declared = declaredViews(app);
|
|
117
|
-
if (!declared.ok) return declared;
|
|
118
|
-
const problems = [...singlePublicProblems(declared.views)];
|
|
119
|
-
const seen = /* @__PURE__ */ new Map();
|
|
120
|
-
for (const view of declared.views) {
|
|
121
|
-
problems.push(...viewIdProblems(view));
|
|
122
|
-
const first = seen.get(view.id);
|
|
123
|
-
if (first === void 0) {
|
|
124
|
-
seen.set(view.id, view.where);
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
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.`);
|
|
128
|
-
}
|
|
129
|
-
return problems.length > 0 ? {
|
|
130
|
-
ok: false,
|
|
131
|
-
problems
|
|
132
|
-
} : {
|
|
133
|
-
ok: true,
|
|
134
|
-
views: declared.views
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
/** How a participant reaches `cid`, or null if they cannot.
|
|
138
|
-
*
|
|
139
|
-
* Mirrors the rules' read branches for someone holding no role:
|
|
140
|
-
* `partRead` (the whole collection) and `ownRow` (their own record, found
|
|
141
|
-
* by the submit declaration's `emailField` or by a uid-derived id).
|
|
142
|
-
*
|
|
143
|
-
* `participantRead` is a PARAMETER rather than read off the manifest, and
|
|
144
|
-
* that is the whole point of the signature. Publish does not promote the
|
|
145
|
-
* manifest's value: `projectPublish` overwrites `participantRead` with what
|
|
146
|
-
* the STAGED schemas carry, so a cid added since the last deploy is in the
|
|
147
|
-
* manifest and not in the rules. Deriving the scope from the manifest would
|
|
148
|
-
* publish `scope: "all"` for a collection the promoted rules then deny —
|
|
149
|
-
* and removing one gives the mirror-image false refusal. The caller passes
|
|
150
|
-
* the set that will actually be in force. */
|
|
151
|
-
function participantScope(app, cid, participantRead) {
|
|
152
|
-
if (participantRead.includes(cid)) return {
|
|
153
|
-
cid,
|
|
154
|
-
scope: "all"
|
|
155
|
-
};
|
|
156
|
-
const submit = app.public?.submit?.[cid];
|
|
157
|
-
if (submit?.emailField !== void 0) return {
|
|
158
|
-
cid,
|
|
159
|
-
scope: "own",
|
|
160
|
-
emailField: submit.emailField
|
|
161
|
-
};
|
|
162
|
-
if (submit?.idFrom === "auth.uid") return {
|
|
163
|
-
cid,
|
|
164
|
-
scope: "own",
|
|
165
|
-
ownDocId: "auth.uid"
|
|
166
|
-
};
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
/** The document ids one tier uses. `live:` and `staged:` are the only two
|
|
170
|
-
* prefixes, so a single `match` covers the projection and every view. */
|
|
171
|
-
var viewDocId = (stage, viewId) => `${stage}:${viewId}`;
|
|
172
|
-
var VIEW_CONFIG_ID = "config";
|
|
173
|
-
/** The role a member holds on one collection, by the rules' own resolution:
|
|
174
|
-
* the per-collection entry, else the `*` fallback, else none. */
|
|
175
|
-
function roleOn(app, address, cid) {
|
|
176
|
-
const held = app.members[address];
|
|
177
|
-
if (held === void 0) return void 0;
|
|
178
|
-
return held[cid] ?? held["*"];
|
|
179
|
-
}
|
|
180
|
-
/** The addresses holding one of `roles` on `cid`.
|
|
181
|
-
*
|
|
182
|
-
* Sorted, for the same reason `memberEmails` is: a second publish of an
|
|
183
|
-
* unchanged declaration must produce an unchanged document. */
|
|
184
|
-
function holdersOf(app, cid, roles) {
|
|
185
|
-
return Object.keys(app.members).filter((address) => roles.includes(roleOn(app, address, cid) ?? "")).sort();
|
|
186
|
-
}
|
|
187
|
-
/** Who may write every row of `cid`, and who may write only their own.
|
|
188
|
-
*
|
|
189
|
-
* WHY ADDRESSES ARE PUBLISHED AT ALL. One `member/config` document is read by
|
|
190
|
-
* everyone the tier admits, and the tier only establishes that somebody holds
|
|
191
|
-
* SOME role SOMEWHERE — so a `viewer`, or a stylist scoped to another
|
|
192
|
-
* collection, reads the same entry as the front desk. Without these lists the
|
|
193
|
-
* page would draw approve and reassign for all of them and the rules would
|
|
194
|
-
* refuse when pressed, which is the declaration/enforcement mismatch this
|
|
195
|
-
* whole mechanism exists to prevent.
|
|
196
|
-
*
|
|
197
|
-
* It cannot be answered per principal instead: the document is written once
|
|
198
|
-
* at publish and read by many, and the reader cannot look their own role up —
|
|
199
|
-
* `apps/{aid}` is `readerOf(a, '*')`, and a stylist carrying only
|
|
200
|
-
* `{bookings: "editor"}` holds no `*` role. So the ROSTER'S ANSWER travels
|
|
201
|
-
* with the declaration and the page compares its own address to it.
|
|
202
|
-
*
|
|
203
|
-
* The cost is that staff addresses are visible to staff. That is already true
|
|
204
|
-
* of the approval mail they send each other, and participants read the
|
|
205
|
-
* `roster` tier, which never carries these.
|
|
206
|
-
*
|
|
207
|
-
* A SNAPSHOT, like everything else published: a member added since the last
|
|
208
|
-
* publish is absent until the next one. The rules are the authority either
|
|
209
|
-
* way — this only decides which buttons are drawn. */
|
|
210
|
-
function writersOf(app, cid) {
|
|
211
|
-
return holdersOf(app, cid, ["owner", "editor"]);
|
|
212
|
-
}
|
|
213
|
-
/** The transition half: which table applies, and the field it moves.
|
|
214
|
-
*
|
|
215
|
-
* Both halves or neither. A status field with no table would offer every
|
|
216
|
-
* value; a table with no field has nothing to write it to. */
|
|
217
|
-
function transitionPart(app, audience, cid) {
|
|
218
|
-
const config = app.collections?.[cid];
|
|
219
|
-
const transitions = audience === "member" ? config?.transitions : app.public?.submit?.[cid]?.selfTransitions;
|
|
220
|
-
if (config?.statusField === void 0 || transitions === void 0) return {};
|
|
221
|
-
const part = {
|
|
222
|
-
statusField: config.statusField,
|
|
223
|
-
transitions
|
|
224
|
-
};
|
|
225
|
-
if (audience === "member" && config.mail !== void 0) part.mail = config.mail;
|
|
226
|
-
return part;
|
|
227
|
-
}
|
|
228
|
-
/** The assignment half. `member` only — see {@link writersOf}.
|
|
229
|
-
*
|
|
230
|
-
* `rowWriters` rides here rather than beside `writers`, because the
|
|
231
|
-
* `assignee` role grants nothing at all without a field to compare against
|
|
232
|
-
* (`isAssigned` in the rules requires one, and publish refuses the pair). */
|
|
233
|
-
function assignPart(app, audience, cid) {
|
|
234
|
-
const assigneeField = app.collections?.[cid]?.assigneeField;
|
|
235
|
-
if (audience !== "member" || assigneeField === void 0) return {};
|
|
236
|
-
return {
|
|
237
|
-
assigneeField,
|
|
238
|
-
rowWriters: holdersOf(app, cid, ["assignee"])
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
/** What `audience` may change about `cid`, or null when the answer is nothing.
|
|
242
|
-
*
|
|
243
|
-
* The two audiences differ in WHICH transition table applies, in whether
|
|
244
|
-
* assignment exists at all, and in whether the roster's answer travels with
|
|
245
|
-
* it; they agree that the status field is the collection's, since the rules
|
|
246
|
-
* read one field either way. */
|
|
247
|
-
function writeFor(app, audience, cid) {
|
|
248
|
-
const write = {
|
|
249
|
-
cid,
|
|
250
|
-
...transitionPart(app, audience, cid),
|
|
251
|
-
...assignPart(app, audience, cid)
|
|
252
|
-
};
|
|
253
|
-
if (Object.keys(write).length === 1) return null;
|
|
254
|
-
if (audience === "member") write.writers = writersOf(app, cid);
|
|
255
|
-
return write;
|
|
256
|
-
}
|
|
257
|
-
//#endregion
|
|
258
|
-
//#region src/collection/server/publishManifest.ts
|
|
259
|
-
/** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
|
|
260
|
-
* that `sharedCollectionKey` applies. Stated once so a path built later
|
|
261
|
-
* cannot be a way around it. */
|
|
262
|
-
var NameZ = z.string().refine(isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
|
|
263
|
-
/** An address on the roster. Not validated as an email beyond "has an @":
|
|
264
|
-
* the rules compare it to `request.auth.token.email` verbatim, so any
|
|
265
|
-
* narrowing here would refuse addresses Firebase itself accepts. */
|
|
266
|
-
var EmailZ = z.string().trim().min(3).includes("@");
|
|
267
|
-
/** The roles the deployed rules understand.
|
|
268
|
-
*
|
|
269
|
-
* Two of them are row-scoped, in opposite directions, and the pair is what
|
|
270
|
-
* the four-way split could not express:
|
|
271
|
-
*
|
|
272
|
-
* `participant` — the layer that is NAMED but reads only its OWN rows (the
|
|
273
|
-
* rows it submitted). See `readerOf` vs `listedIn`.
|
|
274
|
-
*
|
|
275
|
-
* `assignee` — reads EVERY row and writes only the rows ASSIGNED to it. The
|
|
276
|
-
* stylist who approves their own bookings and not a colleague's; the marker
|
|
277
|
-
* who grades their own students. Which rows are theirs is
|
|
278
|
-
* `collections[cid].assigneeField`, a field on the record holding the
|
|
279
|
-
* member's address. Reads are deliberately unscoped: a stylist needs the
|
|
280
|
-
* whole day's schedule, and scoping the read makes the app unusable.
|
|
281
|
-
*
|
|
282
|
-
* The names are permanent. The deployed rules compare these strings directly
|
|
283
|
-
* and they are written into `app.json` files people commit, so a rename is a
|
|
284
|
-
* migration over published apps rather than an edit. */
|
|
285
|
-
var APP_ROLES = [
|
|
286
|
-
"owner",
|
|
287
|
-
"editor",
|
|
288
|
-
"viewer",
|
|
289
|
-
"participant",
|
|
290
|
-
"assignee"
|
|
291
|
-
];
|
|
292
|
-
var RoleZ = z.enum(APP_ROLES);
|
|
293
|
-
/** `{ email: { "*" | cid: role } }`. The `"*"` key is the app-wide role; a
|
|
294
|
-
* member may hold per-collection roles only (the stylist who is editor of
|
|
295
|
-
* bookings and viewer of everything else). */
|
|
296
|
-
var MembersZ = z.record(EmailZ, z.record(z.union([z.literal("*"), NameZ]), RoleZ));
|
|
297
|
-
/** The declarative mail queue, as the rules re-derive it: a transition of the
|
|
298
|
-
* status field, a recipient read off the RECORD, and a fixed template. */
|
|
299
|
-
var MailZ = z.object({
|
|
300
|
-
toField: z.string().trim().min(1),
|
|
301
|
-
on: z.record(z.string().trim().min(1), z.object({
|
|
302
|
-
from: z.array(z.string().trim().min(1)).min(1),
|
|
303
|
-
to: z.string().trim().min(1)
|
|
304
|
-
}).strict()),
|
|
305
|
-
dataFields: z.array(z.string().trim().min(1)).optional()
|
|
306
|
-
}).strict();
|
|
307
|
-
/** What the rules read out of `collections[cid]`. NOT the schema — the schema
|
|
308
|
-
* is published beside it, untouched, for clients to render from. */
|
|
309
|
-
var CollectionConfigZ = z.object({
|
|
310
|
-
statusField: z.string().trim().min(1).optional(),
|
|
311
|
-
/** `{ initial: [...], <status>: [<status>...] }`. Binds writers too, and
|
|
312
|
-
* binds `create` — that is the point of publishing it. */
|
|
313
|
-
transitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
314
|
-
immutable: z.boolean().optional(),
|
|
315
|
-
submitOnly: z.boolean().optional(),
|
|
316
|
-
/** The field naming the member a row belongs to, for the `assignee` role.
|
|
317
|
-
*
|
|
318
|
-
* Holds an ADDRESS, because that is the only thing the rules can compare
|
|
319
|
-
* a member against (`request.auth.token.email`). A `ref` to a staff
|
|
320
|
-
* collection stores the target's primary-key slug, not an address, so it
|
|
321
|
-
* cannot be this field — declare a plain field beside the ref and let the
|
|
322
|
-
* ref stay the thing the UI renders. The alternative, having the rules
|
|
323
|
-
* `get()` the staff record to read an address off it, costs a document
|
|
324
|
-
* access on every write and puts a second document between an
|
|
325
|
-
* authorization decision and its answer.
|
|
326
|
-
*
|
|
327
|
-
* Only meaningful with a member holding `assignee` on this cid; a
|
|
328
|
-
* declaration with the role and no field is refused (`assigneeProblems`),
|
|
329
|
-
* because that member would silently hold nothing. */
|
|
330
|
-
assigneeField: z.string().trim().min(1).optional(),
|
|
331
|
-
/** This collection is the public projection of `mirrorOf` — the other
|
|
332
|
-
* half of `public.submit[...].mirror`, declared here because the rules
|
|
333
|
-
* read it when the PROJECTION is written rather than when the record is.
|
|
334
|
-
*
|
|
335
|
-
* What it buys: `state` may be written by anybody, and only to the value
|
|
336
|
-
* the authority actually says, so a visitor who was refused a slot can
|
|
337
|
-
* repair the stale row that offered it to them. */
|
|
338
|
-
mirrorOf: NameZ.optional(),
|
|
339
|
-
peerVisibility: z.enum(["public", "hidden"]).optional(),
|
|
340
|
-
revealGated: z.boolean().optional(),
|
|
341
|
-
gatedFrom: NameZ.optional(),
|
|
342
|
-
revealBy: z.string().trim().min(1).optional(),
|
|
343
|
-
mail: MailZ.optional(),
|
|
344
|
-
/** Which fields an aggregate groups by. Declared here rather than in the
|
|
345
|
-
* schema for the same reason as everything else in this file — the schema
|
|
346
|
-
* has no `aggregate` key yet — and it is here at all because the
|
|
347
|
-
* invariant that guards it ("every aggregation key is a CHECKED field")
|
|
348
|
-
* is about `public.submit`, which is an app-level declaration. Published
|
|
349
|
-
* as-is; the rules never read it. */
|
|
350
|
-
aggregate: z.object({ by: z.array(z.string().trim().min(1)).min(1) }).strict().optional()
|
|
351
|
-
}).strict();
|
|
352
|
-
/** An authored submit window. ISO strings, because `app.json` is JSON and a
|
|
353
|
-
* Firestore `Timestamp` has no JSON form. Publish lowers it to epoch millis —
|
|
354
|
-
* the rules do not coerce strings, so an ISO string reaching Firestore is a
|
|
355
|
-
* type error that fails CLOSED (`inWindow` refuses every submission and the
|
|
356
|
-
* author sees "nobody can submit", not an error). */
|
|
357
|
-
/** A window bound that lives on ANOTHER record, read at write time.
|
|
358
|
-
*
|
|
359
|
-
* `window.from` is one absolute instant for the whole collection, which is
|
|
360
|
-
* enough for a survey and useless for anything recurring: "each class opens
|
|
361
|
-
* three days before it starts, at 08:00" is a bound PER RECORD. So the bound
|
|
362
|
-
* is not computed in the rules — they have no usable date arithmetic and
|
|
363
|
-
* `request.time` is UTC, which is the wrong answer for "08:00" — it is
|
|
364
|
-
* computed by whoever schedules the class, stored on the class record as
|
|
365
|
-
* epoch millis, and merely COMPARED here.
|
|
366
|
-
*
|
|
367
|
-
* `ref` is the field on the record being written that names the target
|
|
368
|
-
* (`classId`); `collection` is the cid the target lives in, fixed in the
|
|
369
|
-
* declaration so that a path is never built out of a value a submitter wrote;
|
|
370
|
-
* `field` is the epoch-millis field on the target.
|
|
371
|
-
*
|
|
372
|
-
* Not spelled `in` — that is an operator in the rules language, and
|
|
373
|
-
* `w.fromField.in` does not parse there. */
|
|
374
|
-
var WindowRefZ = z.object({
|
|
375
|
-
ref: z.string().trim().min(1),
|
|
376
|
-
collection: NameZ,
|
|
377
|
-
field: z.string().trim().min(1)
|
|
378
|
-
}).strict();
|
|
379
|
-
/** The closing bound's per-record twin, and it ships WITH `fromField` rather
|
|
380
|
-
* than as a symmetric extra: a booking desk that opens per slot and never
|
|
381
|
-
* closes is not a booking desk. Same shape, opposite comparison — and
|
|
382
|
-
* EXCLUSIVE where `fromField` is inclusive, so one slot's closing instant and
|
|
383
|
-
* the next one's opening instant may be the same number. */
|
|
384
|
-
var WindowZ = z.object({
|
|
385
|
-
from: z.iso.datetime().optional(),
|
|
386
|
-
until: z.iso.datetime().optional(),
|
|
387
|
-
fromField: WindowRefZ.optional(),
|
|
388
|
-
untilField: WindowRefZ.optional()
|
|
389
|
-
}).strict();
|
|
390
|
-
/** Which record a `field` document id must name, and what state it must be in.
|
|
391
|
-
*
|
|
392
|
-
* `idFrom: "field"` alone only stops the same string being written twice —
|
|
393
|
-
* nothing stops a client bypassing the page and inventing a slot, so the
|
|
394
|
-
* rules check the referenced record themselves. `exists()` is a FLOOR: a
|
|
395
|
-
* cancelled slot and a slot nobody may book any more exist too, which is what
|
|
396
|
-
* `where` is for.
|
|
397
|
-
*
|
|
398
|
-
* Always the object form, never a bare collection name. Two shapes for one
|
|
399
|
-
* key is the kind of thing a generator gets right once and wrong afterwards,
|
|
400
|
-
* and the rules read `s.idIn.collection` either way. */
|
|
401
|
-
var IdInZ = z.object({
|
|
402
|
-
collection: NameZ,
|
|
403
|
-
where: z.object({
|
|
404
|
-
field: z.string().trim().min(1),
|
|
405
|
-
equals: z.union([
|
|
406
|
-
z.string(),
|
|
407
|
-
z.number(),
|
|
408
|
-
z.boolean()
|
|
409
|
-
])
|
|
410
|
-
}).strict().optional()
|
|
411
|
-
}).strict();
|
|
412
|
-
var ValidateZ = z.object({
|
|
413
|
-
required: z.array(z.string().trim().min(1)).optional(),
|
|
414
|
-
/** Capped at two by the rules themselves: rules have no iteration, so
|
|
415
|
-
* `keyFieldsOk` is unrolled. A third would be accepted here and silently
|
|
416
|
-
* unchecked there. */
|
|
417
|
-
keyFields: z.array(z.object({
|
|
418
|
-
field: z.string().trim().min(1),
|
|
419
|
-
values: z.array(z.union([
|
|
420
|
-
z.string(),
|
|
421
|
-
z.number(),
|
|
422
|
-
z.boolean()
|
|
423
|
-
])).min(1)
|
|
424
|
-
}).strict()).optional()
|
|
425
|
-
}).strict();
|
|
426
|
-
var SubmitZ = z.object({
|
|
427
|
-
auth: z.enum([
|
|
428
|
-
"none",
|
|
429
|
-
"anonymous",
|
|
430
|
-
"verifiedEmail"
|
|
431
|
-
]),
|
|
432
|
-
emailField: z.string().trim().min(1).optional(),
|
|
433
|
-
createFields: z.array(z.string().trim().min(1)).min(1),
|
|
434
|
-
initialStatus: z.string().trim().min(1).optional(),
|
|
435
|
-
/** `field` is the mode that makes a CONTESTED resource exclusive: the
|
|
436
|
-
* booking's document id IS the slot's id, so the second person to want
|
|
437
|
-
* that slot is writing a document that already exists — an update, which
|
|
438
|
-
* the public submission path never allows. Firestore decides that
|
|
439
|
-
* atomically, so unlike a countable capacity (see `stampField`) this is
|
|
440
|
-
* first-come ENFORCED rather than first-come read off a rank. */
|
|
441
|
-
idFrom: z.enum([
|
|
442
|
-
"auto",
|
|
443
|
-
"auth.uid",
|
|
444
|
-
"auth.uid+field",
|
|
445
|
-
"field"
|
|
446
|
-
]).optional(),
|
|
447
|
-
idField: z.string().trim().min(1).optional(),
|
|
448
|
-
/** Required by `idFrom: "field"` — see {@link IdInZ}. */
|
|
449
|
-
idIn: IdInZ.optional(),
|
|
450
|
-
/** The collection holding this record's PUBLIC PROJECTION, one row per
|
|
451
|
-
* contested thing, sharing its document id.
|
|
452
|
-
*
|
|
453
|
-
* A booking carries a name, an address and a phone number, and Firestore
|
|
454
|
-
* rules cannot hide a field, so the public page must not read bookings at
|
|
455
|
-
* all. It reads the projection instead, whose `state` is a copy of "does
|
|
456
|
-
* a booking with this id exist" — and the rules accept the two writes
|
|
457
|
-
* only as one batch, in both directions, so the copy cannot drift into
|
|
458
|
-
* advertising a slot that is gone. */
|
|
459
|
-
mirror: NameZ.optional(),
|
|
460
|
-
validate: ValidateZ.optional(),
|
|
461
|
-
window: WindowZ.optional(),
|
|
462
|
-
/** A field the rules PIN to the server clock on create: the record must
|
|
463
|
-
* carry `request.time` in it, and may never change it afterwards.
|
|
464
|
-
*
|
|
465
|
-
* What it buys is an order nobody can jump. A first-come app takes its
|
|
466
|
-
* capacity from rank rather than from a count — the rules cannot count
|
|
467
|
-
* documents, so "the first 8" can only ever be a reading of the rows —
|
|
468
|
-
* and a rank is only as honest as the timestamp it sorts by. `idFrom`
|
|
469
|
-
* stops a person holding two places; nothing else stops them writing
|
|
470
|
-
* yesterday's date into the field that decides who got there first.
|
|
471
|
-
*
|
|
472
|
-
* Binds EVERY create, the writer branch included, so a staff-entered row
|
|
473
|
-
* cannot be back-dated into the queue either. */
|
|
474
|
-
stampField: z.string().trim().min(1).optional(),
|
|
475
|
-
/** Per CURRENT STATUS, never a flat list: a flat list lets a customer move
|
|
476
|
-
* an approved booking's `startAt` without anyone re-approving it. */
|
|
477
|
-
selfUpdate: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
478
|
-
selfTransitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
479
|
-
finalize: z.boolean().optional(),
|
|
480
|
-
audience: z.literal("participant").optional(),
|
|
481
|
-
gateOn: z.object({
|
|
482
|
-
phase: z.string().trim().min(1),
|
|
483
|
-
match: z.string().trim().min(1)
|
|
484
|
-
}).strict().optional()
|
|
485
|
-
}).strict();
|
|
486
|
-
var PublicZ = z.object({
|
|
487
|
-
/** The master switch. Anonymous submission (`auth: "none"`) needs it as
|
|
488
|
-
* well as its own declaration. */
|
|
489
|
-
enabled: z.boolean().optional(),
|
|
490
|
-
read: z.array(NameZ).optional(),
|
|
491
|
-
/** The page the public sees, instead of the generated form.
|
|
492
|
-
*
|
|
493
|
-
* A form is enough to ANSWER something and not enough to CHOOSE from
|
|
494
|
-
* what is available — a stylist-by-hour grid is not the far end of a
|
|
495
|
-
* table. So the app may name one HTML file, which the host publishes to
|
|
496
|
-
* `config/view` and the public page renders in a sandboxed iframe.
|
|
497
|
-
*
|
|
498
|
-
* `submit` stays declared alongside: the view sends an INTENT, and the
|
|
499
|
-
* page it is embedded in performs the write against these rules.
|
|
500
|
-
*
|
|
501
|
-
* `collections` is declared rather than inferred from `read`. Inferring
|
|
502
|
-
* it produces the worst failure this feature has — the view renders, the
|
|
503
|
-
* data it wanted was never sent, and it draws an empty grid with no error
|
|
504
|
-
* anywhere. */
|
|
505
|
-
view: z.object({
|
|
506
|
-
path: z.string().trim().min(1),
|
|
507
|
-
collections: z.array(NameZ).min(1)
|
|
508
|
-
}).strict().optional(),
|
|
509
|
-
submit: z.record(NameZ, SubmitZ).optional()
|
|
510
|
-
}).strict();
|
|
511
|
-
/** One page the app shows, and who it is for.
|
|
512
|
-
*
|
|
513
|
-
* Generalised from `public.view` (which is still accepted, and normalizes
|
|
514
|
-
* into this — see `appViews.ts`). The audience is what decides which document
|
|
515
|
-
* the HTML is published to, and therefore who may read it: a rule cannot hide
|
|
516
|
-
* a field, so "the front desk sees this" is a place, not a filter.
|
|
517
|
-
*
|
|
518
|
-
* `id` is not decoration. It becomes the document id the page is published
|
|
519
|
-
* at, which is what lets one audience have more than one page — the front
|
|
520
|
-
* desk and the stock room — and what lets a withdrawn view be found and
|
|
521
|
-
* deleted. Its grammar is enforced at the gate, not here, so the refusal can
|
|
522
|
-
* say what the value is used for.
|
|
523
|
-
*
|
|
524
|
-
* `collections` is declared rather than inferred, for the reason `public.view`
|
|
525
|
-
* gives: an inferred list renders a perfect page with no data in it, and
|
|
526
|
-
* nothing anywhere says why. */
|
|
527
|
-
var ViewZ = z.object({
|
|
528
|
-
id: z.string().trim().min(1),
|
|
529
|
-
audience: z.enum(VIEW_AUDIENCES),
|
|
530
|
-
path: z.string().trim().min(1),
|
|
531
|
-
collections: z.array(NameZ).min(1)
|
|
532
|
-
}).strict();
|
|
533
|
-
/** The URL name an app is handed out under: `https://<host>/{slug}`.
|
|
534
|
-
*
|
|
535
|
-
* A SEPARATE name from the `aid`, and that separation is the point (design
|
|
536
|
-
* D2b). `apps/{aid}` is a shelf every user of the deployment shares and the
|
|
537
|
-
* rules' `allow create` asks only that you name yourself owner — so a
|
|
538
|
-
* memorable aid is first-come-first-served, cannot be checked for
|
|
539
|
-
* availability, and frees up again when an app is deleted. The aid is
|
|
540
|
-
* therefore a UUID, and the thing people can fight over is moved to the name
|
|
541
|
-
* that costs nothing to change.
|
|
542
|
-
*
|
|
543
|
-
* Declared here rather than kept beside `app.json` because a RESERVATION has
|
|
544
|
-
* to travel with the repository: `appSlugs/{slug}` is unreadable until the app
|
|
545
|
-
* is published (`allow read: if resource.data.published == true`), so nothing
|
|
546
|
-
* can recover which slug an app holds by asking Firestore. A second file to
|
|
547
|
-
* keep in step with the declaration is the alternative, and it is the kind of
|
|
548
|
-
* pair that goes out of step silently.
|
|
549
|
-
*
|
|
550
|
-
* The shape is stricter than `NameZ` on purpose: it is BOTH a URL path
|
|
551
|
-
* segment people read aloud and a Firestore document id. Lowercase
|
|
552
|
-
* alphanumerics separated by single hyphens covers both without a case rule
|
|
553
|
-
* that would make two slugs collide in one place and not the other.
|
|
554
|
-
*
|
|
555
|
-
* Which slug an app ended up with is the HOST's business — a wanted slug can
|
|
556
|
-
* be taken, and the host writes the one it reserved back here. Nothing in this
|
|
557
|
-
* package reads the key; it is declared so that writing it back does not make
|
|
558
|
-
* the file unparseable. */
|
|
559
|
-
var SLUG_SHAPE = "must be lowercase letters, digits and single hyphens, and must not start or end with one (e.g. sakura-hair)";
|
|
560
|
-
var SlugZ = z.string().trim().max(64).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, SLUG_SHAPE).refine((slug) => !slug.includes("--"), SLUG_SHAPE);
|
|
561
|
-
/** The whole authored declaration.
|
|
562
|
-
*
|
|
563
|
-
* `owner` is accepted but is NOT the published value — publish stamps the
|
|
564
|
-
* publisher's uid (or carries the existing one forward, which is what the
|
|
565
|
-
* rules require on update) and refuses a declaration that disagrees. It is
|
|
566
|
-
* accepted rather than banned because the sample app.json in the design note
|
|
567
|
-
* shows it, and a hard refusal on a key the samples contain would be a worse
|
|
568
|
-
* first experience than a message naming the mismatch. */
|
|
569
|
-
var AuthoredAppZ = z.object({
|
|
570
|
-
aid: NameZ,
|
|
571
|
-
name: z.string().trim().min(1).optional(),
|
|
572
|
-
/** The wanted (or reserved) URL name — see {@link SlugZ}. */
|
|
573
|
-
slug: SlugZ.optional(),
|
|
574
|
-
/** Per-worktree app id (design D6, implementation order 7). Accepted so a
|
|
575
|
-
* repository already carrying it parses; nothing reads it yet. */
|
|
576
|
-
aidEnv: z.string().trim().min(1).optional(),
|
|
577
|
-
owner: z.string().trim().min(1).optional(),
|
|
578
|
-
members: MembersZ,
|
|
579
|
-
collections: z.record(NameZ, CollectionConfigZ).optional(),
|
|
580
|
-
participantRead: z.array(NameZ).optional(),
|
|
581
|
-
public: PublicZ.optional(),
|
|
582
|
-
/** The app's pages, per audience. See {@link ViewZ}; `public.view` is the
|
|
583
|
-
* older spelling of the `public` one and normalizes into this list. */
|
|
584
|
-
views: z.array(ViewZ).optional()
|
|
585
|
-
}).strict();
|
|
586
|
-
/** Parse the authored declaration out of `app.json`'s text.
|
|
587
|
-
*
|
|
588
|
-
* Returns a LIST of problems rather than throwing, for the same reason
|
|
589
|
-
* `loadAppManifest` returns a failure: the caller is a gate whose entire job
|
|
590
|
-
* is to hand the author something to act on. Every problem is reported at
|
|
591
|
-
* once — publish is a manual step, and a parser that stops at the first key
|
|
592
|
-
* makes it N round trips. */
|
|
593
|
-
function parseAuthoredApp(raw) {
|
|
594
|
-
const manifest = parseAppManifest(raw);
|
|
595
|
-
if (!manifest.ok) return {
|
|
596
|
-
ok: false,
|
|
597
|
-
problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
|
|
598
|
-
};
|
|
599
|
-
const parsed = AuthoredAppZ.safeParse(JSON.parse(raw));
|
|
600
|
-
if (!parsed.success) return {
|
|
601
|
-
ok: false,
|
|
602
|
-
problems: authoredProblems(parsed.error)
|
|
603
|
-
};
|
|
604
|
-
return {
|
|
605
|
-
ok: true,
|
|
606
|
-
app: parsed.data
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
/** zod issues as one actionable line each: `public.submit.responses.auth: …`. */
|
|
610
|
-
function authoredProblems(error) {
|
|
611
|
-
return error.issues.map((issue) => {
|
|
612
|
-
return `${issue.path.length > 0 ? issue.path.join(".") : "app.json"}: ${issue.message}`;
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
//#endregion
|
|
616
|
-
//#region src/collection/server/publishProject.ts
|
|
617
|
-
/** The document id under `apps/{aid}/config`. One document, named, rather than
|
|
618
|
-
* a spread of them: a second public document is a second thing to keep in
|
|
619
|
-
* step, and nothing yet needs one. */
|
|
620
|
-
var PUBLIC_CONFIG_DOC = "public";
|
|
621
|
-
/** Drop keys whose value is `undefined`. Firestore rejects an undefined field
|
|
622
|
-
* value outright, and `"k" in c` — which every optional key in the rules is
|
|
623
|
-
* read through — must mean "the author declared it". */
|
|
624
|
-
function compact(entries) {
|
|
625
|
-
return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== void 0));
|
|
626
|
-
}
|
|
627
|
-
/** ISO → epoch millis, the one conversion the rules cannot do for themselves.
|
|
628
|
-
* The caller has already refused an unparseable string (the authored parser
|
|
629
|
-
* requires `z.iso.datetime()`), so a NaN here would be a programming error;
|
|
630
|
-
* it is still checked, because a NaN written to Firestore fails closed in the
|
|
631
|
-
* same silent way an ISO string does. */
|
|
632
|
-
function windowMillis(window) {
|
|
633
|
-
if (!window) return void 0;
|
|
634
|
-
const out = {};
|
|
635
|
-
if (window.from !== void 0) out.fromMs = Date.parse(window.from);
|
|
636
|
-
if (window.until !== void 0) out.untilMs = Date.parse(window.until);
|
|
637
|
-
if (Object.values(out).some((value) => !Number.isFinite(value))) throw new Error(`publish: window bound is not a parseable timestamp (${JSON.stringify(window)})`);
|
|
638
|
-
const projected = {
|
|
639
|
-
...out,
|
|
640
|
-
...window.fromField === void 0 ? {} : { fromField: window.fromField },
|
|
641
|
-
...window.untilField === void 0 ? {} : { untilField: window.untilField }
|
|
642
|
-
};
|
|
643
|
-
return Object.keys(projected).length > 0 ? projected : void 0;
|
|
644
|
-
}
|
|
645
|
-
/** One `public.submit[cid]`, with its window lowered. Everything else passes
|
|
646
|
-
* through: the rules read these keys by the names the author wrote. */
|
|
647
|
-
function projectSubmit(submit) {
|
|
648
|
-
const { window, ...rest } = submit;
|
|
649
|
-
return compact({
|
|
650
|
-
...rest,
|
|
651
|
-
window: windowMillis(window)
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
/** The roster's addresses as a set, in a stable order.
|
|
655
|
-
*
|
|
656
|
-
* Sorted so two publishes of the same declaration produce the same document
|
|
657
|
-
* — idempotence is a property this step is tested for, and Firestore compares
|
|
658
|
-
* arrays by ORDER. `membersConsistent()` compares as sets and would accept
|
|
659
|
-
* any order; the test that would notice is the one asserting a second publish
|
|
660
|
-
* changes nothing but `publishedAt`. */
|
|
661
|
-
function memberEmailsOf(members) {
|
|
662
|
-
return Object.keys(members).sort();
|
|
663
|
-
}
|
|
664
|
-
/** The previous document, kept for rollback, with its OWN `previousPublished`
|
|
665
|
-
* stripped.
|
|
666
|
-
*
|
|
667
|
-
* One level, deliberately. Chaining would make every publish carry the entire
|
|
668
|
-
* history of the app inside a single document, which grows without bound and
|
|
669
|
-
* meets Firestore's 1 MiB document limit as a permission-shaped failure at
|
|
670
|
-
* some unpredictable publish. One level answers the question rollback
|
|
671
|
-
* actually asks — "put back what was there before I broke it" — and the
|
|
672
|
-
* further history is in git, which is where the declaration came from. */
|
|
673
|
-
function previousOf(existing) {
|
|
674
|
-
if (!existing) return void 0;
|
|
675
|
-
const { previousPublished: __dropped, ...rest } = existing;
|
|
676
|
-
return rest;
|
|
677
|
-
}
|
|
678
|
-
/** Project the authored declaration into the documents publish writes.
|
|
679
|
-
*
|
|
680
|
-
* `existing` is the app document as it is in Firestore right now, or null on
|
|
681
|
-
* a first publish. Two things need it, both required by the rules:
|
|
682
|
-
* - `owner` must be UNCHANGED on update. Re-stamping the publisher's uid
|
|
683
|
-
* would be refused for any app whose owner ever signed in as a different
|
|
684
|
-
* account, and would silently transfer ownership if it were not.
|
|
685
|
-
* - `previousPublished` is that document, so a rollback has something to
|
|
686
|
-
* put back.
|
|
687
|
-
*
|
|
688
|
-
* Pure: no clock, no filesystem, no Firestore. Everything variable arrives as
|
|
689
|
-
* a parameter, which is what makes the conversion table testable as a table. */
|
|
690
|
-
function projectApp(authored, schemas, stamp, existing) {
|
|
691
|
-
const owner = typeof existing?.owner === "string" ? existing.owner : stamp.uid;
|
|
692
|
-
const submit = Object.fromEntries(Object.entries(authored.public?.submit ?? {}).map(([cid, spec]) => [cid, projectSubmit(spec)]));
|
|
693
|
-
const publicBlock = authored.public ? compact({
|
|
694
|
-
enabled: authored.public.enabled,
|
|
695
|
-
read: authored.public.read,
|
|
696
|
-
submit: Object.keys(submit).length > 0 ? submit : void 0
|
|
697
|
-
}) : void 0;
|
|
698
|
-
const app = compact({
|
|
699
|
-
aid: authored.aid,
|
|
700
|
-
name: authored.name,
|
|
701
|
-
owner,
|
|
702
|
-
members: authored.members,
|
|
703
|
-
memberEmails: memberEmailsOf(authored.members),
|
|
704
|
-
collections: authored.collections,
|
|
705
|
-
participantRead: authored.participantRead,
|
|
706
|
-
public: publicBlock,
|
|
707
|
-
publishedAt: stamp.publishedAt,
|
|
708
|
-
publishedBy: stamp.email,
|
|
709
|
-
publishedCommit: stamp.commit,
|
|
710
|
-
publishedDirty: stamp.dirty === true ? true : void 0,
|
|
711
|
-
previousPublished: previousOf(existing)
|
|
712
|
-
});
|
|
713
|
-
const normalized = normalizeViews(authored);
|
|
714
|
-
if (!normalized.ok) throw new Error(`publish: views declaration is not publishable (${normalized.problems.join(" ")})`);
|
|
715
|
-
const publicView = normalized.views.find((view) => view.audience === "public");
|
|
716
|
-
const config = {
|
|
717
|
-
enabled: authored.public?.enabled === true,
|
|
718
|
-
read: authored.public?.read ?? [],
|
|
719
|
-
submit,
|
|
720
|
-
...publicView === void 0 ? {} : { view: { collections: publicView.collections } },
|
|
721
|
-
publishedAt: stamp.publishedAt
|
|
722
|
-
};
|
|
723
|
-
if (authored.name !== void 0) config.name = authored.name;
|
|
724
|
-
return {
|
|
725
|
-
app,
|
|
726
|
-
schemas: schemas.map(({ cid, schema }) => ({
|
|
727
|
-
cid,
|
|
728
|
-
doc: schemaDoc(schema, stamp)
|
|
729
|
-
})),
|
|
730
|
-
config
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
/** Keys on the app document that publish owns: what is PUBLIC right now, and
|
|
734
|
-
* the rule-facing configuration anonymous access is judged against. Deploy
|
|
735
|
-
* carries them through from the existing document and never authors them. */
|
|
736
|
-
var PUBLISH_OWNED_KEYS = [
|
|
737
|
-
"public",
|
|
738
|
-
"collections",
|
|
739
|
-
"participantRead",
|
|
740
|
-
"publishedAt",
|
|
741
|
-
"publishedBy",
|
|
742
|
-
"publishedCommit",
|
|
743
|
-
"publishedDirty",
|
|
744
|
-
"previousPublished"
|
|
745
|
-
];
|
|
746
|
-
var isPublishOwned = (key) => PUBLISH_OWNED_KEYS.includes(key);
|
|
747
|
-
function projectDeploy(authored, schemas, stamp, existing) {
|
|
748
|
-
const { app } = projectApp(authored, schemas, stamp, existing);
|
|
749
|
-
const deployed = Object.fromEntries(Object.entries(app).filter(([key]) => !isPublishOwned(key)));
|
|
750
|
-
for (const key of PUBLISH_OWNED_KEYS) {
|
|
751
|
-
const live = existing?.[key];
|
|
752
|
-
if (live !== void 0) deployed[key] = live;
|
|
753
|
-
}
|
|
754
|
-
deployed.deployedAt = stamp.publishedAt;
|
|
755
|
-
deployed.deployedBy = stamp.email;
|
|
756
|
-
if (stamp.commit !== void 0) deployed.deployedCommit = stamp.commit;
|
|
757
|
-
return {
|
|
758
|
-
app: deployed,
|
|
759
|
-
staging: schemas.map(({ cid, schema }) => ({
|
|
760
|
-
cid,
|
|
761
|
-
doc: stagedDoc(schema, stamp, authored, cid)
|
|
762
|
-
}))
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
/** One staged schema document, carrying this cid's rule-facing configuration
|
|
766
|
-
* alongside the schema so publish can promote them together. */
|
|
767
|
-
function stagedDoc(schema, stamp, authored, cid) {
|
|
768
|
-
const doc = {
|
|
769
|
-
publishedSchema: schema,
|
|
770
|
-
deployedAt: stamp.publishedAt,
|
|
771
|
-
deployedBy: stamp.email
|
|
772
|
-
};
|
|
773
|
-
const config = authored.collections?.[cid];
|
|
774
|
-
if (config !== void 0) doc.config = config;
|
|
775
|
-
if (authored.participantRead?.includes(cid) === true) doc.participantRead = true;
|
|
776
|
-
if (stamp.commit !== void 0) doc.deployedCommit = stamp.commit;
|
|
777
|
-
return doc;
|
|
778
|
-
}
|
|
779
|
-
/** The rule-facing configuration to promote, read from the STAGED documents
|
|
780
|
-
* rather than from the manifest as it reads right now.
|
|
781
|
-
*
|
|
782
|
-
* Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
|
|
783
|
-
* the promoted schema is A's while the authorization behaviour is B's, a
|
|
784
|
-
* combination nobody exercised through `/staging/{aid}`.
|
|
785
|
-
*
|
|
786
|
-
* (`public` is deliberately NOT part of this: it is not staged, because it is
|
|
787
|
-
* the decision being made AT publish rather than something under test.) */
|
|
788
|
-
function stagedRuleConfig(staged) {
|
|
789
|
-
const entries = [];
|
|
790
|
-
const participantRead = [];
|
|
791
|
-
for (const entry of staged) {
|
|
792
|
-
const { config } = entry.doc;
|
|
793
|
-
if (config !== void 0) entries.push([entry.cid, config]);
|
|
794
|
-
if (entry.doc.participantRead === true) participantRead.push(entry.cid);
|
|
795
|
-
}
|
|
796
|
-
return {
|
|
797
|
-
collections: entries.length > 0 ? Object.fromEntries(entries) : void 0,
|
|
798
|
-
participantRead: participantRead.length > 0 ? participantRead : void 0
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
function projectPublish(authored, staged, stamp, existing) {
|
|
802
|
-
const { app, config } = projectApp(authored, [], stamp, existing);
|
|
803
|
-
const staging = stagedRuleConfig(staged);
|
|
804
|
-
app.collections = staging.collections;
|
|
805
|
-
app.participantRead = staging.participantRead;
|
|
806
|
-
const published = Object.fromEntries(Object.entries(existing ?? app).filter(([key]) => !isPublishOwned(key)));
|
|
807
|
-
for (const key of PUBLISH_OWNED_KEYS) if (key !== "public" && app[key] !== void 0) published[key] = app[key];
|
|
808
|
-
const publicBlock = app.public;
|
|
809
|
-
return {
|
|
810
|
-
app: published,
|
|
811
|
-
config,
|
|
812
|
-
public: isRecord(publicBlock) ? publicBlock : void 0
|
|
813
|
-
};
|
|
814
|
-
}
|
|
815
|
-
/** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
|
|
816
|
-
*
|
|
817
|
-
* The stamp answers "which version is PUBLIC right now, and who made it so",
|
|
818
|
-
* so it is written by the operation that changes the answer — publish — not
|
|
819
|
-
* carried over from the deploy that staged it. */
|
|
820
|
-
function promoteSchema(staged, stamp) {
|
|
821
|
-
const doc = {
|
|
822
|
-
publishedSchema: staged.publishedSchema,
|
|
823
|
-
publishedAt: stamp.publishedAt,
|
|
824
|
-
publishedBy: stamp.email
|
|
825
|
-
};
|
|
826
|
-
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
827
|
-
return doc;
|
|
828
|
-
}
|
|
829
|
-
/** One published schema document. Written key by key rather than through
|
|
830
|
-
* `compact`, so the declared type is the type — an optional commit is the
|
|
831
|
-
* only variable part. */
|
|
832
|
-
function schemaDoc(schema, stamp) {
|
|
833
|
-
const doc = {
|
|
834
|
-
publishedSchema: schema,
|
|
835
|
-
publishedAt: stamp.publishedAt,
|
|
836
|
-
publishedBy: stamp.email
|
|
837
|
-
};
|
|
838
|
-
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
839
|
-
return doc;
|
|
840
|
-
}
|
|
841
|
-
/** The app documents' parent path — the `FirestoreDocs` seam takes a
|
|
842
|
-
* collection path plus a document id, and the app document's id is the aid. */
|
|
843
|
-
var APPS_COLLECTION = "apps";
|
|
844
|
-
/** The collection (schema) documents' parent path — what the PUBLIC page
|
|
845
|
-
* reads, written only by publish (promotion). */
|
|
846
|
-
var appSchemasPath = (aid) => `apps/${aid}/collections`;
|
|
847
|
-
/** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
|
|
848
|
-
*
|
|
849
|
-
* A TOP-LEVEL collection, not a field on the app: the public page resolves a
|
|
850
|
-
* slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
|
|
851
|
-
* has to be claimable atomically (create-if-absent) so two apps cannot hold
|
|
852
|
-
* the same URL.
|
|
853
|
-
*
|
|
854
|
-
* `published` is what makes the reservation invisible until publish. The slug
|
|
855
|
-
* is human-readable, so a readable reservation would let anyone guess the URL
|
|
856
|
-
* and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
|
|
857
|
-
* `allow read: if resource.data.published == true`, which needs no `get()` and
|
|
858
|
-
* so costs nothing against the rules' expression budget. */
|
|
859
|
-
var APP_SLUGS_COLLECTION = "appSlugs";
|
|
860
|
-
var appSlugDoc = (aid, published) => ({
|
|
861
|
-
aid,
|
|
862
|
-
published
|
|
863
|
-
});
|
|
864
|
-
/** The staged schema documents' parent path — what `/staging/{aid}` reads,
|
|
865
|
-
* written by deploy. A separate DOCUMENT rather than a field beside
|
|
866
|
-
* `publishedSchema`, because the rules cannot hide a field: anything inside a
|
|
867
|
-
* document the public page may read is public. */
|
|
868
|
-
var appStagingPath = (aid) => `apps/${aid}/staging`;
|
|
869
|
-
/** The public-config documents' parent path. */
|
|
870
|
-
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
871
|
-
/** Where one audience's pages live. `member` is read by anyone holding a role;
|
|
872
|
-
* `roster` by anyone on the roster, participants included. */
|
|
873
|
-
var appViewTierPath = (aid, tier) => `apps/${aid}/${tier}`;
|
|
874
|
-
/** What one audience may see of one collection.
|
|
875
|
-
*
|
|
876
|
-
* For `member` this is always the whole collection: every read branch a role
|
|
877
|
-
* opens (`readerOf`) is unscoped. Whether THIS member holds the role is not
|
|
878
|
-
* knowable here — one projection is read by every member of the tier — and is
|
|
879
|
-
* settled where it can be, by the entrance trying the read.
|
|
880
|
-
*
|
|
881
|
-
* For `participant` it is the rules' own answer, which is why it can be null:
|
|
882
|
-
* a participant with neither `participantRead` nor an own-row submit path
|
|
883
|
-
* cannot read the collection at all, and a page handed it would fail rather
|
|
884
|
-
* than render less. */
|
|
885
|
-
function scopeFor(authored, audience, cid, participantRead) {
|
|
886
|
-
return audience === "member" ? {
|
|
887
|
-
cid,
|
|
888
|
-
scope: "all"
|
|
889
|
-
} : participantScope(authored, cid, participantRead);
|
|
890
|
-
}
|
|
891
|
-
/** What this audience may CHANGE, per collection it draws.
|
|
892
|
-
*
|
|
893
|
-
* The `collections` config is the PROMOTED one where there is one: at publish
|
|
894
|
-
* the rules run against what deploy staged, so projecting the manifest's
|
|
895
|
-
* would advertise transitions the live rules deny. */
|
|
896
|
-
function tierWrites(authored, audience, cids, promoted) {
|
|
897
|
-
const effective = promoted.collections === void 0 ? authored : {
|
|
898
|
-
...authored,
|
|
899
|
-
collections: promoted.collections
|
|
900
|
-
};
|
|
901
|
-
return cids.map((cid) => writeFor(effective, audience, cid)).filter((entry) => entry !== null);
|
|
902
|
-
}
|
|
903
|
-
/** What this audience may READ, and how to query for it.
|
|
904
|
-
*
|
|
905
|
-
* A collection with no scope is dropped rather than published as unreachable:
|
|
906
|
-
* the gate has already refused the declaration, so reaching here with one is
|
|
907
|
-
* a programming error, and a page that queries it is denied. */
|
|
908
|
-
function tierViews(authored, audience, views, participantRead) {
|
|
909
|
-
return views.map((view) => ({
|
|
910
|
-
id: view.id,
|
|
911
|
-
collections: view.collections.map((cid) => scopeFor(authored, audience, cid, participantRead)).filter((scope) => scope !== null)
|
|
912
|
-
}));
|
|
913
|
-
}
|
|
914
|
-
/** One tier's projection: what this audience may read, and what it may change. */
|
|
915
|
-
function tierConfig(authored, audience, views, stamp, promoted) {
|
|
916
|
-
const cids = [...new Set(views.flatMap((view) => view.collections))];
|
|
917
|
-
const config = {
|
|
918
|
-
write: tierWrites(authored, audience, cids, promoted),
|
|
919
|
-
views: tierViews(authored, audience, views, promoted.participantRead ?? authored.participantRead ?? []),
|
|
920
|
-
submit: tierSubmit(authored, cids),
|
|
921
|
-
publishedAt: stamp.publishedAt
|
|
922
|
-
};
|
|
923
|
-
if (authored.name !== void 0) config.name = authored.name;
|
|
924
|
-
return config;
|
|
925
|
-
}
|
|
926
|
-
/** The submit declarations for the collections these views draw, so a page can
|
|
927
|
-
* show what may be sent rather than discovering it from a denial. */
|
|
928
|
-
function tierSubmit(authored, cids) {
|
|
929
|
-
const declared = authored.public?.submit ?? {};
|
|
930
|
-
return Object.fromEntries(cids.flatMap((cid) => {
|
|
931
|
-
const spec = declared[cid];
|
|
932
|
-
return spec === void 0 ? [] : [[cid, projectSubmit(spec)]];
|
|
933
|
-
}));
|
|
934
|
-
}
|
|
935
|
-
function projectAppViews(authored, stamp, promoted = {}) {
|
|
936
|
-
const normalized = normalizeViews(authored);
|
|
937
|
-
if (!normalized.ok) throw new Error(`publish: views declaration is not publishable (${normalized.problems.join(" ")})`);
|
|
938
|
-
return ["member", "participant"].map((audience) => {
|
|
939
|
-
const views = normalized.views.filter((view) => view.audience === audience);
|
|
940
|
-
return {
|
|
941
|
-
tier: VIEW_TIER[audience],
|
|
942
|
-
audience,
|
|
943
|
-
config: tierConfig(authored, audience, views, stamp, promoted),
|
|
944
|
-
views
|
|
945
|
-
};
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
/** The document a tier's projection is published at. Beside the views
|
|
949
|
-
* themselves, under one `match` — see `firestore.rules`. */
|
|
950
|
-
var viewConfigDocId = (stage) => viewDocId(stage, VIEW_CONFIG_ID);
|
|
951
|
-
//#endregion
|
|
952
|
-
//#region src/collection/server/publishChecks.ts
|
|
953
|
-
/** Does this submit declaration bind a record to the submitter's identity?
|
|
954
|
-
*
|
|
955
|
-
* The condition for requiring `submitOnly`, and deliberately NOT "declares an
|
|
956
|
-
* `audience`": `audience` appears only in the rules' public-create branch, so
|
|
957
|
-
* an owner or editor never meets it and can add records freely. `immutable`
|
|
958
|
-
* is the wrong condition too — a survey's responses are not immutable and
|
|
959
|
-
* can be padded exactly the same way.
|
|
960
|
-
*
|
|
961
|
-
* What these four have in common is that each one makes the record MEAN "the
|
|
962
|
-
* person who submitted it said this": a per-uid id, a per-uid+field id, a
|
|
963
|
-
* row stamped with the submitter's verified address, or a submission
|
|
964
|
-
* restricted to a named participant. A record created through the writer
|
|
965
|
-
* branch carries the same shape and none of that meaning. */
|
|
966
|
-
function bindsSubmitterIdentity(submit) {
|
|
967
|
-
return submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field" || submit.emailField !== void 0 || submit.audience === "participant";
|
|
968
|
-
}
|
|
969
|
-
/** The fields a rule actually CHECKS the value of, for one collection.
|
|
970
|
-
*
|
|
971
|
-
* `keyFields` pins a value against a declared set, `gateOn.match` pins it
|
|
972
|
-
* against the session's current question, and the status field is pinned by
|
|
973
|
-
* the transition machine. An aggregation grouped by anything else is grouped
|
|
974
|
-
* by a field any submitter may write anything into — so the published
|
|
975
|
-
* aggregate is whatever the noisiest respondent decided it should be. */
|
|
976
|
-
function checkedFields(collection, submit) {
|
|
977
|
-
const fields = /* @__PURE__ */ new Set();
|
|
978
|
-
for (const keyField of submit?.validate?.keyFields ?? []) fields.add(keyField.field);
|
|
979
|
-
if (submit?.gateOn) fields.add(submit.gateOn.match);
|
|
980
|
-
if (collection?.statusField) fields.add(collection.statusField);
|
|
981
|
-
return fields;
|
|
982
|
-
}
|
|
983
|
-
/** INVARIANT 1 — a submission bound to its submitter needs `submitOnly`. */
|
|
984
|
-
function submitOnlyProblems(app) {
|
|
985
|
-
const problems = [];
|
|
986
|
-
for (const [cid, submit] of Object.entries(app.public?.submit ?? {})) {
|
|
987
|
-
if (!bindsSubmitterIdentity(submit)) continue;
|
|
988
|
-
if (app.collections?.[cid]?.submitOnly === true) continue;
|
|
989
|
-
problems.push(`collections.${cid}.submitOnly must be true: public.submit.${cid} binds each record to its submitter (${identityBindings(submit).join(", ")}), so a record created any other way would carry that meaning without having earned it. Without submitOnly the rules let an owner or editor write rows directly into ${cid}.`);
|
|
990
|
-
}
|
|
991
|
-
return problems;
|
|
992
|
-
}
|
|
993
|
-
function identityBindings(submit) {
|
|
994
|
-
const bindings = [];
|
|
995
|
-
if (submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field") bindings.push(`idFrom: "${submit.idFrom}"`);
|
|
996
|
-
if (submit.emailField !== void 0) bindings.push(`emailField: "${submit.emailField}"`);
|
|
997
|
-
if (submit.audience === "participant") bindings.push(`audience: "participant"`);
|
|
998
|
-
return bindings;
|
|
999
|
-
}
|
|
1000
|
-
/** INVARIANT 2 — every aggregation key is a field some rule checks. */
|
|
1001
|
-
function aggregateProblems(app) {
|
|
1002
|
-
const problems = [];
|
|
1003
|
-
for (const [cid, collection] of Object.entries(app.collections ?? {})) {
|
|
1004
|
-
const keys = collection.aggregate?.by;
|
|
1005
|
-
if (!keys) continue;
|
|
1006
|
-
const checked = checkedFields(collection, app.public?.submit?.[cid]);
|
|
1007
|
-
const loose = keys.filter((field) => !checked.has(field));
|
|
1008
|
-
const spelled = loose.map((field) => `'${field}'`).join(", ");
|
|
1009
|
-
if (loose.length > 0) problems.push(`collections.${cid}.aggregate.by names ${spelled}, which no rule checks the value of. An aggregation key must appear in public.submit.${cid}.validate.keyFields, in gateOn.match, or be the statusField — otherwise a submitter chooses their own bucket and the published aggregate is not a count of anything.`);
|
|
1010
|
-
}
|
|
1011
|
-
return problems;
|
|
1012
|
-
}
|
|
1013
|
-
/** INVARIANT 3 — `auth: "verifiedEmail"` only.
|
|
1014
|
-
*
|
|
1015
|
-
* A product decision, not a rules limitation: the rules keep all three stages
|
|
1016
|
-
* and the emulator tests keep exercising them, because deleting a stage from
|
|
1017
|
-
* the rules turns a change of mind into a cross-repo deploy. Publish is where
|
|
1018
|
-
* the current decision is expressed, and it is one line to move. */
|
|
1019
|
-
function authProblems(app) {
|
|
1020
|
-
return Object.entries(app.public?.submit ?? {}).filter(([, submit]) => submit.auth !== "verifiedEmail").map(([cid, submit]) => `public.submit.${cid}.auth is "${submit.auth}": only "verifiedEmail" may be published. The rules still implement "none" and "anonymous" — this is a product decision, and lifting it is a change here, not a rules deploy.`);
|
|
1021
|
-
}
|
|
1022
|
-
/** INVARIANT 5 — a mail transition's origins and destination must be disjoint.
|
|
1023
|
-
*
|
|
1024
|
-
* Overlap means the same write can satisfy the same template twice over, and
|
|
1025
|
-
* the deterministic mail id is the only other thing stopping a duplicate
|
|
1026
|
-
* send. The rules also require the status to have CHANGED, so an overlapping
|
|
1027
|
-
* declaration is not merely redundant: `from` containing `to` is a transition
|
|
1028
|
-
* that can never fire, which is a mail nobody ever receives. */
|
|
1029
|
-
function mailProblems(app) {
|
|
1030
|
-
return Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => collectionMailProblems(cid, collection));
|
|
1031
|
-
}
|
|
1032
|
-
function collectionMailProblems(cid, collection) {
|
|
1033
|
-
const { mail } = collection;
|
|
1034
|
-
if (!mail) return [];
|
|
1035
|
-
const problems = [];
|
|
1036
|
-
if (!collection.statusField) problems.push(`collections.${cid}.mail needs collections.${cid}.statusField: the rules read the status before and after the write to decide the mail is warranted.`);
|
|
1037
|
-
for (const [template, transition] of Object.entries(mail.on)) problems.push(...templateMailProblems(cid, collection, template, transition));
|
|
1038
|
-
return problems;
|
|
1039
|
-
}
|
|
1040
|
-
function templateMailProblems(cid, collection, template, transition) {
|
|
1041
|
-
const problems = [];
|
|
1042
|
-
if (transition.from.includes(transition.to)) problems.push(`collections.${cid}.mail.on.${template} lists "${transition.to}" in both \`from\` and \`to\`. The rules require the status to CHANGE in the same write, so this template can never send.`);
|
|
1043
|
-
const allowed = collection.transitions;
|
|
1044
|
-
if (allowed) {
|
|
1045
|
-
const unreachable = transition.from.filter((from) => !(allowed[from] ?? []).includes(transition.to));
|
|
1046
|
-
const spelled = unreachable.map((from) => `'${from}' -> '${transition.to}'`).join(", ");
|
|
1047
|
-
if (unreachable.length > 0) problems.push(`collections.${cid}.mail.on.${template} sends on ${spelled}, which collections.${cid}.transitions does not allow. The record write is refused first, so the mail never fires.`);
|
|
1048
|
-
}
|
|
1049
|
-
return problems;
|
|
1050
|
-
}
|
|
1051
|
-
/** INVARIANTS 6 and 7 — the window is a real interval, and `keyFields` fits
|
|
1052
|
-
* the unrolled check in the rules. */
|
|
1053
|
-
function submitShapeProblems(app) {
|
|
1054
|
-
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => [...windowProblems(cid, submit), ...keyFieldCountProblems(cid, submit)]);
|
|
1055
|
-
}
|
|
1056
|
-
function windowProblems(cid, submit) {
|
|
1057
|
-
const { window } = submit;
|
|
1058
|
-
if (window?.from === void 0 || window.until === void 0) return [];
|
|
1059
|
-
if (Date.parse(window.until) > Date.parse(window.from)) return [];
|
|
1060
|
-
return [`public.submit.${cid}.window closes at or before it opens (${window.from} -> ${window.until}): nothing could ever be submitted.`];
|
|
1061
|
-
}
|
|
1062
|
-
function keyFieldCountProblems(cid, submit) {
|
|
1063
|
-
const keyFields = submit.validate?.keyFields ?? [];
|
|
1064
|
-
if (keyFields.length <= 2) return [];
|
|
1065
|
-
return [`public.submit.${cid}.validate.keyFields declares ${keyFields.length}; the rules check at most 2. Rules have no iteration, so the check is unrolled — a third would be published and never enforced.`];
|
|
1066
|
-
}
|
|
1067
|
-
/** The fail-closed traps: declarations the rules read together, where the
|
|
1068
|
-
* missing half denies every write instead of loosening one. */
|
|
1069
|
-
function coherenceProblems(app) {
|
|
1070
|
-
const fromSubmits = Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitCoherenceProblems(app, cid, submit));
|
|
1071
|
-
const fromCollections = Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => gateCoherenceProblems(cid, collection));
|
|
1072
|
-
return [...fromSubmits, ...fromCollections];
|
|
1073
|
-
}
|
|
1074
|
-
/** `initialStatus` is read together with the collection's `statusField` and
|
|
1075
|
-
* with `createFields`; miss either and every submission is refused. */
|
|
1076
|
-
function statusCoherenceProblems(cid, submit, collection) {
|
|
1077
|
-
if (submit.initialStatus === void 0) return [];
|
|
1078
|
-
if (!collection?.statusField) return [`public.submit.${cid}.initialStatus needs collections.${cid}.statusField: the rules look the status up by that name, and refuse every submission without it.`];
|
|
1079
|
-
if (new Set(submit.createFields).has(collection.statusField)) return [];
|
|
1080
|
-
return [`public.submit.${cid}.createFields must include "${collection.statusField}": a submission may carry ONLY the createFields, and the rules also require the status field to be present and equal to initialStatus. As written, every submission is refused.`];
|
|
1081
|
-
}
|
|
1082
|
-
/** Every field a RULE reads off a submitted record, other than the status
|
|
1083
|
-
* field (which `statusCoherenceProblems` words for itself).
|
|
1084
|
-
*
|
|
1085
|
-
* `emailField` and `idField` belong here for exactly the reason `required`
|
|
1086
|
-
* and `keyFields` do, and forgetting them was the same oversight twice: the
|
|
1087
|
-
* rules read `request.resource.data[s.emailField]` and rebuild the document
|
|
1088
|
-
* id from `s.idField`, while `hasOnly(createFields)` decides what a
|
|
1089
|
-
* submission may carry at all. A field in one list and not the other is a
|
|
1090
|
-
* contradiction the submitter cannot resolve — including it is refused,
|
|
1091
|
-
* omitting it fails the check. */
|
|
1092
|
-
function ruleReadFields(submit) {
|
|
1093
|
-
const fields = [];
|
|
1094
|
-
if (submit.emailField !== void 0) fields.push({
|
|
1095
|
-
field: submit.emailField,
|
|
1096
|
-
why: `public.submit.<cid>.emailField — the rules compare it to the submitter's verified address`
|
|
1097
|
-
});
|
|
1098
|
-
if ((submit.idFrom === "auth.uid+field" || submit.idFrom === "field") && submit.idField !== void 0) fields.push({
|
|
1099
|
-
field: submit.idField,
|
|
1100
|
-
why: `public.submit.<cid>.idField — the rules rebuild the document id from it`
|
|
1101
|
-
});
|
|
1102
|
-
return fields;
|
|
1103
|
-
}
|
|
1104
|
-
/** A checked field a submission is not allowed to carry can never be
|
|
1105
|
-
* satisfied: carrying it fails `hasOnly`, omitting it fails the check. */
|
|
1106
|
-
function createFieldProblems(cid, submit) {
|
|
1107
|
-
const createFields = new Set(submit.createFields);
|
|
1108
|
-
const ruleRead = ruleReadFields(submit).filter((entry) => !createFields.has(entry.field)).map((entry) => `public.submit.${cid}.createFields must include "${entry.field}" (${entry.why.replace("<cid>", cid)}): a submission may carry only the createFields, so as written every submission is refused whether or not it carries the field.`);
|
|
1109
|
-
const required = (submit.validate?.required ?? []).filter((field) => !createFields.has(field)).map((field) => `public.submit.${cid}.validate.required names "${field}", which is not in createFields: a submission may carry only the createFields, so the requirement can never be met.`);
|
|
1110
|
-
const keyFields = (submit.validate?.keyFields ?? []).filter((keyField) => !createFields.has(keyField.field)).map((keyField) => `public.submit.${cid}.validate.keyFields checks "${keyField.field}", which is not in createFields: a submission carrying it is refused, and one omitting it fails the check.`);
|
|
1111
|
-
return [
|
|
1112
|
-
...ruleRead,
|
|
1113
|
-
...required,
|
|
1114
|
-
...keyFields
|
|
1115
|
-
];
|
|
1116
|
-
}
|
|
1117
|
-
function submitCoherenceProblems(app, cid, submit) {
|
|
1118
|
-
const collection = app.collections?.[cid];
|
|
1119
|
-
const problems = [...statusCoherenceProblems(cid, submit, collection), ...createFieldProblems(cid, submit)];
|
|
1120
|
-
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.`);
|
|
1121
|
-
problems.push(...fieldIdProblems(cid, submit));
|
|
1122
|
-
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.`);
|
|
1123
|
-
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.`);
|
|
1124
|
-
return problems;
|
|
1125
|
-
}
|
|
1126
|
-
/** `idFrom: "field"` makes the document id a CLAIM ABOUT ANOTHER RECORD, and
|
|
1127
|
-
* the claim is only worth what is checked.
|
|
1128
|
-
*
|
|
1129
|
-
* `idIn` is required rather than optional, and that is the whole point of
|
|
1130
|
-
* refusing here: without it the id is any string a stranger likes, so the app
|
|
1131
|
-
* quietly accepts bookings for slots that do not exist. Nothing downstream
|
|
1132
|
-
* ever notices — the booking is real, its slot is not — which is exactly the
|
|
1133
|
-
* kind of hole a gate is for and a rule cannot state.
|
|
1134
|
-
*
|
|
1135
|
-
* `idIn` without the mode is refused for the opposite reason: the rules read
|
|
1136
|
-
* it only in that branch, so an author who wrote it believes a check is
|
|
1137
|
-
* running that is not. */
|
|
1138
|
-
function fieldIdProblems(cid, submit) {
|
|
1139
|
-
const problems = [];
|
|
1140
|
-
if (submit.idFrom === "field") {
|
|
1141
|
-
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.`);
|
|
1142
|
-
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" } }.`);
|
|
1143
|
-
} else if (submit.idIn !== void 0) {
|
|
1144
|
-
const mode = submit.idFrom === void 0 ? "absent" : JSON.stringify(submit.idFrom);
|
|
1145
|
-
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.`);
|
|
1146
|
-
}
|
|
1147
|
-
return problems;
|
|
1148
|
-
}
|
|
1149
|
-
/** Every `idIn` target, checked against the collections this repository has.
|
|
1150
|
-
*
|
|
1151
|
-
* Separate from {@link fieldIdProblems} for the reason the file is split at
|
|
1152
|
-
* all: that one reads the declaration alone, this one needs to know what
|
|
1153
|
-
* exists. */
|
|
1154
|
-
function idTargetProblems(app, collections) {
|
|
1155
|
-
const known = new Set(collections.map((collection) => collection.cid));
|
|
1156
|
-
const names = known.size > 0 ? [...known].sort().join(", ") : "(none)";
|
|
1157
|
-
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => idInTargetProblems(cid, submit, known, names));
|
|
1158
|
-
}
|
|
1159
|
-
/** Where a `field` id says its record must be found.
|
|
1160
|
-
*
|
|
1161
|
-
* A typo passes every other check: the rules look the record up in a
|
|
1162
|
-
* collection that does not exist, the lookup can never succeed, and every
|
|
1163
|
-
* submission is refused with no explanation anywhere. A collection pointing
|
|
1164
|
-
* at ITSELF is worse than a typo — on a create the document being written
|
|
1165
|
-
* does not exist yet, so it is a declaration that can never accept anything. */
|
|
1166
|
-
function idInTargetProblems(cid, submit, known, names) {
|
|
1167
|
-
const target = submit.idIn?.collection;
|
|
1168
|
-
if (target === void 0) return [];
|
|
1169
|
-
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).`];
|
|
1170
|
-
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}.`];
|
|
1171
|
-
return [];
|
|
1172
|
-
}
|
|
1173
|
-
/** The staged reveal reads its flag off the PARENT record, so the path to that
|
|
1174
|
-
* parent is not optional decoration — without it the gate never opens. */
|
|
1175
|
-
function gateCoherenceProblems(cid, collection) {
|
|
1176
|
-
if (collection.revealGated !== true) return [];
|
|
1177
|
-
if (collection.gatedFrom !== void 0 && collection.revealBy !== void 0) return [];
|
|
1178
|
-
return [`collections.${cid}.revealGated needs both gatedFrom and revealBy: the flag is read off the PARENT record, and without the path the gate never opens.`];
|
|
1179
|
-
}
|
|
1180
|
-
/** The publisher must be able to write what they are about to write.
|
|
1181
|
-
*
|
|
1182
|
-
* On a first publish the rules require the creator to name themselves owner,
|
|
1183
|
-
* in the roster, under `'*'`. Getting this wrong produces a bare permission
|
|
1184
|
-
* error from Firestore with nothing in it about rosters — worth one line
|
|
1185
|
-
* here instead. */
|
|
1186
|
-
function publisherProblems(app, publisherEmail) {
|
|
1187
|
-
if (app.members[publisherEmail]?.["*"] === "owner") return [];
|
|
1188
|
-
return [`members must give you app-wide owner: add "${publisherEmail}": { "*": "owner" }. The rules require the publisher to hold that role (and to name themselves owner when the app is first created); otherwise the write is refused with no explanation.`];
|
|
1189
|
-
}
|
|
1190
|
-
/** Every cid the declaration mentions must be a collection that exists.
|
|
1191
|
-
*
|
|
1192
|
-
* A typo'd cid is not an error anywhere else: the app document simply carries
|
|
1193
|
-
* a configuration for a collection nobody publishes, and the collection the
|
|
1194
|
-
* author meant is published with no configuration at all — i.e. with the
|
|
1195
|
-
* status machine and the submit path silently absent. */
|
|
1196
|
-
function unknownCidProblems(app, collections) {
|
|
1197
|
-
const known = new Set(collections.map((collection) => collection.cid));
|
|
1198
|
-
return [
|
|
1199
|
-
["collections", Object.keys(app.collections ?? {})],
|
|
1200
|
-
["public.read", app.public?.read ?? []],
|
|
1201
|
-
["public.submit", Object.keys(app.public?.submit ?? {})],
|
|
1202
|
-
["participantRead", app.participantRead ?? []],
|
|
1203
|
-
["members", [...new Set(Object.values(app.members).flatMap((roles) => Object.keys(roles)))].filter((key) => key !== "*")]
|
|
1204
|
-
].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\")"}.`));
|
|
1205
|
-
}
|
|
1206
|
-
/** Everything publish refuses, as lines the author can act on.
|
|
1207
|
-
*
|
|
1208
|
-
* All of them, every time. Publish is a manual step with a human waiting on
|
|
1209
|
-
* it; stopping at the first problem turns one review into five. */
|
|
1210
|
-
function publishProblems(app, collections, publisherEmail) {
|
|
1211
|
-
return [
|
|
1212
|
-
...unknownCidProblems(app, collections),
|
|
1213
|
-
...publisherProblems(app, publisherEmail),
|
|
1214
|
-
...submitOnlyProblems(app),
|
|
1215
|
-
...aggregateProblems(app),
|
|
1216
|
-
...authProblems(app),
|
|
1217
|
-
...mailProblems(app),
|
|
1218
|
-
...submitShapeProblems(app),
|
|
1219
|
-
...coherenceProblems(app),
|
|
1220
|
-
...primaryKeyProblems(app, collections),
|
|
1221
|
-
...assigneeProblems(app),
|
|
1222
|
-
...stampProblems(app),
|
|
1223
|
-
...windowRefProblems(app, collections),
|
|
1224
|
-
...idTargetProblems(app, collections),
|
|
1225
|
-
...mirrorProblems(app, collections),
|
|
1226
|
-
...viewProblems(app, collections)
|
|
1227
|
-
];
|
|
1228
|
-
}
|
|
1229
|
-
/** A public submission must NOT be allowed to name its own primary key.
|
|
1230
|
-
*
|
|
1231
|
-
* The rules constrain the DOCUMENT ID (`idFrom`) and cannot constrain the
|
|
1232
|
-
* value of a field — nothing compares `request.resource.data[primaryKey]`
|
|
1233
|
-
* with the path being written. So a submit path that accepts the primary key
|
|
1234
|
-
* as a `createField` lets a submitter write at their one permitted document
|
|
1235
|
-
* id while CLAIMING another record's identity, or a duplicate.
|
|
1236
|
-
*
|
|
1237
|
-
* It is refused rather than tolerated because there is nothing for the field
|
|
1238
|
-
* to do: `firestoreStore` takes a shared record's identity from the document
|
|
1239
|
-
* id and overwrites the field on read, so a submitted value is either equal
|
|
1240
|
-
* to the id (noise) or a lie (silently discarded). Publishing a form field
|
|
1241
|
-
* whose value is thrown away is worse than not having it — the author will
|
|
1242
|
-
* believe submitters choose their ids.
|
|
1243
|
-
*
|
|
1244
|
-
* This is the second answer to the same question. The first was the reverse —
|
|
1245
|
-
* REQUIRE the key, because a record without one was rejected by every reader
|
|
1246
|
-
* — and it was right about the symptom and wrong about the cure: the identity
|
|
1247
|
-
* belongs to the id the rules can pin, not to a field they cannot. */
|
|
1248
|
-
function primaryKeyProblems(app, collections) {
|
|
1249
|
-
const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
|
|
1250
|
-
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
1251
|
-
const primaryKey = primaryKeyOf.get(cid);
|
|
1252
|
-
if (primaryKey === void 0 || !submit.createFields.includes(primaryKey)) return [];
|
|
1253
|
-
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.`];
|
|
1254
|
-
});
|
|
1255
|
-
}
|
|
1256
|
-
/** `assignee` without the field that says which rows are theirs.
|
|
1257
|
-
*
|
|
1258
|
-
* A FAIL-CLOSED trap of the worst kind, because it fails closed for one
|
|
1259
|
-
* person and nobody else: the rules ask `collections[cid].assigneeField` for
|
|
1260
|
-
* the field to compare, find nothing, and refuse every write that member
|
|
1261
|
-
* makes. The app works for the owner who set it up, and the member it was set
|
|
1262
|
-
* up for is told only "permission denied".
|
|
1263
|
-
*
|
|
1264
|
-
* `'*': "assignee"` is refused outright rather than checked against every
|
|
1265
|
-
* collection. The role means "the rows assigned to you", and what counts as
|
|
1266
|
-
* assigned is per collection — an app-wide one would need the same field name
|
|
1267
|
-
* to be right everywhere, and where it is missing it silently means "no
|
|
1268
|
-
* access to this collection" rather than "no scoping here".
|
|
1269
|
-
*/
|
|
1270
|
-
function assigneeProblems(app) {
|
|
1271
|
-
return Object.entries(app.members).flatMap(([email, roles]) => Object.entries(roles).flatMap(([cid, role]) => {
|
|
1272
|
-
if (role !== "assignee") return [];
|
|
1273
|
-
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" }.`];
|
|
1274
|
-
if (app.collections?.[cid]?.assigneeField !== void 0) return [];
|
|
1275
|
-
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.`];
|
|
1276
|
-
}));
|
|
1277
|
-
}
|
|
1278
|
-
/** A server-stamped field the submitter cannot write, or can rewrite later.
|
|
1279
|
-
*
|
|
1280
|
-
* Both failures are silent in opposite directions. Left out of
|
|
1281
|
-
* `createFields`, the rules refuse every submission (`hasOnly(createFields)`
|
|
1282
|
-
* rejects the key the stamp check requires) — an app nobody can use. Left IN
|
|
1283
|
-
* a `selfUpdate` list, the field the queue is ordered by becomes editable by
|
|
1284
|
-
* the person standing in the queue. */
|
|
1285
|
-
function stampProblems(app) {
|
|
1286
|
-
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
1287
|
-
const stamp = submit.stampField;
|
|
1288
|
-
if (stamp === void 0) return [];
|
|
1289
|
-
const problems = [];
|
|
1290
|
-
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.`);
|
|
1291
|
-
for (const [status, fields] of Object.entries(submit.selfUpdate ?? {})) {
|
|
1292
|
-
if (!fields.includes(stamp)) continue;
|
|
1293
|
-
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.`);
|
|
1294
|
-
}
|
|
1295
|
-
return problems;
|
|
1296
|
-
});
|
|
1297
|
-
}
|
|
1298
|
-
/** A per-record window bound pointing at a collection or a field the submitter
|
|
1299
|
-
* never writes.
|
|
1300
|
-
*
|
|
1301
|
-
* `fromField` makes the rules read another record, and every part of that
|
|
1302
|
-
* read is fail-closed: an unknown collection, or a `ref` the submission does
|
|
1303
|
-
* not carry, means the bound can never be satisfied and the form is shut for
|
|
1304
|
-
* good. */
|
|
1305
|
-
function windowRefProblems(app, collections) {
|
|
1306
|
-
const known = new Set(collections.map((collection) => collection.cid));
|
|
1307
|
-
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")]);
|
|
1308
|
-
}
|
|
1309
|
-
/** Both bounds, checked identically. `untilField` arrived with the booking
|
|
1310
|
-
* desk and reads exactly like its twin, so a check that knew only about
|
|
1311
|
-
* `fromField` would let the closing half through unchecked — and a closing
|
|
1312
|
-
* bound that names nothing does not leave the door ajar, it refuses every
|
|
1313
|
-
* submission with no explanation. */
|
|
1314
|
-
function windowBoundProblems(cid, submit, known, key, ref, which) {
|
|
1315
|
-
if (ref === void 0) return [];
|
|
1316
|
-
const problems = [];
|
|
1317
|
-
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)"}.`);
|
|
1318
|
-
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.`);
|
|
1319
|
-
return problems;
|
|
1320
|
-
}
|
|
1321
|
-
/** The two halves of a mirror, checked as the pair they only work as.
|
|
1322
|
-
*
|
|
1323
|
-
* `mirror` on the submission and `mirrorOf` on the projection are separate
|
|
1324
|
-
* keys in separate places, and each is inert without the other: a booking
|
|
1325
|
-
* whose slot declares no `mirrorOf` can never be created (the rules demand a
|
|
1326
|
-
* paired write that the projection's own rule will refuse), and a projection
|
|
1327
|
-
* whose authority declares no `mirror` drifts unbounded because nothing makes
|
|
1328
|
-
* the two move together. Both failures are silent, and one of them —
|
|
1329
|
-
* advertising a slot somebody already holds — is the exact thing the mirror
|
|
1330
|
-
* exists to prevent.
|
|
1331
|
-
*
|
|
1332
|
-
* Also refuses a collection mirroring ITSELF, which reads as a typo and
|
|
1333
|
-
* behaves as an unwritable collection: every create would have to prove its
|
|
1334
|
-
* own document is simultaneously taken and open. */
|
|
1335
|
-
function mirrorProblems(app, collections) {
|
|
1336
|
-
const known = new Set(collections.map((collection) => collection.cid));
|
|
1337
|
-
const names = known.size > 0 ? [...known].sort().join(", ") : "(none)";
|
|
1338
|
-
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))];
|
|
1339
|
-
}
|
|
1340
|
-
/** The submission side: `public.submit[cid].mirror`. */
|
|
1341
|
-
function mirrorClaimProblems(app, cid, submit, known, names) {
|
|
1342
|
-
const { mirror } = submit;
|
|
1343
|
-
if (mirror === void 0) return [];
|
|
1344
|
-
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.`];
|
|
1345
|
-
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}.`];
|
|
1346
|
-
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.`];
|
|
1347
|
-
return [];
|
|
1348
|
-
}
|
|
1349
|
-
/** The projection side: `collections[cid].mirrorOf`. */
|
|
1350
|
-
function mirrorOfProblems(app, cid, collection, known, names) {
|
|
1351
|
-
const authority = collection.mirrorOf;
|
|
1352
|
-
if (authority === void 0) return [];
|
|
1353
|
-
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}.`];
|
|
1354
|
-
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.`];
|
|
1355
|
-
return [];
|
|
1356
|
-
}
|
|
1357
|
-
/** What each view is handed, and whether its audience can actually read it.
|
|
1358
|
-
*
|
|
1359
|
-
* Declared, never inferred — a view whose datasets were guessed from
|
|
1360
|
-
* `public.read` renders perfectly and draws an empty grid, with nothing in
|
|
1361
|
-
* the page, the rules or the log to say why.
|
|
1362
|
-
*
|
|
1363
|
-
* The reachability check below is that same failure, once per audience. A
|
|
1364
|
-
* `public` view naming a collection outside `public.read` draws nothing; a
|
|
1365
|
-
* `participant` view naming one the participant reaches by neither
|
|
1366
|
-
* `participantRead` nor their own row is worse, because an unscoped list on
|
|
1367
|
-
* an own-row collection is DENIED rather than narrowed — the page does not
|
|
1368
|
-
* render less, it fails.
|
|
1369
|
-
*
|
|
1370
|
-
* There is deliberately no such check for `member`. Every read a role opens
|
|
1371
|
-
* is unscoped, and WHICH role a given member holds is not a property of the
|
|
1372
|
-
* declaration: a stylist scoped to `bookings` and an owner read the same
|
|
1373
|
-
* projection. That one is settled at the entrance, by trying the read. */
|
|
1374
|
-
/** The path, for one view. The SAME validator the host's own custom views use,
|
|
1375
|
-
* rather than a second opinion about what a safe view path is.
|
|
1376
|
-
*
|
|
1377
|
-
* Two ad-hoc attempts were wrong here in the same afternoon: a prefix-and-
|
|
1378
|
-
* suffix test let `views/../../secrets.html` through, and `views/[^/]+\.html`
|
|
1379
|
-
* still let `views/..\..\secrets.html` through, because a backslash is not a
|
|
1380
|
-
* slash on this side of the check and IS a separator on Windows. This one
|
|
1381
|
-
* rejects `..`, backslashes, leading slashes and anything outside
|
|
1382
|
-
* `[A-Za-z0-9._-]` per segment.
|
|
1383
|
-
*
|
|
1384
|
-
* It matters more here than for a host view: the host reads this path to
|
|
1385
|
-
* decide which file to copy onto a document other people read — for a public
|
|
1386
|
-
* view, a document whose rule is `allow read: if true`, so the blast radius of
|
|
1387
|
-
* a bad path is the world rather than the author's own iframe. Nested paths
|
|
1388
|
-
* ARE allowed by the shared validator; the extra `views/<one name>.html` shape
|
|
1389
|
-
* is this publisher's own narrowing, kept because there is no reason for a
|
|
1390
|
-
* published view to live in a subdirectory. */
|
|
1391
|
-
function viewPathProblems(view) {
|
|
1392
|
-
if (isSafeCustomViewPath(view.path) && view.path.split("/").length === 2) return [];
|
|
1393
|
-
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.`];
|
|
1394
|
-
}
|
|
1395
|
-
/** One dataset, for one view: does it exist here, and can the audience it is
|
|
1396
|
-
* handed to actually read it? */
|
|
1397
|
-
function viewCollectionProblems(app, view, cid, known) {
|
|
1398
|
-
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)"}.`];
|
|
1399
|
-
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.`];
|
|
1400
|
-
return [];
|
|
1401
|
-
}
|
|
1402
|
-
function viewProblems(app, collections) {
|
|
1403
|
-
const normalized = normalizeViews(app);
|
|
1404
|
-
if (!normalized.ok) return normalized.problems;
|
|
1405
|
-
const known = new Set(collections.map((collection) => collection.cid));
|
|
1406
|
-
return normalized.views.flatMap((view) => [...viewPathProblems(view), ...view.collections.flatMap((cid) => viewCollectionProblems(app, view, cid, known))]);
|
|
1407
|
-
}
|
|
1408
|
-
/** What publish will actually promote, checked as the PAIR it becomes.
|
|
1409
|
-
*
|
|
1410
|
-
* `publishProblems` reads the manifest, where `members` and `collections` sit
|
|
1411
|
-
* side by side and agree. Publish does not write that pair. It writes the
|
|
1412
|
-
* roster from the manifest and the collection configuration from what DEPLOY
|
|
1413
|
-
* staged, so the app that lands is one half of each — and no check has ever
|
|
1414
|
-
* looked at that combination.
|
|
1415
|
-
*
|
|
1416
|
-
* The sequence that gets through: deploy revision A with no `assigneeField`,
|
|
1417
|
-
* add the field AND the member in revision B, publish without redeploying.
|
|
1418
|
-
* Every manifest-level check passes on a declaration that is internally sound,
|
|
1419
|
-
* while what lands is A's field-less configuration beside B's roster — an
|
|
1420
|
-
* assignee with nothing to be compared against, refused every write, in an app
|
|
1421
|
-
* that keeps working for everybody else. That is the precise trap
|
|
1422
|
-
* `assigneeProblems` exists to prevent, reached by the one route it cannot
|
|
1423
|
-
* see.
|
|
1424
|
-
*
|
|
1425
|
-
* Separate from `publishProblems` because it needs what deploy staged, which
|
|
1426
|
-
* is a Firestore read the host makes and this package does not. It is checked
|
|
1427
|
-
* against `stagedRuleConfig` — the same function the projection uses — rather
|
|
1428
|
-
* than against a re-derivation, so the value validated is the value written.
|
|
1429
|
-
*
|
|
1430
|
-
* Only the staged half can be stale, so only that half is named and the fix is
|
|
1431
|
-
* "deploy again" rather than "fix the declaration". */
|
|
1432
|
-
function promotedRoleProblems(app, staged) {
|
|
1433
|
-
const promoted = stagedRuleConfig(staged).collections ?? {};
|
|
1434
|
-
const stagedCids = new Set(staged.map((entry) => entry.cid));
|
|
1435
|
-
return [
|
|
1436
|
-
...promotedAssigneeProblems(app, promoted, stagedCids),
|
|
1437
|
-
...promotedMirrorProblems(app, promoted, stagedCids),
|
|
1438
|
-
...promotedRefFieldProblems(app, staged),
|
|
1439
|
-
...promotedParticipantViewProblems(app, staged)
|
|
1440
|
-
];
|
|
1441
|
-
}
|
|
1442
|
-
/** A participant's page, checked against the `participantRead` publish will
|
|
1443
|
-
* actually PROMOTE.
|
|
1444
|
-
*
|
|
1445
|
-
* Not against the manifest's. `projectPublish` overwrites `participantRead`
|
|
1446
|
-
* with what the staged schemas carry, so a cid added to the manifest since the
|
|
1447
|
-
* last deploy is not in the rules — and a page written for it would be
|
|
1448
|
-
* published, offered, and then refused the read. The manifest half of this
|
|
1449
|
-
* file cannot see that; the promoted half can, which is why the check lives
|
|
1450
|
-
* here rather than beside the other view checks. */
|
|
1451
|
-
function promotedParticipantViewProblems(app, staged) {
|
|
1452
|
-
const normalized = normalizeViews(app);
|
|
1453
|
-
if (!normalized.ok) return [];
|
|
1454
|
-
const participantRead = stagedRuleConfig(staged).participantRead ?? [];
|
|
1455
|
-
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.)`));
|
|
1456
|
-
}
|
|
1457
|
-
/** The FIELDS a rule reads off another record — `idIn.where.field` and the two
|
|
1458
|
-
* window bounds — checked against the schema publish is about to promote.
|
|
1459
|
-
*
|
|
1460
|
-
* These are checked here rather than in `publishProblems` because that gate
|
|
1461
|
-
* is given a cid and a primary key per collection and nothing else, on
|
|
1462
|
-
* purpose: it reads the DECLARATION. A field name can only be judged against
|
|
1463
|
-
* a schema, and the schema that matters is the STAGED one — the version
|
|
1464
|
-
* publish promotes — not whatever the working tree says now.
|
|
1465
|
-
*
|
|
1466
|
-
* What a typo costs: `where: { field: "staet" }` publishes cleanly, the
|
|
1467
|
-
* rules' comparison can never match, and every submission is denied with no
|
|
1468
|
-
* message. The author's own app looks broken with nothing to read.
|
|
1469
|
-
*
|
|
1470
|
-
* Only fields the schema DECLARES are accepted. A record may carry more than
|
|
1471
|
-
* its schema does, but a shared collection's records are written through it,
|
|
1472
|
-
* and "the field exists on some rows" is not something a gate can promise. */
|
|
1473
|
-
function promotedRefFieldProblems(app, staged) {
|
|
1474
|
-
const schemaOf = new Map(staged.map((entry) => [entry.cid, entry.doc.publishedSchema]));
|
|
1475
|
-
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitRefProblems(schemaOf, cid, submit));
|
|
1476
|
-
}
|
|
1477
|
-
function submitRefProblems(schemaOf, cid, submit) {
|
|
1478
|
-
return [
|
|
1479
|
-
...idInRefProblems(schemaOf, cid, submit),
|
|
1480
|
-
...boundRefProblems(schemaOf, cid, "fromField", submit.window?.fromField),
|
|
1481
|
-
...boundRefProblems(schemaOf, cid, "untilField", submit.window?.untilField)
|
|
1482
|
-
];
|
|
1483
|
-
}
|
|
1484
|
-
function idInRefProblems(schemaOf, cid, submit) {
|
|
1485
|
-
const where = submit.idIn?.where;
|
|
1486
|
-
if (where === void 0) return [];
|
|
1487
|
-
return [...refFieldProblem(schemaOf, cid, "idIn.where.field", submit.idIn?.collection, where.field), ...comparableProblem(schemaOf, cid, submit.idIn?.collection, where)];
|
|
1488
|
-
}
|
|
1489
|
-
function boundRefProblems(schemaOf, cid, key, ref) {
|
|
1490
|
-
if (ref === void 0) return [];
|
|
1491
|
-
return [...refFieldProblem(schemaOf, cid, `window.${key}.field`, ref.collection, ref.field), ...millisProblem(schemaOf, cid, `window.${key}.field`, ref)];
|
|
1492
|
-
}
|
|
1493
|
-
/** The field spec a reference points at, or undefined when there is nothing
|
|
1494
|
-
* staged to judge it against (the host refuses that separately, naming every
|
|
1495
|
-
* missing collection at once). */
|
|
1496
|
-
function referencedField(schemaOf, target, field) {
|
|
1497
|
-
if (target === void 0 || field === void 0) return void 0;
|
|
1498
|
-
return schemaOf.get(target)?.fields?.[field];
|
|
1499
|
-
}
|
|
1500
|
-
/** An enum's domain, or undefined for every other kind. Narrowed by the key
|
|
1501
|
-
* rather than asserted: `fields` is a discriminated union and only some of
|
|
1502
|
-
* its members carry `values`. */
|
|
1503
|
-
function enumValues(spec) {
|
|
1504
|
-
return spec.type === "enum" ? spec.values : void 0;
|
|
1505
|
-
}
|
|
1506
|
-
function refFieldProblem(schemaOf, cid, key, target, field) {
|
|
1507
|
-
if (target === void 0 || field === void 0) return [];
|
|
1508
|
-
const schema = schemaOf.get(target);
|
|
1509
|
-
if (schema === void 0 || referencedField(schemaOf, target, field) !== void 0) return [];
|
|
1510
|
-
const known = Object.keys(schema.fields ?? {}).sort().join(", ");
|
|
1511
|
-
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)"}.`];
|
|
1512
|
-
}
|
|
1513
|
-
/** A comparison the rules can never satisfy is as dead as a missing field, and
|
|
1514
|
-
* looks even more correct on the page: an `enum` whose domain does not contain
|
|
1515
|
-
* the value, or a boolean field compared with a string. */
|
|
1516
|
-
function comparableProblem(schemaOf, cid, target, where) {
|
|
1517
|
-
const spec = referencedField(schemaOf, target, where.field);
|
|
1518
|
-
if (spec === void 0) return [];
|
|
1519
|
-
const said = JSON.stringify(where.equals);
|
|
1520
|
-
const values = enumValues(spec);
|
|
1521
|
-
if (values !== void 0) {
|
|
1522
|
-
if (values.includes(String(where.equals))) return [];
|
|
1523
|
-
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.`];
|
|
1524
|
-
}
|
|
1525
|
-
const wanted = spec.type === "number" ? "number" : spec.type === "boolean" ? "boolean" : "string";
|
|
1526
|
-
if (typeof where.equals === wanted) return [];
|
|
1527
|
-
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.`];
|
|
1528
|
-
}
|
|
1529
|
-
/** A per-record window bound is EPOCH MILLIS, because the rules have no date
|
|
1530
|
-
* arithmetic and do not coerce: they compare `request.time.toMillis()` with
|
|
1531
|
-
* whatever is stored. A `datetime` field holds an ISO string, which is a type
|
|
1532
|
-
* error that fails closed — the window never opens, and nothing says so. */
|
|
1533
|
-
function millisProblem(schemaOf, cid, key, ref) {
|
|
1534
|
-
const spec = referencedField(schemaOf, ref?.collection, ref?.field);
|
|
1535
|
-
if (spec === void 0 || spec.type === "number") return [];
|
|
1536
|
-
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.`];
|
|
1537
|
-
}
|
|
1538
|
-
function promotedAssigneeProblems(app, promoted, stagedCids) {
|
|
1539
|
-
return Object.entries(app.members).flatMap(([email, roles]) => Object.entries(roles).flatMap(([cid, role]) => {
|
|
1540
|
-
if (role !== "assignee" || cid === "*" || !stagedCids.has(cid)) return [];
|
|
1541
|
-
if (promoted[cid]?.assigneeField !== void 0) return [];
|
|
1542
|
-
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.`];
|
|
1543
|
-
}));
|
|
1544
|
-
}
|
|
1545
|
-
/** The mirror's other half, checked against what publish will actually
|
|
1546
|
-
* promote — the same trap as the assignee's field, reached by the same route.
|
|
1547
|
-
*
|
|
1548
|
-
* `mirror` is published from the MANIFEST (it lives in `public.submit`) while
|
|
1549
|
-
* `mirrorOf` is promoted from what DEPLOY staged. Add both halves to
|
|
1550
|
-
* `app.json` and publish without redeploying, and what lands is a submission
|
|
1551
|
-
* demanding a paired projection write beside a projection whose rule config
|
|
1552
|
-
* does not allow it: every booking is refused, and the declaration on disk
|
|
1553
|
-
* looks perfectly sound.
|
|
1554
|
-
*
|
|
1555
|
-
* Refused in the reverse direction too. Removing `mirrorOf` from a live app
|
|
1556
|
-
* and publishing without a deploy leaves the projection accepting nothing —
|
|
1557
|
-
* and removing it FROM the staged side while the submission still demands it
|
|
1558
|
-
* is the drift this pair exists to prevent. */
|
|
1559
|
-
function promotedMirrorProblems(app, promoted, stagedCids) {
|
|
1560
|
-
return [...addedMirrorProblems(app, promoted, stagedCids), ...strandedMirrorProblems(app, promoted)];
|
|
1561
|
-
}
|
|
1562
|
-
/** The manifest asks for a projection the promoted configuration will not
|
|
1563
|
-
* allow: every submission denied, and nothing on the page to say why. */
|
|
1564
|
-
function addedMirrorProblems(app, promoted, stagedCids) {
|
|
1565
|
-
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
1566
|
-
const { mirror } = submit;
|
|
1567
|
-
if (mirror === void 0 || !stagedCids.has(mirror)) return [];
|
|
1568
|
-
if (promoted[mirror]?.mirrorOf === cid) return [];
|
|
1569
|
-
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.`];
|
|
1570
|
-
});
|
|
1571
|
-
}
|
|
1572
|
-
/** The other direction, and the DANGEROUS one.
|
|
1573
|
-
*
|
|
1574
|
-
* Take a live pair, delete BOTH halves from `app.json`, and publish without
|
|
1575
|
-
* redeploying. The submission side comes from the manifest, so nothing
|
|
1576
|
-
* requires the projection to move any more; the collection side comes from
|
|
1577
|
-
* staging, which still says `mirrorOf`, so the projection stays writable.
|
|
1578
|
-
* Bookings are then created while the public row goes on saying `open` — the
|
|
1579
|
-
* precise failure the pair exists to prevent, arrived at by removing it.
|
|
1580
|
-
*
|
|
1581
|
-
* Refused rather than tolerated because the app keeps WORKING: submissions
|
|
1582
|
-
* succeed. Only the public page is wrong, and only to the people reading it. */
|
|
1583
|
-
function strandedMirrorProblems(app, promoted) {
|
|
1584
|
-
return Object.entries(promoted).flatMap(([cid, config]) => {
|
|
1585
|
-
const authority = config.mirrorOf;
|
|
1586
|
-
if (authority === void 0) return [];
|
|
1587
|
-
if (app.public?.submit?.[authority]?.mirror === cid) return [];
|
|
1588
|
-
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.`];
|
|
1589
|
-
});
|
|
1590
|
-
}
|
|
1591
|
-
//#endregion
|
|
1592
13
|
//#region src/collection/server/skillAssets.ts
|
|
1593
14
|
/** Read a collection's custom-view HTML, path-safely. `viewFile` is a
|
|
1594
15
|
* schema-validated `views/*.html` path, resolved with realpath containment.
|
|
@@ -3658,6 +2079,6 @@ function makeManageCollectionTool(deps = {}) {
|
|
|
3658
2079
|
};
|
|
3659
2080
|
}
|
|
3660
2081
|
//#endregion
|
|
3661
|
-
export {
|
|
2082
|
+
export { runQueryOverRows as A, STORE_UNREADABLE as C, recordFieldProblem as D, compileRecordZ as E, readCustomViewI18n as F, readSkillTemplate as I, buildCollectionActionSeedPrompt as M, promptPathsFor as N, runCollectionQuery as O, readCustomViewHtml as P, MAX_RECORD_ISSUES as S, validateRecordObject as T, computeCollectionIcon as _, deleteCollection as a, applyMutateAction as b, computeSuccessor as c, isTriggerDue as d, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, buildActionSeedPrompt as j, enrichItems as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, buildWorkspaceOntology as v, validateCollectionRecords as w, firstMutateParamProblem as x, schemaRelations as y };
|
|
3662
2083
|
|
|
3663
|
-
//# sourceMappingURL=server-
|
|
2084
|
+
//# sourceMappingURL=server-BIewQYpC.js.map
|