Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,13 @@ export default tseslint.config(
// in families, its suite keeps each assertion beside the family it belongs to, and both arrows
// are one sequence with the reasoning written between the steps. The ratchet says so without
// pretending the split is imminent.
//
// 1303 -> 1344 for `views[].ownRead`, which arrives as its own family: three refusals of the key
// itself, and one existing refusal re-pointed from the app's scope to the view's now that two
// participant pages of one app can be scoped differently.
{
files: ["src/publishChecks.ts"],
rules: { "max-lines": ["error", { max: 1303, skipBlankLines: true, skipComments: true }] },
rules: { "max-lines": ["error", { max: 1344, skipBlankLines: true, skipComments: true }] },
},
{
files: ["test/test_publishChecks.ts"],
Expand Down
24 changes: 24 additions & 0 deletions src/appViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ export interface NormalizedView {
* same page, and the projection of an app with no `live` must be byte-for-
* byte what it was before this key existed. */
live?: string[];
/** The subset of `collections` a PARTICIPANT reads as their own rows rather
* than whole. See `ViewZ.ownRead`.
*
* Absent, not empty, when the author declared nothing — `live`'s reason: an
* app that never asked for this must project byte-for-byte what it did
* before the key existed. */
ownRead?: string[];
/** `{ <cid>: <rows> }` over a subset of `collections`: read only the LATEST
* `rows` records of that dataset. See `ViewZ.limit` for why it is the
* latest and never the first.
Expand Down Expand Up @@ -163,6 +170,7 @@ function declaredViews(app: AuthoredApp): NormalizedViewsResult {
...(view.article === undefined ? {} : { article: view.article }),
collections: view.collections,
...(view.live === undefined ? {} : { live: view.live }),
...(view.ownRead === undefined ? {} : { ownRead: view.ownRead }),
...(view.limit === undefined ? {} : { limit: view.limit }),
where: `views[${index}]`,
}));
Expand Down Expand Up @@ -373,6 +381,22 @@ export interface ProjectedViewCollection {
export function participantScope(app: AuthoredApp, cid: string, participantRead: readonly string[]): ProjectedViewCollection | null {
if (participantRead.includes(cid)) return { cid, scope: "all" };
if (app.public?.enabled === true && (app.public.read ?? []).includes(cid)) return { cid, scope: "all" };
return ownScope(app, cid);
}

/** The OWN-ROW branches on their own — how the rules would hand a submitter
* their own records, with the two widening branches above skipped.
*
* Split out of `participantScope` rather than duplicated, so the widened and
* the narrowed answer can never disagree about what "own" means: the query a
* page is given by `views[].ownRead` has to be the same query the rules grant,
* and a second copy of these three lines is where that stops being true.
*
* Null where the declaration names no way to find the reader's rows. That is a
* refusal at the gate rather than something to paper over here: dropped
* silently, the collection would vanish from the projection and the page would
* be handed no dataset at all — less than the "all" it asked to narrow. */
export function ownScope(app: AuthoredApp, cid: string): ProjectedViewCollection | null {
const submit: AuthoredSubmit | undefined = app.public?.submit?.[cid];
if (submit?.emailField !== undefined) return { cid, scope: "own", emailField: submit.emailField };
if (submit?.uidField !== undefined) return { cid, scope: "own", uidField: submit.uidField };
Expand Down
74 changes: 72 additions & 2 deletions src/publishChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@

import type { CollectionFieldSpec, CollectionSchema } from "@mulmoclaude/core/collection";
import { isSafeCustomViewPath } from "@mulmoclaude/core/collection/server";
import { articleCid, declaresMoves, normalizeViews, participantScope, type NormalizedView, type ViewAudience } from "./appViews.js";
import {
articleCid,
declaresMoves,
normalizeViews,
ownScope,
participantScope,
type NormalizedView,
type ProjectedViewCollection,
type ViewAudience,
} from "./appViews.js";
import { agentCids, AGENT_ID_PATTERN, AGENT_INSTRUCTION_MAX, RESERVED_AGENT_IDS } from "./appAgents.js";
import { APP_PROTOCOL, protocolOf, protocolWithin } from "./appProtocol.js";
import { writersOf } from "./appViews.js";
Expand Down Expand Up @@ -1433,6 +1442,62 @@
return problems;
}

/** What ONE VIEW is actually given for a collection, opt-in included.
*
* The same question `scopeFor` answers when projecting, asked here so the gate judges the
* declaration that will be written rather than the one it would have been without the key. Kept
* to the participant audience because that is the only tier either branch describes. */
function scopeOfView(app: AuthoredApp, view: NormalizedView, cid: string): ProjectedViewCollection | null {
if (view.ownRead?.includes(cid) === true) return ownScope(app, cid);
return participantScope(app, cid, app.participantRead ?? []);
}

/** `ownRead`: the opt-in that narrows one participant page to the reader's own rows.
*
* Three refusals, and each one is a page that would look broken rather than smaller.
*
* NOT IN `collections` is `live`'s arithmetic: the page is handed no query for that dataset, so
* there is nothing to narrow.
*
* NOT A PARTICIPANT PAGE. `member` reads every collection whole because that is what the tier is
* for, and `public` has no reader to be the owner of anything — an anonymous visitor's "own rows"
* is not a smaller answer, it is no answer. Both would be honoured by nothing in the projection
* and leave the author wondering why the key did nothing.
*
* NOTHING TO FALL TO is the one worth the longest message. `ownScope` needs a field carrying the
* reader's identity — `emailField`, `uidField`, or an id built from `auth.uid` — and a declaration
* with none has no way to say which rows are this reader's. Left to the projection, the collection
* is DROPPED rather than narrowed (`tierViews` filters out a null scope), so the page is handed no
* dataset at all: strictly less than the whole collection it asked to trim, and silent. */
function viewOwnReadProblems(app: AuthoredApp, view: NormalizedView): string[] {
const problems: string[] = [];
for (const cid of view.ownRead ?? []) {
if (!view.collections.includes(cid)) {
problems.push(
`${view.where}.ownRead names '${cid}', which is not in ${view.where}.collections. A view narrows a subset of the datasets it is handed — ` +
"the page is given no query for this one, so there is nothing to narrow.",
);
continue;
}
if (view.audience !== "participant") {
problems.push(
`${view.where}.ownRead names '${cid}' on an audience of "${view.audience}", and only "participant" has an owner to narrow to. The member ` +
"tier reads a collection whole because that is what it is for, and a public page's reader may be nobody at all. Drop the key, or move " +
"this page to the participant audience.",
);
continue;
}
if (ownScope(app, cid) === null) {
problems.push(
`${view.where}.ownRead names '${cid}', and nothing in public.submit.${cid} says which rows are the reader's: it declares no emailField, no ` +
'uidField and no idFrom "auth.uid". There is no query to narrow to, so the dataset would be dropped from the projection entirely and the ' +
"page handed nothing — less than the whole collection it asked to trim. Declare one of those, or drop the key.",
);
}
}
return problems;
}

/** The most a view may be capped to and still be a cap worth declaring.
*
* Arbitrary, and deliberately generous: what the key exists to stop is the
Expand Down Expand Up @@ -1491,7 +1556,11 @@
);
continue;
}
if (view.audience === "participant" && participantScope(app, cid, app.participantRead ?? [])?.scope === "own") {
// THE VIEW'S SCOPE, not the app's. `ownRead` makes one page's read own-scoped while another
// participant page of the same app still reads the collection whole, so asking `participantScope`
// alone would now answer about a different page than the one being capped — passing the pair
// that fails, and refusing one that is fine.
if (view.audience === "participant" && scopeOfView(app, view, cid)?.scope === "own") {
problems.push(
`${view.where}.limit caps '${cid}', which a participant reads as their OWN ROWS: the query already carries a where on the field that makes ` +
"it readable, so ordering it as well needs a composite index, and the deployment declares none — the read would FAIL rather than return " +
Expand Down Expand Up @@ -1686,6 +1755,7 @@
...viewPathProblems(view),
...view.collections.flatMap((cid) => viewCollectionProblems(app, view, cid, known)),
...viewLiveProblems(app, view),
...viewOwnReadProblems(app, view),
...viewLimitProblems(app, view),
...articleCostProblems(app, view),
]),
Expand Down Expand Up @@ -1943,7 +2013,7 @@
const cid = articleCid(view);
const schema = cid === undefined ? undefined : schemaOf.get(cid);
if (article === undefined || schema === undefined) return [];
const fields = schema.fields ?? {};

Check warning on line 2016 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (24.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined

Check warning on line 2016 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (22.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined
const known = Object.keys(fields).sort(byText).join(", ") || "(none)";
const drawn: { key: "title" | "body" | "summary" | "byline"; field: string | undefined; missing: string }[] = [
{ key: "title", field: article.title, missing: "the page falls back to the document id, so an article is headed by its URL name" },
Expand Down Expand Up @@ -1988,7 +2058,7 @@
const { mail } = collection;
const schema = schemaOf.get(cid);
if (mail === undefined || schema === undefined) return [];
const fields = schema.fields ?? {};

Check warning on line 2061 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (24.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined

Check warning on line 2061 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (22.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined
const known = Object.keys(fields).sort(byText).join(", ") || "(none)";
const problems: string[] = [];
if (!declaredField(fields, mail.toField)) {
Expand Down Expand Up @@ -2036,7 +2106,7 @@
/** The two ways a ref field is the wrong field, both of them silent at publish
* and total at write time. */
function refKindProblems(own: CollectionSchema | undefined, cid: string, refIn: { ref: string; collection: string }): string[] {
const spec = own?.fields?.[refIn.ref];

Check warning on line 2109 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (24.x)

Unnecessary optional chain on a non-nullish value

Check warning on line 2109 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (22.x)

Unnecessary optional chain on a non-nullish value
if (spec === undefined || !Object.hasOwn(own?.fields ?? {}, refIn.ref)) return [];
if (!STRING_VALUED.has(spec.type)) {
return [
Expand Down Expand Up @@ -2064,13 +2134,13 @@
if (refIn === undefined) return [];
const own = schemaOf.get(cid);
const refProblem =
own === undefined || declaredField(own.fields ?? {}, refIn.ref)

Check warning on line 2137 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (24.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined

Check warning on line 2137 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (22.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined
? []
: [
`collections.${cid}.refIn.ref names '${refIn.ref}', which the schema of '${cid}' does not declare. The rules build the path to the parent out of ` +
`that field, so a record without it is an evaluation error and NOTHING can be created in '${cid}' — the owner included. ` +
`Fields on '${cid}': ${
Object.keys(own.fields ?? {})

Check warning on line 2143 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (24.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined

Check warning on line 2143 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (22.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined
.sort(byText)
.join(", ") || "(none)"
}.`,
Expand Down Expand Up @@ -2110,7 +2180,7 @@
function maxBytesRefProblems(schemaOf: ReadonlyMap<string, CollectionSchema>, cid: string, submit: AuthoredSubmit): string[] {
const schema = schemaOf.get(cid);
if (schema === undefined) return [];
const fields = schema.fields ?? {};

Check warning on line 2183 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (24.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined

Check warning on line 2183 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (22.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined
const known = Object.keys(fields).sort(byText).join(", ") || "(none)";
return Object.keys(submit.maxBytes ?? {}).flatMap((field) => {
const where = `public.submit.${cid}.maxBytes.${field}`;
Expand Down Expand Up @@ -2181,7 +2251,7 @@
if (target === undefined || field === undefined) return [];
const schema = schemaOf.get(target);
if (schema === undefined || referencedField(schemaOf, target, field) !== undefined) return [];
const known = Object.keys(schema.fields ?? {})

Check warning on line 2254 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (24.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined

Check warning on line 2254 in src/publishChecks.ts

View workflow job for this annotation

GitHub Actions / check (22.x)

Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined
.sort(byText)
.join(", ");
return [
Expand Down
30 changes: 30 additions & 0 deletions src/publishManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,36 @@ const ViewZ = z
* view is handed are still declared once, and `live` only says which of
* them keep moving. */
live: z.array(NameZ).min(1).optional(),
/** The subset of `collections` this page reads as the READER'S OWN ROWS
* rather than whole — `audience: "participant"` only.
*
* WHAT IT IS FOR. A participant's scope is worked out from the app
* (`participantScope`), and a collection the app publishes to the world
* resolves to `all` before the own-row branches are ever reached: the
* rows are public, so a page that showed the reader less would be hiding
* what a stranger can read. That is the right default and it is not
* always what the page is for. A magazine's writers' desk lists what YOU
* published so you can correct it, and handing it every article the app
* has ever published — bodies included, since a rule cannot project a
* field away — is a read that grows with the archive to draw a list that
* does not.
*
* IT IS NOT A PERMISSION AND MUST NOT BE READ AS ONE. The rows stay
* exactly as readable as they were; this narrows one page's QUERY. On a
* collection outside `public.read` the participant already read their own
* rows and this key changes nothing. Anything private is private because
* the rules say so.
*
* It projects no new vocabulary: the result is `scope: "own"` with the
* same `emailField` / `uidField` / `ownDocId` every reader has honoured
* since the first release, so an older host narrows the query correctly
* without knowing this key exists. That is why it does not move
* `APP_PROTOCOL`.
*
* A SUBSET of `collections`, like `live`, and refused on a collection
* with no own-row branch to fall to — there the page would be handed
* nothing at all rather than less. */
ownRead: z.array(NameZ).min(1).optional(),
/** The most recent N records of a dataset, instead of every record there
* is: `{ <cid>: <rows> }`, over a subset of `collections`.
*
Expand Down
14 changes: 12 additions & 2 deletions src/publishProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { protocolFor } from "./appProtocol.js";
import {
limitFor,
normalizeViews,
ownScope,
participantScope,
type ArticleFields,
articleCid,
Expand Down Expand Up @@ -563,10 +564,19 @@ export interface AppViewTier {
function scopeFor(
authored: AuthoredApp,
audience: Exclude<ViewAudience, "public">,
view: NormalizedView,
cid: string,
participantRead: readonly string[],
): ProjectedViewCollection | null {
return audience === "member" ? { cid, scope: "all" } : participantScope(authored, cid, participantRead);
if (audience === "member") return { cid, scope: "all" };
// THE VIEW'S OWN ANSWER FIRST. `ownRead` is per view rather than per app
// because two participant pages of one app can legitimately want different
// things — a writers' desk showing what you published, a directory showing
// everyone — and the app-level `participantRead` cannot express both. The
// gate has already refused an opt-in with nothing to fall to, so a null here
// is the same programming error `tierViews` documents for the widened path.
if (view.ownRead?.includes(cid) === true) return ownScope(authored, cid);
return participantScope(authored, cid, participantRead);
}

/** Project the declaration into the per-audience documents.
Expand Down Expand Up @@ -595,7 +605,7 @@ export function tierWrites(authored: AuthoredApp, audience: Exclude<ViewAudience
export function tierViews(authored: AuthoredApp, audience: Exclude<ViewAudience, "public">, views: NormalizedView[], participantRead: readonly string[]) {
return views.map((view) => {
const collections = view.collections
.map((cid) => scopeFor(authored, audience, cid, participantRead))
.map((cid) => scopeFor(authored, audience, view, cid, participantRead))
.filter((scope): scope is ProjectedViewCollection => scope !== null)
.map((scope) => limitFor(authored, view, scope));
// Narrowed to what this tier is actually handed, for the reason the scopes
Expand Down
Loading
Loading