@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.
@@ -1,332 +0,0 @@
1
- import { CollectionSchema } from '../core/schema';
2
- import { AppViewConfigDoc, NormalizedView, ProjectedViewCollection, ProjectedViewWrite, ViewAudience } from './appViews';
3
- import { AuthoredApp, AuthoredCollectionConfig } from './publishManifest';
4
- /** Who and when. Threaded in rather than read from a clock inside the
5
- * projection so the projection stays pure and the tests can assert on an
6
- * exact document. */
7
- export interface PublishStamp {
8
- /** The publisher's Firebase uid. The rules require `owner ==
9
- * request.auth.uid` on CREATE and `owner` unchanged on UPDATE, so this is
10
- * used only when the app document does not exist yet. */
11
- uid: string;
12
- /** The publisher's verified email — the principal the roster is keyed by. */
13
- email: string;
14
- /** Wall-clock millis of this publish. */
15
- publishedAt: number;
16
- /** The git commit the declaration was read from, when the host could
17
- * resolve one. Absent is a normal state (a dirty tree, a repository
18
- * without git), and absent is more honest than a fabricated value. */
19
- commit?: string | undefined;
20
- /** Was the working tree modified when the commit was read?
21
- *
22
- * Recorded as `publishedDirty` on the app document, because a commit that
23
- * does not describe what was published is worse than no commit: it looks
24
- * auditable. Publish-owned like the rest of the `published*` family — a
25
- * deploy must not drop the marker, and a later CLEAN publish must clear it
26
- * rather than inherit it forever. */
27
- dirty?: boolean | undefined;
28
- }
29
- /** One collection's published schema document (`apps/{aid}/collections/{cid}`).
30
- *
31
- * The rules never read `publishedSchema` — `schemaRead` gates the whole
32
- * document and stops there. It is here for the CLIENTS: a member's host
33
- * renders from it, and a public webview has no other way to know the fields.
34
- * The whole schema is published rather than a projection of it, because
35
- * every attempt to guess "the part a view needs" is a guess about a view
36
- * that has not been written yet. */
37
- export interface PublishedSchemaDoc extends Record<string, unknown> {
38
- publishedSchema: CollectionSchema;
39
- publishedAt: number;
40
- publishedBy: string;
41
- publishedCommit?: string;
42
- }
43
- /** A STAGED schema document (`apps/{aid}/staging/{cid}`) — what deploy writes
44
- * and what `/staging/{aid}` renders from.
45
- *
46
- * Its provenance keys are `deployed*`, not `published*`. The two are different
47
- * questions with different answers at almost every moment: `published*` says
48
- * which revision the PUBLIC is looking at, and a deploy must not move it —
49
- * otherwise staging a draft rewrites the recorded public revision (and, with
50
- * `previousPublished`, the thing a rollback would restore) before anyone has
51
- * published anything. */
52
- export interface StagedSchemaDoc extends Record<string, unknown> {
53
- publishedSchema: CollectionSchema;
54
- /** This collection's RULE-FACING configuration (`transitions`, `immutable`,
55
- * `submitOnly`, `peerVisibility`, …) — staged with the schema, not written
56
- * straight onto the app document.
57
- *
58
- * It has to be staged for the same reason the `public` block is: the rules
59
- * read `apps/{aid}.collections[cid]` when they authorize a PUBLIC write, so
60
- * a deploy that landed it would change what anonymous visitors may do
61
- * before anyone published. The cost is that `/staging/{aid}` exercises the
62
- * new schema against the CURRENTLY PUBLISHED rule configuration; that is
63
- * the safe direction to be wrong in. */
64
- config?: AuthoredCollectionConfig;
65
- /** Whether this cid is in `participantRead` — same reason, same treatment. */
66
- participantRead?: boolean;
67
- deployedAt: number;
68
- deployedBy: string;
69
- deployedCommit?: string;
70
- }
71
- /** The public settings document (`apps/{aid}/config/public`).
72
- *
73
- * `allow read: if true` — this is the one document an anonymous visitor can
74
- * read, and it is the reason `apps/{aid}` itself is reader-only: a
75
- * participant reading the app document would see their classmates'
76
- * addresses. So the roster is NOT here, and neither is `owner`. What is here
77
- * is what a public form needs in order to render itself and to tell a visitor
78
- * "this closed yesterday" instead of failing a write with no explanation. */
79
- export interface PublishedConfigDoc extends Record<string, unknown> {
80
- name?: string;
81
- enabled: boolean;
82
- read: string[];
83
- submit: Record<string, Record<string, unknown>>;
84
- /** That the app HAS a published view, and which datasets it asked for.
85
- *
86
- * This is the only place the public page can learn it: the rules' app
87
- * document is reader-only and deliberately carries no view, and the HTML
88
- * itself lands in a SEPARATE document (`config/view`) because a 1 MiB limit
89
- * applies per document. Omitted here, the page has the HTML and no idea
90
- * what to send it — the feature does not work at all.
91
- *
92
- * The authored PATH is deliberately not published. It names a file in the
93
- * author's repository, which the browser cannot use and which nobody should
94
- * be handed on a world-readable document; what the page needs is the
95
- * dataset list, and `publishedAt` beside it is what pins this declaration
96
- * to the HTML published in the same run. */
97
- view?: {
98
- collections: string[];
99
- };
100
- publishedAt: number;
101
- }
102
- /** Everything one publish writes. Separate documents because they live at
103
- * separate paths with separate rules — and in the order given: the app
104
- * document authorizes the other two, so it goes first. */
105
- export interface PublishedApp {
106
- app: Record<string, unknown>;
107
- schemas: {
108
- cid: string;
109
- doc: PublishedSchemaDoc;
110
- }[];
111
- config: PublishedConfigDoc;
112
- }
113
- /** The document id under `apps/{aid}/config`. One document, named, rather than
114
- * a spread of them: a second public document is a second thing to keep in
115
- * step, and nothing yet needs one. */
116
- export declare const PUBLIC_CONFIG_DOC = "public";
117
- /** Project the authored declaration into the documents publish writes.
118
- *
119
- * `existing` is the app document as it is in Firestore right now, or null on
120
- * a first publish. Two things need it, both required by the rules:
121
- * - `owner` must be UNCHANGED on update. Re-stamping the publisher's uid
122
- * would be refused for any app whose owner ever signed in as a different
123
- * account, and would silently transfer ownership if it were not.
124
- * - `previousPublished` is that document, so a rollback has something to
125
- * put back.
126
- *
127
- * Pure: no clock, no filesystem, no Firestore. Everything variable arrives as
128
- * a parameter, which is what makes the conversion table testable as a table. */
129
- export declare function projectApp(authored: AuthoredApp, schemas: {
130
- cid: string;
131
- schema: CollectionSchema;
132
- }[], stamp: PublishStamp, existing: Record<string, unknown> | null): PublishedApp;
133
- /** Everything `deploy` writes — the host's staging half of the split (the
134
- * design's D10).
135
- *
136
- * Two things are deliberately ABSENT from the app document here:
137
- *
138
- * - **`public`**. That block is what the rules read to authorize anonymous
139
- * access (`publicOn` / `subOpen` read `apps/{aid}.public`, never the
140
- * world-readable `config/public`), so writing it on deploy would open the
141
- * app the moment someone deployed to test. It belongs to `publish`.
142
- * - **the published schemas**. They go to `staging/{cid}`, which only the
143
- * roster can read, so a deploy against a LIVE app does not swap the view
144
- * its visitors are looking at.
145
- *
146
- * A host writing this must MERGE the app document rather than replace it:
147
- * a replace would drop a `public` block a previous publish put there and
148
- * silently unpublish the app. */
149
- export interface DeployedApp {
150
- /** The COMPLETE app document. **Write it with `set`, replacing — never with
151
- * `{ merge: true }`.**
152
- *
153
- * A merge cannot DELETE, and every deletion here is a permission change:
154
- * removing `members.<email>` revokes access, and a merge would leave the
155
- * entry live. Worse, the rules require `memberEmails` to equal the keys of
156
- * `members` (`membersConsistent()`), so a merged member-removal is rejected
157
- * outright — a deploy that silently does nothing would be the good case.
158
- *
159
- * Everything publish owns is carried through from `existing` verbatim, so
160
- * replacing does not unpublish a live app. */
161
- app: Record<string, unknown>;
162
- /** The staged schema documents, for `apps/{aid}/staging/{cid}`. */
163
- staging: {
164
- cid: string;
165
- doc: StagedSchemaDoc;
166
- }[];
167
- }
168
- export declare function projectDeploy(authored: AuthoredApp, schemas: {
169
- cid: string;
170
- schema: CollectionSchema;
171
- }[], stamp: PublishStamp, existing: Record<string, unknown> | null): DeployedApp;
172
- /** Everything `publish` writes that is NOT a promotion — the public face.
173
- *
174
- * The schemas are absent on purpose: publish PROMOTES the documents deploy
175
- * staged (copy `staging/{cid}` → `collections/{cid}`, re-stamped with
176
- * {@link promoteSchema}) rather than re-projecting them from git. What the
177
- * roster tested is then exactly what ships; re-projecting would publish
178
- * whatever the working tree says at publish time, which nobody has looked at
179
- * through `/staging/{aid}`.
180
- *
181
- * WRITE `public` LAST. It is the only one of the three that grants anything,
182
- * so a partial failure with it last leaves the app private (fail closed).
183
- * See the design note's publish ordering. */
184
- export interface PublishedFace {
185
- /** The COMPLETE app document **without `public`** — write it with `set`,
186
- * replacing.
187
- *
188
- * Replacing (not merging) is what lets a key DISAPPEAR: withdrawing a
189
- * collection's rule configuration, or taking `public` out of `app.json`,
190
- * has to actually remove the field. Everything deploy owns is carried
191
- * through from `existing`, so publishing does not revert an invitation.
192
- *
193
- * `public` is absent HERE on purpose — see {@link PublishedFace.public}. */
194
- app: Record<string, unknown>;
195
- /** `apps/{aid}/config/public` — the world-readable projection. */
196
- config: PublishedConfigDoc;
197
- /** The `public` block, to be written **LAST, as its own update** — or, when
198
- * `undefined`, DELETED from the app document (that is how an app becomes
199
- * private again).
200
- *
201
- * Separate from {@link PublishedFace.app} because it is the only one of
202
- * publish's writes that GRANTS anything: the rules authorize anonymous
203
- * reads and submissions from `apps/{aid}.public`. Writing it inside the
204
- * replacement document would open the app before the promoted schemas and
205
- * the world-readable config exist, so a failure part-way would leave
206
- * anonymous access live against a half-published surface. Written last, the
207
- * same failure leaves the app private — which is the direction to fail in.
208
- *
209
- * A re-publish therefore passes through a moment with no `public` block.
210
- * That is a brief denial for visitors, not a brief exposure. */
211
- public: Record<string, unknown> | undefined;
212
- }
213
- /** The rule-facing configuration to promote, read from the STAGED documents
214
- * rather than from the manifest as it reads right now.
215
- *
216
- * Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
217
- * the promoted schema is A's while the authorization behaviour is B's, a
218
- * combination nobody exercised through `/staging/{aid}`.
219
- *
220
- * (`public` is deliberately NOT part of this: it is not staged, because it is
221
- * the decision being made AT publish rather than something under test.) */
222
- export declare function stagedRuleConfig(staged: {
223
- cid: string;
224
- doc: StagedSchemaDoc;
225
- }[]): {
226
- collections: Record<string, AuthoredCollectionConfig> | undefined;
227
- participantRead: string[] | undefined;
228
- };
229
- export declare function projectPublish(authored: AuthoredApp, staged: {
230
- cid: string;
231
- doc: StagedSchemaDoc;
232
- }[], stamp: PublishStamp, existing: Record<string, unknown> | null): PublishedFace;
233
- /** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
234
- *
235
- * The stamp answers "which version is PUBLIC right now, and who made it so",
236
- * so it is written by the operation that changes the answer — publish — not
237
- * carried over from the deploy that staged it. */
238
- export declare function promoteSchema(staged: StagedSchemaDoc, stamp: PublishStamp): PublishedSchemaDoc;
239
- /** The app documents' parent path — the `FirestoreDocs` seam takes a
240
- * collection path plus a document id, and the app document's id is the aid. */
241
- export declare const APPS_COLLECTION = "apps";
242
- /** The collection (schema) documents' parent path — what the PUBLIC page
243
- * reads, written only by publish (promotion). */
244
- export declare const appSchemasPath: (aid: string) => string;
245
- /** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
246
- *
247
- * A TOP-LEVEL collection, not a field on the app: the public page resolves a
248
- * slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
249
- * has to be claimable atomically (create-if-absent) so two apps cannot hold
250
- * the same URL.
251
- *
252
- * `published` is what makes the reservation invisible until publish. The slug
253
- * is human-readable, so a readable reservation would let anyone guess the URL
254
- * and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
255
- * `allow read: if resource.data.published == true`, which needs no `get()` and
256
- * so costs nothing against the rules' expression budget. */
257
- export declare const APP_SLUGS_COLLECTION = "appSlugs";
258
- /** The reservation document. Written by deploy as `{ aid, published: false }`
259
- * and flipped by publish — never re-pointed at another aid, which the rules
260
- * enforce on update. */
261
- export interface AppSlugDoc extends Record<string, unknown> {
262
- aid: string;
263
- published: boolean;
264
- }
265
- export declare const appSlugDoc: (aid: string, published: boolean) => AppSlugDoc;
266
- /** The staged schema documents' parent path — what `/staging/{aid}` reads,
267
- * written by deploy. A separate DOCUMENT rather than a field beside
268
- * `publishedSchema`, because the rules cannot hide a field: anything inside a
269
- * document the public page may read is public. */
270
- export declare const appStagingPath: (aid: string) => string;
271
- /** The public-config documents' parent path. */
272
- export declare const appConfigPath: (aid: string) => string;
273
- /** Where one audience's pages live. `member` is read by anyone holding a role;
274
- * `roster` by anyone on the roster, participants included. */
275
- export declare const appViewTierPath: (aid: string, tier: "member" | "roster") => string;
276
- /** One audience's tier, as publish (or deploy) must write it.
277
- *
278
- * Both tiers are returned even when empty, deliberately. An app that WITHDREW
279
- * its member pages produces an empty tier, and a host that only ever saw the
280
- * tiers with something in them would leave the previous pages live — the
281
- * failure `config/view` already had, where a declaration was withdrawn and
282
- * the world went on reading the page. */
283
- export interface AppViewTier {
284
- tier: "member" | "roster";
285
- audience: Exclude<ViewAudience, "public">;
286
- /** The projection document, for `{tier}/live:config` or `{tier}/staged:config`.
287
- * Meaningless when `views` is empty — the host deletes the tier instead. */
288
- config: AppViewConfigDoc;
289
- /** The views to publish, in declaration order. The host reads each `path`
290
- * and writes it to `{tier}/live:{id}`. */
291
- views: NormalizedView[];
292
- }
293
- /** Project the declaration into the per-audience documents.
294
- *
295
- * Pure, like `projectApp`: the HTML is not here (the host reads the files),
296
- * and neither is the clock. What is here is the answer to "what may this
297
- * audience read, and how" — computed once, so the page never has to guess and
298
- * never has to discover it from a denial. */
299
- /** What the RULES will be in force with, as against what the manifest says.
300
- *
301
- * `projectPublish` replaces BOTH `participantRead` and `collections` with what
302
- * the staged schemas carry, so at publish the promoted pair is what decides
303
- * whether a read is allowed and which transitions exist. They travel together
304
- * deliberately: passing one and not the other publishes a page whose datasets
305
- * follow revision A and whose buttons follow revision B.
306
- *
307
- * At DEPLOY the manifest is exactly what is being staged, so the default is
308
- * right — and today deploy is the only caller, because publish PROMOTES the
309
- * staged documents rather than re-projecting them. */
310
- export interface PromotedRuleConfig {
311
- participantRead?: readonly string[];
312
- collections?: Record<string, AuthoredCollectionConfig>;
313
- }
314
- /** What this audience may CHANGE, per collection it draws.
315
- *
316
- * The `collections` config is the PROMOTED one where there is one: at publish
317
- * the rules run against what deploy staged, so projecting the manifest's
318
- * would advertise transitions the live rules deny. */
319
- export declare function tierWrites(authored: AuthoredApp, audience: Exclude<ViewAudience, "public">, cids: string[], promoted: PromotedRuleConfig): ProjectedViewWrite[];
320
- /** What this audience may READ, and how to query for it.
321
- *
322
- * A collection with no scope is dropped rather than published as unreachable:
323
- * the gate has already refused the declaration, so reaching here with one is
324
- * a programming error, and a page that queries it is denied. */
325
- export declare function tierViews(authored: AuthoredApp, audience: Exclude<ViewAudience, "public">, views: NormalizedView[], participantRead: readonly string[]): {
326
- id: string;
327
- collections: ProjectedViewCollection[];
328
- }[];
329
- export declare function projectAppViews(authored: AuthoredApp, stamp: PublishStamp, promoted?: PromotedRuleConfig): AppViewTier[];
330
- /** The document a tier's projection is published at. Beside the views
331
- * themselves, under one `match` — see `firestore.rules`. */
332
- export declare const viewConfigDocId: (stage: "live" | "staged") => string;