@mulmoclaude/core 3.6.0 → 3.10.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/core/itemId.d.ts +28 -0
- package/dist/collection/firestore.cjs +9 -1
- package/dist/collection/firestore.cjs.map +1 -1
- package/dist/collection/firestore.js +10 -2
- package/dist/collection/firestore.js.map +1 -1
- package/dist/collection/index.cjs +55 -66
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +1 -1
- package/dist/collection/index.js +3 -15
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +2 -2
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/firestoreDocs.d.ts +18 -0
- package/dist/collection/server/host.d.ts +40 -0
- package/dist/collection/server/index.cjs +25 -4
- package/dist/collection/server/index.d.ts +4 -1
- package/dist/collection/server/index.js +4 -4
- package/dist/collection/server/io.d.ts +5 -3
- package/dist/collection/server/publishChecks.d.ts +29 -0
- package/dist/collection/server/publishManifest.d.ts +181 -0
- package/dist/collection/server/publishProject.d.ts +255 -0
- package/dist/collection/server/validate.d.ts +12 -0
- package/dist/collection-watchers/index.cjs +70 -26
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +67 -23
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-CdkaURVY.js → discovery-B9zdNkgW.js} +189 -18
- package/dist/discovery-B9zdNkgW.js.map +1 -0
- package/dist/{discovery-Cw5xWzZD.cjs → discovery-BsXiDZBR.cjs} +226 -43
- package/dist/discovery-BsXiDZBR.cjs.map +1 -0
- package/dist/feeds/index.cjs +4 -4
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/index.cjs +6 -6
- package/dist/feeds/server/index.js +4 -4
- package/dist/firestore/listen.d.ts +4 -0
- package/dist/google/index.cjs +5 -5
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +2 -2
- package/dist/{ingestTypes-CEi-Ot7F.js → ingestTypes-B312BwXg.js} +2 -2
- package/dist/{ingestTypes-CEi-Ot7F.js.map → ingestTypes-B312BwXg.js.map} +1 -1
- package/dist/{ingestTypes-iaiq33Ph.cjs → ingestTypes-B4XfrUoq.cjs} +3 -3
- package/dist/{ingestTypes-iaiq33Ph.cjs.map → ingestTypes-B4XfrUoq.cjs.map} +1 -1
- package/dist/{calendarGrid-CQ8MVSRb.js → itemId-DfTm08jm.js} +34 -2
- package/dist/itemId-DfTm08jm.js.map +1 -0
- package/dist/{calendarGrid-DGILaVxI.cjs → itemId-Dzrr3vs5.cjs} +45 -1
- package/dist/itemId-Dzrr3vs5.cjs.map +1 -0
- package/dist/listen-D5av7GzB.js +22 -0
- package/dist/listen-D5av7GzB.js.map +1 -0
- package/dist/listen-UZA68Upt.cjs +45 -0
- package/dist/listen-UZA68Upt.cjs.map +1 -0
- package/dist/{promptSafety-CpiME8pj.js → promptSafety-CSdyp7RF.js} +2 -2
- package/dist/{promptSafety-CpiME8pj.js.map → promptSafety-CSdyp7RF.js.map} +1 -1
- package/dist/{promptSafety-NNGViiCr.cjs → promptSafety-CWqYq-NS.cjs} +8 -8
- package/dist/{promptSafety-NNGViiCr.cjs.map → promptSafety-CWqYq-NS.cjs.map} +1 -1
- package/dist/remote-host/server/hostRunner.d.ts +2 -4
- package/dist/remote-host/server/index.cjs +4 -19
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +1 -16
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{server-DdHaX_Uu.js → server-BNe8XMtu.js} +677 -6
- package/dist/server-BNe8XMtu.js.map +1 -0
- package/dist/{server-9mFaGLdq.cjs → server-CgkkWWBd.cjs} +807 -22
- package/dist/server-CgkkWWBd.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/calendarGrid-CQ8MVSRb.js.map +0 -1
- package/dist/calendarGrid-DGILaVxI.cjs.map +0 -1
- package/dist/collection/core/shortHexId.d.ts +0 -8
- package/dist/discovery-CdkaURVY.js.map +0 -1
- package/dist/discovery-Cw5xWzZD.cjs.map +0 -1
- package/dist/server-9mFaGLdq.cjs.map +0 -1
- package/dist/server-DdHaX_Uu.js.map +0 -1
|
@@ -1,15 +1,674 @@
|
|
|
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 {
|
|
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-
|
|
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,
|
|
4
|
+
import { B as fieldText, I as embedTargetId, L as isFieldDrivenEvery, N as COMPUTED_TYPES, V as fieldTextOrNull, d as parseIsoDate, f as parseIsoDateTime, x as isValidCollectionName, z as storageKindFor } from "./itemId-DfTm08jm.js";
|
|
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, I as parseAppManifest, 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-B9zdNkgW.js";
|
|
7
7
|
import { ingestStatePath } from "./feeds/paths.js";
|
|
8
8
|
import { mirrorSkillWrite } from "./skill-bridge/index.js";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
11
11
|
import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
+
//#region src/collection/server/publishManifest.ts
|
|
14
|
+
/** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
|
|
15
|
+
* that `sharedCollectionKey` applies. Stated once so a path built later
|
|
16
|
+
* cannot be a way around it. */
|
|
17
|
+
var NameZ = z.string().refine(isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
|
|
18
|
+
/** An address on the roster. Not validated as an email beyond "has an @":
|
|
19
|
+
* the rules compare it to `request.auth.token.email` verbatim, so any
|
|
20
|
+
* narrowing here would refuse addresses Firebase itself accepts. */
|
|
21
|
+
var EmailZ = z.string().trim().min(3).includes("@");
|
|
22
|
+
/** The four roles the deployed rules understand. `participant` is the layer
|
|
23
|
+
* that is NAMED but reads only its own rows — see `readerOf` vs `listedIn`. */
|
|
24
|
+
var APP_ROLES = [
|
|
25
|
+
"owner",
|
|
26
|
+
"editor",
|
|
27
|
+
"viewer",
|
|
28
|
+
"participant"
|
|
29
|
+
];
|
|
30
|
+
var RoleZ = z.enum(APP_ROLES);
|
|
31
|
+
/** `{ email: { "*" | cid: role } }`. The `"*"` key is the app-wide role; a
|
|
32
|
+
* member may hold per-collection roles only (the stylist who is editor of
|
|
33
|
+
* bookings and viewer of everything else). */
|
|
34
|
+
var MembersZ = z.record(EmailZ, z.record(z.union([z.literal("*"), NameZ]), RoleZ));
|
|
35
|
+
/** The declarative mail queue, as the rules re-derive it: a transition of the
|
|
36
|
+
* status field, a recipient read off the RECORD, and a fixed template. */
|
|
37
|
+
var MailZ = z.object({
|
|
38
|
+
toField: z.string().trim().min(1),
|
|
39
|
+
on: z.record(z.string().trim().min(1), z.object({
|
|
40
|
+
from: z.array(z.string().trim().min(1)).min(1),
|
|
41
|
+
to: z.string().trim().min(1)
|
|
42
|
+
}).strict()),
|
|
43
|
+
dataFields: z.array(z.string().trim().min(1)).optional()
|
|
44
|
+
}).strict();
|
|
45
|
+
/** What the rules read out of `collections[cid]`. NOT the schema — the schema
|
|
46
|
+
* is published beside it, untouched, for clients to render from. */
|
|
47
|
+
var CollectionConfigZ = z.object({
|
|
48
|
+
statusField: z.string().trim().min(1).optional(),
|
|
49
|
+
/** `{ initial: [...], <status>: [<status>...] }`. Binds writers too, and
|
|
50
|
+
* binds `create` — that is the point of publishing it. */
|
|
51
|
+
transitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
52
|
+
immutable: z.boolean().optional(),
|
|
53
|
+
submitOnly: z.boolean().optional(),
|
|
54
|
+
peerVisibility: z.enum(["public", "hidden"]).optional(),
|
|
55
|
+
revealGated: z.boolean().optional(),
|
|
56
|
+
gatedFrom: NameZ.optional(),
|
|
57
|
+
revealBy: z.string().trim().min(1).optional(),
|
|
58
|
+
mail: MailZ.optional(),
|
|
59
|
+
/** Which fields an aggregate groups by. Declared here rather than in the
|
|
60
|
+
* schema for the same reason as everything else in this file — the schema
|
|
61
|
+
* has no `aggregate` key yet — and it is here at all because the
|
|
62
|
+
* invariant that guards it ("every aggregation key is a CHECKED field")
|
|
63
|
+
* is about `public.submit`, which is an app-level declaration. Published
|
|
64
|
+
* as-is; the rules never read it. */
|
|
65
|
+
aggregate: z.object({ by: z.array(z.string().trim().min(1)).min(1) }).strict().optional()
|
|
66
|
+
}).strict();
|
|
67
|
+
/** An authored submit window. ISO strings, because `app.json` is JSON and a
|
|
68
|
+
* Firestore `Timestamp` has no JSON form. Publish lowers it to epoch millis —
|
|
69
|
+
* the rules do not coerce strings, so an ISO string reaching Firestore is a
|
|
70
|
+
* type error that fails CLOSED (`inWindow` refuses every submission and the
|
|
71
|
+
* author sees "nobody can submit", not an error). */
|
|
72
|
+
var WindowZ = z.object({
|
|
73
|
+
from: z.iso.datetime().optional(),
|
|
74
|
+
until: z.iso.datetime().optional()
|
|
75
|
+
}).strict();
|
|
76
|
+
var ValidateZ = z.object({
|
|
77
|
+
required: z.array(z.string().trim().min(1)).optional(),
|
|
78
|
+
/** Capped at two by the rules themselves: rules have no iteration, so
|
|
79
|
+
* `keyFieldsOk` is unrolled. A third would be accepted here and silently
|
|
80
|
+
* unchecked there. */
|
|
81
|
+
keyFields: z.array(z.object({
|
|
82
|
+
field: z.string().trim().min(1),
|
|
83
|
+
values: z.array(z.union([
|
|
84
|
+
z.string(),
|
|
85
|
+
z.number(),
|
|
86
|
+
z.boolean()
|
|
87
|
+
])).min(1)
|
|
88
|
+
}).strict()).optional()
|
|
89
|
+
}).strict();
|
|
90
|
+
var SubmitZ = z.object({
|
|
91
|
+
auth: z.enum([
|
|
92
|
+
"none",
|
|
93
|
+
"anonymous",
|
|
94
|
+
"verifiedEmail"
|
|
95
|
+
]),
|
|
96
|
+
emailField: z.string().trim().min(1).optional(),
|
|
97
|
+
createFields: z.array(z.string().trim().min(1)).min(1),
|
|
98
|
+
initialStatus: z.string().trim().min(1).optional(),
|
|
99
|
+
idFrom: z.enum([
|
|
100
|
+
"auto",
|
|
101
|
+
"auth.uid",
|
|
102
|
+
"auth.uid+field"
|
|
103
|
+
]).optional(),
|
|
104
|
+
idField: z.string().trim().min(1).optional(),
|
|
105
|
+
validate: ValidateZ.optional(),
|
|
106
|
+
window: WindowZ.optional(),
|
|
107
|
+
/** Per CURRENT STATUS, never a flat list: a flat list lets a customer move
|
|
108
|
+
* an approved booking's `startAt` without anyone re-approving it. */
|
|
109
|
+
selfUpdate: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
110
|
+
selfTransitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
111
|
+
finalize: z.boolean().optional(),
|
|
112
|
+
audience: z.literal("participant").optional(),
|
|
113
|
+
gateOn: z.object({
|
|
114
|
+
phase: z.string().trim().min(1),
|
|
115
|
+
match: z.string().trim().min(1)
|
|
116
|
+
}).strict().optional()
|
|
117
|
+
}).strict();
|
|
118
|
+
var PublicZ = z.object({
|
|
119
|
+
/** The master switch. Anonymous submission (`auth: "none"`) needs it as
|
|
120
|
+
* well as its own declaration. */
|
|
121
|
+
enabled: z.boolean().optional(),
|
|
122
|
+
read: z.array(NameZ).optional(),
|
|
123
|
+
submit: z.record(NameZ, SubmitZ).optional()
|
|
124
|
+
}).strict();
|
|
125
|
+
/** The whole authored declaration.
|
|
126
|
+
*
|
|
127
|
+
* `owner` is accepted but is NOT the published value — publish stamps the
|
|
128
|
+
* publisher's uid (or carries the existing one forward, which is what the
|
|
129
|
+
* rules require on update) and refuses a declaration that disagrees. It is
|
|
130
|
+
* accepted rather than banned because the sample app.json in the design note
|
|
131
|
+
* shows it, and a hard refusal on a key the samples contain would be a worse
|
|
132
|
+
* first experience than a message naming the mismatch. */
|
|
133
|
+
var AuthoredAppZ = z.object({
|
|
134
|
+
aid: NameZ,
|
|
135
|
+
name: z.string().trim().min(1).optional(),
|
|
136
|
+
/** Per-worktree app id (design D6, implementation order 7). Accepted so a
|
|
137
|
+
* repository already carrying it parses; nothing reads it yet. */
|
|
138
|
+
aidEnv: z.string().trim().min(1).optional(),
|
|
139
|
+
owner: z.string().trim().min(1).optional(),
|
|
140
|
+
members: MembersZ,
|
|
141
|
+
collections: z.record(NameZ, CollectionConfigZ).optional(),
|
|
142
|
+
participantRead: z.array(NameZ).optional(),
|
|
143
|
+
public: PublicZ.optional()
|
|
144
|
+
}).strict();
|
|
145
|
+
/** Parse the authored declaration out of `app.json`'s text.
|
|
146
|
+
*
|
|
147
|
+
* Returns a LIST of problems rather than throwing, for the same reason
|
|
148
|
+
* `loadAppManifest` returns a failure: the caller is a gate whose entire job
|
|
149
|
+
* is to hand the author something to act on. Every problem is reported at
|
|
150
|
+
* once — publish is a manual step, and a parser that stops at the first key
|
|
151
|
+
* makes it N round trips. */
|
|
152
|
+
function parseAuthoredApp(raw) {
|
|
153
|
+
const manifest = parseAppManifest(raw);
|
|
154
|
+
if (!manifest.ok) return {
|
|
155
|
+
ok: false,
|
|
156
|
+
problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
|
|
157
|
+
};
|
|
158
|
+
const parsed = AuthoredAppZ.safeParse(JSON.parse(raw));
|
|
159
|
+
if (!parsed.success) return {
|
|
160
|
+
ok: false,
|
|
161
|
+
problems: authoredProblems(parsed.error)
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
app: parsed.data
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** zod issues as one actionable line each: `public.submit.responses.auth: …`. */
|
|
169
|
+
function authoredProblems(error) {
|
|
170
|
+
return error.issues.map((issue) => {
|
|
171
|
+
return `${issue.path.length > 0 ? issue.path.join(".") : "app.json"}: ${issue.message}`;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/collection/server/publishProject.ts
|
|
176
|
+
/** The document id under `apps/{aid}/config`. One document, named, rather than
|
|
177
|
+
* a spread of them: a second public document is a second thing to keep in
|
|
178
|
+
* step, and nothing yet needs one. */
|
|
179
|
+
var PUBLIC_CONFIG_DOC = "public";
|
|
180
|
+
/** Drop keys whose value is `undefined`. Firestore rejects an undefined field
|
|
181
|
+
* value outright, and `"k" in c` — which every optional key in the rules is
|
|
182
|
+
* read through — must mean "the author declared it". */
|
|
183
|
+
function compact(entries) {
|
|
184
|
+
return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== void 0));
|
|
185
|
+
}
|
|
186
|
+
/** ISO → epoch millis, the one conversion the rules cannot do for themselves.
|
|
187
|
+
* The caller has already refused an unparseable string (the authored parser
|
|
188
|
+
* requires `z.iso.datetime()`), so a NaN here would be a programming error;
|
|
189
|
+
* it is still checked, because a NaN written to Firestore fails closed in the
|
|
190
|
+
* same silent way an ISO string does. */
|
|
191
|
+
function windowMillis(window) {
|
|
192
|
+
if (!window) return void 0;
|
|
193
|
+
const out = {};
|
|
194
|
+
if (window.from !== void 0) out.fromMs = Date.parse(window.from);
|
|
195
|
+
if (window.until !== void 0) out.untilMs = Date.parse(window.until);
|
|
196
|
+
if (Object.values(out).some((value) => !Number.isFinite(value))) throw new Error(`publish: window bound is not a parseable timestamp (${JSON.stringify(window)})`);
|
|
197
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
198
|
+
}
|
|
199
|
+
/** One `public.submit[cid]`, with its window lowered. Everything else passes
|
|
200
|
+
* through: the rules read these keys by the names the author wrote. */
|
|
201
|
+
function projectSubmit(submit) {
|
|
202
|
+
const { window, ...rest } = submit;
|
|
203
|
+
return compact({
|
|
204
|
+
...rest,
|
|
205
|
+
window: windowMillis(window)
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** The roster's addresses as a set, in a stable order.
|
|
209
|
+
*
|
|
210
|
+
* Sorted so two publishes of the same declaration produce the same document
|
|
211
|
+
* — idempotence is a property this step is tested for, and Firestore compares
|
|
212
|
+
* arrays by ORDER. `membersConsistent()` compares as sets and would accept
|
|
213
|
+
* any order; the test that would notice is the one asserting a second publish
|
|
214
|
+
* changes nothing but `publishedAt`. */
|
|
215
|
+
function memberEmailsOf(members) {
|
|
216
|
+
return Object.keys(members).sort();
|
|
217
|
+
}
|
|
218
|
+
/** The previous document, kept for rollback, with its OWN `previousPublished`
|
|
219
|
+
* stripped.
|
|
220
|
+
*
|
|
221
|
+
* One level, deliberately. Chaining would make every publish carry the entire
|
|
222
|
+
* history of the app inside a single document, which grows without bound and
|
|
223
|
+
* meets Firestore's 1 MiB document limit as a permission-shaped failure at
|
|
224
|
+
* some unpredictable publish. One level answers the question rollback
|
|
225
|
+
* actually asks — "put back what was there before I broke it" — and the
|
|
226
|
+
* further history is in git, which is where the declaration came from. */
|
|
227
|
+
function previousOf(existing) {
|
|
228
|
+
if (!existing) return void 0;
|
|
229
|
+
const { previousPublished: __dropped, ...rest } = existing;
|
|
230
|
+
return rest;
|
|
231
|
+
}
|
|
232
|
+
/** Project the authored declaration into the documents publish writes.
|
|
233
|
+
*
|
|
234
|
+
* `existing` is the app document as it is in Firestore right now, or null on
|
|
235
|
+
* a first publish. Two things need it, both required by the rules:
|
|
236
|
+
* - `owner` must be UNCHANGED on update. Re-stamping the publisher's uid
|
|
237
|
+
* would be refused for any app whose owner ever signed in as a different
|
|
238
|
+
* account, and would silently transfer ownership if it were not.
|
|
239
|
+
* - `previousPublished` is that document, so a rollback has something to
|
|
240
|
+
* put back.
|
|
241
|
+
*
|
|
242
|
+
* Pure: no clock, no filesystem, no Firestore. Everything variable arrives as
|
|
243
|
+
* a parameter, which is what makes the conversion table testable as a table. */
|
|
244
|
+
function projectApp(authored, schemas, stamp, existing) {
|
|
245
|
+
const owner = typeof existing?.owner === "string" ? existing.owner : stamp.uid;
|
|
246
|
+
const submit = Object.fromEntries(Object.entries(authored.public?.submit ?? {}).map(([cid, spec]) => [cid, projectSubmit(spec)]));
|
|
247
|
+
const publicBlock = authored.public ? compact({
|
|
248
|
+
enabled: authored.public.enabled,
|
|
249
|
+
read: authored.public.read,
|
|
250
|
+
submit: Object.keys(submit).length > 0 ? submit : void 0
|
|
251
|
+
}) : void 0;
|
|
252
|
+
const app = compact({
|
|
253
|
+
aid: authored.aid,
|
|
254
|
+
name: authored.name,
|
|
255
|
+
owner,
|
|
256
|
+
members: authored.members,
|
|
257
|
+
memberEmails: memberEmailsOf(authored.members),
|
|
258
|
+
collections: authored.collections,
|
|
259
|
+
participantRead: authored.participantRead,
|
|
260
|
+
public: publicBlock,
|
|
261
|
+
publishedAt: stamp.publishedAt,
|
|
262
|
+
publishedBy: stamp.email,
|
|
263
|
+
publishedCommit: stamp.commit,
|
|
264
|
+
publishedDirty: stamp.dirty === true ? true : void 0,
|
|
265
|
+
previousPublished: previousOf(existing)
|
|
266
|
+
});
|
|
267
|
+
const config = {
|
|
268
|
+
enabled: authored.public?.enabled === true,
|
|
269
|
+
read: authored.public?.read ?? [],
|
|
270
|
+
submit,
|
|
271
|
+
publishedAt: stamp.publishedAt
|
|
272
|
+
};
|
|
273
|
+
if (authored.name !== void 0) config.name = authored.name;
|
|
274
|
+
return {
|
|
275
|
+
app,
|
|
276
|
+
schemas: schemas.map(({ cid, schema }) => ({
|
|
277
|
+
cid,
|
|
278
|
+
doc: schemaDoc(schema, stamp)
|
|
279
|
+
})),
|
|
280
|
+
config
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Keys on the app document that publish owns: what is PUBLIC right now, and
|
|
284
|
+
* the rule-facing configuration anonymous access is judged against. Deploy
|
|
285
|
+
* carries them through from the existing document and never authors them. */
|
|
286
|
+
var PUBLISH_OWNED_KEYS = [
|
|
287
|
+
"public",
|
|
288
|
+
"collections",
|
|
289
|
+
"participantRead",
|
|
290
|
+
"publishedAt",
|
|
291
|
+
"publishedBy",
|
|
292
|
+
"publishedCommit",
|
|
293
|
+
"publishedDirty",
|
|
294
|
+
"previousPublished"
|
|
295
|
+
];
|
|
296
|
+
var isPublishOwned = (key) => PUBLISH_OWNED_KEYS.includes(key);
|
|
297
|
+
function projectDeploy(authored, schemas, stamp, existing) {
|
|
298
|
+
const { app } = projectApp(authored, schemas, stamp, existing);
|
|
299
|
+
const deployed = Object.fromEntries(Object.entries(app).filter(([key]) => !isPublishOwned(key)));
|
|
300
|
+
for (const key of PUBLISH_OWNED_KEYS) {
|
|
301
|
+
const live = existing?.[key];
|
|
302
|
+
if (live !== void 0) deployed[key] = live;
|
|
303
|
+
}
|
|
304
|
+
deployed.deployedAt = stamp.publishedAt;
|
|
305
|
+
deployed.deployedBy = stamp.email;
|
|
306
|
+
if (stamp.commit !== void 0) deployed.deployedCommit = stamp.commit;
|
|
307
|
+
return {
|
|
308
|
+
app: deployed,
|
|
309
|
+
staging: schemas.map(({ cid, schema }) => ({
|
|
310
|
+
cid,
|
|
311
|
+
doc: stagedDoc(schema, stamp, authored, cid)
|
|
312
|
+
}))
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/** One staged schema document, carrying this cid's rule-facing configuration
|
|
316
|
+
* alongside the schema so publish can promote them together. */
|
|
317
|
+
function stagedDoc(schema, stamp, authored, cid) {
|
|
318
|
+
const doc = {
|
|
319
|
+
publishedSchema: schema,
|
|
320
|
+
deployedAt: stamp.publishedAt,
|
|
321
|
+
deployedBy: stamp.email
|
|
322
|
+
};
|
|
323
|
+
const config = authored.collections?.[cid];
|
|
324
|
+
if (config !== void 0) doc.config = config;
|
|
325
|
+
if (authored.participantRead?.includes(cid) === true) doc.participantRead = true;
|
|
326
|
+
if (stamp.commit !== void 0) doc.deployedCommit = stamp.commit;
|
|
327
|
+
return doc;
|
|
328
|
+
}
|
|
329
|
+
/** The rule-facing configuration to promote, read from the STAGED documents
|
|
330
|
+
* rather than from the manifest as it reads right now.
|
|
331
|
+
*
|
|
332
|
+
* Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
|
|
333
|
+
* the promoted schema is A's while the authorization behaviour is B's, a
|
|
334
|
+
* combination nobody exercised through `/staging/{aid}`.
|
|
335
|
+
*
|
|
336
|
+
* (`public` is deliberately NOT part of this: it is not staged, because it is
|
|
337
|
+
* the decision being made AT publish rather than something under test.) */
|
|
338
|
+
function stagedRuleConfig(staged) {
|
|
339
|
+
const entries = [];
|
|
340
|
+
const participantRead = [];
|
|
341
|
+
for (const entry of staged) {
|
|
342
|
+
const { config } = entry.doc;
|
|
343
|
+
if (config !== void 0) entries.push([entry.cid, config]);
|
|
344
|
+
if (entry.doc.participantRead === true) participantRead.push(entry.cid);
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
collections: entries.length > 0 ? Object.fromEntries(entries) : void 0,
|
|
348
|
+
participantRead: participantRead.length > 0 ? participantRead : void 0
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function projectPublish(authored, staged, stamp, existing) {
|
|
352
|
+
const { app, config } = projectApp(authored, [], stamp, existing);
|
|
353
|
+
const staging = stagedRuleConfig(staged);
|
|
354
|
+
app.collections = staging.collections;
|
|
355
|
+
app.participantRead = staging.participantRead;
|
|
356
|
+
const published = Object.fromEntries(Object.entries(existing ?? app).filter(([key]) => !isPublishOwned(key)));
|
|
357
|
+
for (const key of PUBLISH_OWNED_KEYS) if (key !== "public" && app[key] !== void 0) published[key] = app[key];
|
|
358
|
+
const publicBlock = app.public;
|
|
359
|
+
return {
|
|
360
|
+
app: published,
|
|
361
|
+
config,
|
|
362
|
+
public: isRecord(publicBlock) ? publicBlock : void 0
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
|
|
366
|
+
*
|
|
367
|
+
* The stamp answers "which version is PUBLIC right now, and who made it so",
|
|
368
|
+
* so it is written by the operation that changes the answer — publish — not
|
|
369
|
+
* carried over from the deploy that staged it. */
|
|
370
|
+
function promoteSchema(staged, stamp) {
|
|
371
|
+
const doc = {
|
|
372
|
+
publishedSchema: staged.publishedSchema,
|
|
373
|
+
publishedAt: stamp.publishedAt,
|
|
374
|
+
publishedBy: stamp.email
|
|
375
|
+
};
|
|
376
|
+
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
377
|
+
return doc;
|
|
378
|
+
}
|
|
379
|
+
/** One published schema document. Written key by key rather than through
|
|
380
|
+
* `compact`, so the declared type is the type — an optional commit is the
|
|
381
|
+
* only variable part. */
|
|
382
|
+
function schemaDoc(schema, stamp) {
|
|
383
|
+
const doc = {
|
|
384
|
+
publishedSchema: schema,
|
|
385
|
+
publishedAt: stamp.publishedAt,
|
|
386
|
+
publishedBy: stamp.email
|
|
387
|
+
};
|
|
388
|
+
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
389
|
+
return doc;
|
|
390
|
+
}
|
|
391
|
+
/** The app documents' parent path — the `FirestoreDocs` seam takes a
|
|
392
|
+
* collection path plus a document id, and the app document's id is the aid. */
|
|
393
|
+
var APPS_COLLECTION = "apps";
|
|
394
|
+
/** The collection (schema) documents' parent path — what the PUBLIC page
|
|
395
|
+
* reads, written only by publish (promotion). */
|
|
396
|
+
var appSchemasPath = (aid) => `apps/${aid}/collections`;
|
|
397
|
+
/** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
|
|
398
|
+
*
|
|
399
|
+
* A TOP-LEVEL collection, not a field on the app: the public page resolves a
|
|
400
|
+
* slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
|
|
401
|
+
* has to be claimable atomically (create-if-absent) so two apps cannot hold
|
|
402
|
+
* the same URL.
|
|
403
|
+
*
|
|
404
|
+
* `published` is what makes the reservation invisible until publish. The slug
|
|
405
|
+
* is human-readable, so a readable reservation would let anyone guess the URL
|
|
406
|
+
* and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
|
|
407
|
+
* `allow read: if resource.data.published == true`, which needs no `get()` and
|
|
408
|
+
* so costs nothing against the rules' expression budget. */
|
|
409
|
+
var APP_SLUGS_COLLECTION = "appSlugs";
|
|
410
|
+
var appSlugDoc = (aid, published) => ({
|
|
411
|
+
aid,
|
|
412
|
+
published
|
|
413
|
+
});
|
|
414
|
+
/** The staged schema documents' parent path — what `/staging/{aid}` reads,
|
|
415
|
+
* written by deploy. A separate DOCUMENT rather than a field beside
|
|
416
|
+
* `publishedSchema`, because the rules cannot hide a field: anything inside a
|
|
417
|
+
* document the public page may read is public. */
|
|
418
|
+
var appStagingPath = (aid) => `apps/${aid}/staging`;
|
|
419
|
+
/** The public-config documents' parent path. */
|
|
420
|
+
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/collection/server/publishChecks.ts
|
|
423
|
+
/** Does this submit declaration bind a record to the submitter's identity?
|
|
424
|
+
*
|
|
425
|
+
* The condition for requiring `submitOnly`, and deliberately NOT "declares an
|
|
426
|
+
* `audience`": `audience` appears only in the rules' public-create branch, so
|
|
427
|
+
* an owner or editor never meets it and can add records freely. `immutable`
|
|
428
|
+
* is the wrong condition too — a survey's responses are not immutable and
|
|
429
|
+
* can be padded exactly the same way.
|
|
430
|
+
*
|
|
431
|
+
* What these four have in common is that each one makes the record MEAN "the
|
|
432
|
+
* person who submitted it said this": a per-uid id, a per-uid+field id, a
|
|
433
|
+
* row stamped with the submitter's verified address, or a submission
|
|
434
|
+
* restricted to a named participant. A record created through the writer
|
|
435
|
+
* branch carries the same shape and none of that meaning. */
|
|
436
|
+
function bindsSubmitterIdentity(submit) {
|
|
437
|
+
return submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field" || submit.emailField !== void 0 || submit.audience === "participant";
|
|
438
|
+
}
|
|
439
|
+
/** The fields a rule actually CHECKS the value of, for one collection.
|
|
440
|
+
*
|
|
441
|
+
* `keyFields` pins a value against a declared set, `gateOn.match` pins it
|
|
442
|
+
* against the session's current question, and the status field is pinned by
|
|
443
|
+
* the transition machine. An aggregation grouped by anything else is grouped
|
|
444
|
+
* by a field any submitter may write anything into — so the published
|
|
445
|
+
* aggregate is whatever the noisiest respondent decided it should be. */
|
|
446
|
+
function checkedFields(collection, submit) {
|
|
447
|
+
const fields = /* @__PURE__ */ new Set();
|
|
448
|
+
for (const keyField of submit?.validate?.keyFields ?? []) fields.add(keyField.field);
|
|
449
|
+
if (submit?.gateOn) fields.add(submit.gateOn.match);
|
|
450
|
+
if (collection?.statusField) fields.add(collection.statusField);
|
|
451
|
+
return fields;
|
|
452
|
+
}
|
|
453
|
+
/** INVARIANT 1 — a submission bound to its submitter needs `submitOnly`. */
|
|
454
|
+
function submitOnlyProblems(app) {
|
|
455
|
+
const problems = [];
|
|
456
|
+
for (const [cid, submit] of Object.entries(app.public?.submit ?? {})) {
|
|
457
|
+
if (!bindsSubmitterIdentity(submit)) continue;
|
|
458
|
+
if (app.collections?.[cid]?.submitOnly === true) continue;
|
|
459
|
+
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}.`);
|
|
460
|
+
}
|
|
461
|
+
return problems;
|
|
462
|
+
}
|
|
463
|
+
function identityBindings(submit) {
|
|
464
|
+
const bindings = [];
|
|
465
|
+
if (submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field") bindings.push(`idFrom: "${submit.idFrom}"`);
|
|
466
|
+
if (submit.emailField !== void 0) bindings.push(`emailField: "${submit.emailField}"`);
|
|
467
|
+
if (submit.audience === "participant") bindings.push(`audience: "participant"`);
|
|
468
|
+
return bindings;
|
|
469
|
+
}
|
|
470
|
+
/** INVARIANT 2 — every aggregation key is a field some rule checks. */
|
|
471
|
+
function aggregateProblems(app) {
|
|
472
|
+
const problems = [];
|
|
473
|
+
for (const [cid, collection] of Object.entries(app.collections ?? {})) {
|
|
474
|
+
const keys = collection.aggregate?.by;
|
|
475
|
+
if (!keys) continue;
|
|
476
|
+
const checked = checkedFields(collection, app.public?.submit?.[cid]);
|
|
477
|
+
const loose = keys.filter((field) => !checked.has(field));
|
|
478
|
+
const spelled = loose.map((field) => `'${field}'`).join(", ");
|
|
479
|
+
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.`);
|
|
480
|
+
}
|
|
481
|
+
return problems;
|
|
482
|
+
}
|
|
483
|
+
/** INVARIANT 3 — `auth: "verifiedEmail"` only.
|
|
484
|
+
*
|
|
485
|
+
* A product decision, not a rules limitation: the rules keep all three stages
|
|
486
|
+
* and the emulator tests keep exercising them, because deleting a stage from
|
|
487
|
+
* the rules turns a change of mind into a cross-repo deploy. Publish is where
|
|
488
|
+
* the current decision is expressed, and it is one line to move. */
|
|
489
|
+
function authProblems(app) {
|
|
490
|
+
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.`);
|
|
491
|
+
}
|
|
492
|
+
/** INVARIANT 5 — a mail transition's origins and destination must be disjoint.
|
|
493
|
+
*
|
|
494
|
+
* Overlap means the same write can satisfy the same template twice over, and
|
|
495
|
+
* the deterministic mail id is the only other thing stopping a duplicate
|
|
496
|
+
* send. The rules also require the status to have CHANGED, so an overlapping
|
|
497
|
+
* declaration is not merely redundant: `from` containing `to` is a transition
|
|
498
|
+
* that can never fire, which is a mail nobody ever receives. */
|
|
499
|
+
function mailProblems(app) {
|
|
500
|
+
return Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => collectionMailProblems(cid, collection));
|
|
501
|
+
}
|
|
502
|
+
function collectionMailProblems(cid, collection) {
|
|
503
|
+
const { mail } = collection;
|
|
504
|
+
if (!mail) return [];
|
|
505
|
+
const problems = [];
|
|
506
|
+
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.`);
|
|
507
|
+
for (const [template, transition] of Object.entries(mail.on)) problems.push(...templateMailProblems(cid, collection, template, transition));
|
|
508
|
+
return problems;
|
|
509
|
+
}
|
|
510
|
+
function templateMailProblems(cid, collection, template, transition) {
|
|
511
|
+
const problems = [];
|
|
512
|
+
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.`);
|
|
513
|
+
const allowed = collection.transitions;
|
|
514
|
+
if (allowed) {
|
|
515
|
+
const unreachable = transition.from.filter((from) => !(allowed[from] ?? []).includes(transition.to));
|
|
516
|
+
const spelled = unreachable.map((from) => `'${from}' -> '${transition.to}'`).join(", ");
|
|
517
|
+
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.`);
|
|
518
|
+
}
|
|
519
|
+
return problems;
|
|
520
|
+
}
|
|
521
|
+
/** INVARIANTS 6 and 7 — the window is a real interval, and `keyFields` fits
|
|
522
|
+
* the unrolled check in the rules. */
|
|
523
|
+
function submitShapeProblems(app) {
|
|
524
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => [...windowProblems(cid, submit), ...keyFieldCountProblems(cid, submit)]);
|
|
525
|
+
}
|
|
526
|
+
function windowProblems(cid, submit) {
|
|
527
|
+
const { window } = submit;
|
|
528
|
+
if (window?.from === void 0 || window.until === void 0) return [];
|
|
529
|
+
if (Date.parse(window.until) > Date.parse(window.from)) return [];
|
|
530
|
+
return [`public.submit.${cid}.window closes at or before it opens (${window.from} -> ${window.until}): nothing could ever be submitted.`];
|
|
531
|
+
}
|
|
532
|
+
function keyFieldCountProblems(cid, submit) {
|
|
533
|
+
const keyFields = submit.validate?.keyFields ?? [];
|
|
534
|
+
if (keyFields.length <= 2) return [];
|
|
535
|
+
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.`];
|
|
536
|
+
}
|
|
537
|
+
/** The fail-closed traps: declarations the rules read together, where the
|
|
538
|
+
* missing half denies every write instead of loosening one. */
|
|
539
|
+
function coherenceProblems(app) {
|
|
540
|
+
const fromSubmits = Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitCoherenceProblems(app, cid, submit));
|
|
541
|
+
const fromCollections = Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => gateCoherenceProblems(cid, collection));
|
|
542
|
+
return [...fromSubmits, ...fromCollections];
|
|
543
|
+
}
|
|
544
|
+
/** `initialStatus` is read together with the collection's `statusField` and
|
|
545
|
+
* with `createFields`; miss either and every submission is refused. */
|
|
546
|
+
function statusCoherenceProblems(cid, submit, collection) {
|
|
547
|
+
if (submit.initialStatus === void 0) return [];
|
|
548
|
+
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.`];
|
|
549
|
+
if (new Set(submit.createFields).has(collection.statusField)) return [];
|
|
550
|
+
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.`];
|
|
551
|
+
}
|
|
552
|
+
/** Every field a RULE reads off a submitted record, other than the status
|
|
553
|
+
* field (which `statusCoherenceProblems` words for itself).
|
|
554
|
+
*
|
|
555
|
+
* `emailField` and `idField` belong here for exactly the reason `required`
|
|
556
|
+
* and `keyFields` do, and forgetting them was the same oversight twice: the
|
|
557
|
+
* rules read `request.resource.data[s.emailField]` and rebuild the document
|
|
558
|
+
* id from `s.idField`, while `hasOnly(createFields)` decides what a
|
|
559
|
+
* submission may carry at all. A field in one list and not the other is a
|
|
560
|
+
* contradiction the submitter cannot resolve — including it is refused,
|
|
561
|
+
* omitting it fails the check. */
|
|
562
|
+
function ruleReadFields(submit) {
|
|
563
|
+
const fields = [];
|
|
564
|
+
if (submit.emailField !== void 0) fields.push({
|
|
565
|
+
field: submit.emailField,
|
|
566
|
+
why: `public.submit.<cid>.emailField — the rules compare it to the submitter's verified address`
|
|
567
|
+
});
|
|
568
|
+
if (submit.idFrom === "auth.uid+field" && submit.idField !== void 0) fields.push({
|
|
569
|
+
field: submit.idField,
|
|
570
|
+
why: `public.submit.<cid>.idField — the rules rebuild the document id from it`
|
|
571
|
+
});
|
|
572
|
+
return fields;
|
|
573
|
+
}
|
|
574
|
+
/** A checked field a submission is not allowed to carry can never be
|
|
575
|
+
* satisfied: carrying it fails `hasOnly`, omitting it fails the check. */
|
|
576
|
+
function createFieldProblems(cid, submit) {
|
|
577
|
+
const createFields = new Set(submit.createFields);
|
|
578
|
+
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.`);
|
|
579
|
+
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.`);
|
|
580
|
+
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.`);
|
|
581
|
+
return [
|
|
582
|
+
...ruleRead,
|
|
583
|
+
...required,
|
|
584
|
+
...keyFields
|
|
585
|
+
];
|
|
586
|
+
}
|
|
587
|
+
function submitCoherenceProblems(app, cid, submit) {
|
|
588
|
+
const collection = app.collections?.[cid];
|
|
589
|
+
const problems = [...statusCoherenceProblems(cid, submit, collection), ...createFieldProblems(cid, submit)];
|
|
590
|
+
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.`);
|
|
591
|
+
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.`);
|
|
592
|
+
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.`);
|
|
593
|
+
return problems;
|
|
594
|
+
}
|
|
595
|
+
/** The staged reveal reads its flag off the PARENT record, so the path to that
|
|
596
|
+
* parent is not optional decoration — without it the gate never opens. */
|
|
597
|
+
function gateCoherenceProblems(cid, collection) {
|
|
598
|
+
if (collection.revealGated !== true) return [];
|
|
599
|
+
if (collection.gatedFrom !== void 0 && collection.revealBy !== void 0) return [];
|
|
600
|
+
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.`];
|
|
601
|
+
}
|
|
602
|
+
/** The publisher must be able to write what they are about to write.
|
|
603
|
+
*
|
|
604
|
+
* On a first publish the rules require the creator to name themselves owner,
|
|
605
|
+
* in the roster, under `'*'`. Getting this wrong produces a bare permission
|
|
606
|
+
* error from Firestore with nothing in it about rosters — worth one line
|
|
607
|
+
* here instead. */
|
|
608
|
+
function publisherProblems(app, publisherEmail) {
|
|
609
|
+
if (app.members[publisherEmail]?.["*"] === "owner") return [];
|
|
610
|
+
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.`];
|
|
611
|
+
}
|
|
612
|
+
/** Every cid the declaration mentions must be a collection that exists.
|
|
613
|
+
*
|
|
614
|
+
* A typo'd cid is not an error anywhere else: the app document simply carries
|
|
615
|
+
* a configuration for a collection nobody publishes, and the collection the
|
|
616
|
+
* author meant is published with no configuration at all — i.e. with the
|
|
617
|
+
* status machine and the submit path silently absent. */
|
|
618
|
+
function unknownCidProblems(app, collections) {
|
|
619
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
620
|
+
return [
|
|
621
|
+
["collections", Object.keys(app.collections ?? {})],
|
|
622
|
+
["public.read", app.public?.read ?? []],
|
|
623
|
+
["public.submit", Object.keys(app.public?.submit ?? {})],
|
|
624
|
+
["participantRead", app.participantRead ?? []]
|
|
625
|
+
].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\")"}.`));
|
|
626
|
+
}
|
|
627
|
+
/** Everything publish refuses, as lines the author can act on.
|
|
628
|
+
*
|
|
629
|
+
* All of them, every time. Publish is a manual step with a human waiting on
|
|
630
|
+
* it; stopping at the first problem turns one review into five. */
|
|
631
|
+
function publishProblems(app, collections, publisherEmail) {
|
|
632
|
+
return [
|
|
633
|
+
...unknownCidProblems(app, collections),
|
|
634
|
+
...publisherProblems(app, publisherEmail),
|
|
635
|
+
...submitOnlyProblems(app),
|
|
636
|
+
...aggregateProblems(app),
|
|
637
|
+
...authProblems(app),
|
|
638
|
+
...mailProblems(app),
|
|
639
|
+
...submitShapeProblems(app),
|
|
640
|
+
...coherenceProblems(app),
|
|
641
|
+
...primaryKeyProblems(app, collections)
|
|
642
|
+
];
|
|
643
|
+
}
|
|
644
|
+
/** A public submission must NOT be allowed to name its own primary key.
|
|
645
|
+
*
|
|
646
|
+
* The rules constrain the DOCUMENT ID (`idFrom`) and cannot constrain the
|
|
647
|
+
* value of a field — nothing compares `request.resource.data[primaryKey]`
|
|
648
|
+
* with the path being written. So a submit path that accepts the primary key
|
|
649
|
+
* as a `createField` lets a submitter write at their one permitted document
|
|
650
|
+
* id while CLAIMING another record's identity, or a duplicate.
|
|
651
|
+
*
|
|
652
|
+
* It is refused rather than tolerated because there is nothing for the field
|
|
653
|
+
* to do: `firestoreStore` takes a shared record's identity from the document
|
|
654
|
+
* id and overwrites the field on read, so a submitted value is either equal
|
|
655
|
+
* to the id (noise) or a lie (silently discarded). Publishing a form field
|
|
656
|
+
* whose value is thrown away is worse than not having it — the author will
|
|
657
|
+
* believe submitters choose their ids.
|
|
658
|
+
*
|
|
659
|
+
* This is the second answer to the same question. The first was the reverse —
|
|
660
|
+
* REQUIRE the key, because a record without one was rejected by every reader
|
|
661
|
+
* — and it was right about the symptom and wrong about the cure: the identity
|
|
662
|
+
* belongs to the id the rules can pin, not to a field they cannot. */
|
|
663
|
+
function primaryKeyProblems(app, collections) {
|
|
664
|
+
const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
|
|
665
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
666
|
+
const primaryKey = primaryKeyOf.get(cid);
|
|
667
|
+
if (primaryKey === void 0 || !submit.createFields.includes(primaryKey)) return [];
|
|
668
|
+
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.`];
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
//#endregion
|
|
13
672
|
//#region src/collection/server/skillAssets.ts
|
|
14
673
|
/** Read a collection's custom-view HTML, path-safely. `viewFile` is a
|
|
15
674
|
* schema-validated `views/*.html` path, resolved with realpath containment.
|
|
@@ -469,6 +1128,18 @@ function firstRecordProblem(record, schema, tier) {
|
|
|
469
1128
|
}
|
|
470
1129
|
//#endregion
|
|
471
1130
|
//#region src/collection/server/validate.ts
|
|
1131
|
+
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
1132
|
+
* because a caller that REPORTS a count has to know the count is a floor —
|
|
1133
|
+
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
1134
|
+
var MAX_RECORD_ISSUES = 25;
|
|
1135
|
+
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
1136
|
+
* at all.
|
|
1137
|
+
*
|
|
1138
|
+
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
1139
|
+
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
1140
|
+
* direction that matters: `publish` lets the user override invalid records,
|
|
1141
|
+
* and overriding this one would mean publishing without ever having looked. */
|
|
1142
|
+
var STORE_UNREADABLE = "(store)";
|
|
472
1143
|
var MAX_ISSUES = 25;
|
|
473
1144
|
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
474
1145
|
* ones that won't load or violate the schema. An empty list means every
|
|
@@ -514,7 +1185,7 @@ async function validateStoreRecords(collection, opts) {
|
|
|
514
1185
|
items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
515
1186
|
} catch (err) {
|
|
516
1187
|
return [{
|
|
517
|
-
file:
|
|
1188
|
+
file: STORE_UNREADABLE,
|
|
518
1189
|
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
519
1190
|
}];
|
|
520
1191
|
}
|
|
@@ -2067,6 +2738,6 @@ function makeManageCollectionTool(deps = {}) {
|
|
|
2067
2738
|
};
|
|
2068
2739
|
}
|
|
2069
2740
|
//#endregion
|
|
2070
|
-
export {
|
|
2741
|
+
export { parseAuthoredApp as $, runQueryOverRows as A, APP_SLUGS_COLLECTION as B, STORE_UNREADABLE as C, recordFieldProblem as D, compileRecordZ as E, readCustomViewI18n as F, appStagingPath as G, appConfigPath as H, readSkillTemplate as I, projectPublish as J, projectApp as K, bindsSubmitterIdentity as L, buildCollectionActionSeedPrompt as M, promptPathsFor as N, runCollectionQuery as O, readCustomViewHtml as P, AuthoredAppZ as Q, publishProblems as R, MAX_RECORD_ISSUES as S, validateRecordObject as T, appSchemasPath as U, PUBLIC_CONFIG_DOC as V, appSlugDoc as W, stagedRuleConfig as X, promoteSchema as Y, APP_ROLES as Z, 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, projectDeploy as q, 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, APPS_COLLECTION as z };
|
|
2071
2742
|
|
|
2072
|
-
//# sourceMappingURL=server-
|
|
2743
|
+
//# sourceMappingURL=server-BNe8XMtu.js.map
|