diff --git a/eslint.config.js b/eslint.config.js index 550964f..7966fee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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"], diff --git a/src/appViews.ts b/src/appViews.ts index aab3060..d35c4eb 100644 --- a/src/appViews.ts +++ b/src/appViews.ts @@ -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[]; /** `{ : }` 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. @@ -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}]`, })); @@ -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 }; diff --git a/src/publishChecks.ts b/src/publishChecks.ts index 52fca70..b084cfa 100644 --- a/src/publishChecks.ts +++ b/src/publishChecks.ts @@ -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"; @@ -1433,6 +1442,62 @@ function viewLiveProblems(app: AuthoredApp, view: NormalizedView): string[] { 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 @@ -1491,7 +1556,11 @@ function viewLimitProblems(app: AuthoredApp, view: NormalizedView): string[] { ); 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 " + @@ -1686,6 +1755,7 @@ function viewProblems(app: AuthoredApp, collections: readonly PublishableCollect ...viewPathProblems(view), ...view.collections.flatMap((cid) => viewCollectionProblems(app, view, cid, known)), ...viewLiveProblems(app, view), + ...viewOwnReadProblems(app, view), ...viewLimitProblems(app, view), ...articleCostProblems(app, view), ]), diff --git a/src/publishManifest.ts b/src/publishManifest.ts index bc595c7..d6949a0 100644 --- a/src/publishManifest.ts +++ b/src/publishManifest.ts @@ -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: `{ : }`, over a subset of `collections`. * diff --git a/src/publishProject.ts b/src/publishProject.ts index 0c90b03..aa95565 100644 --- a/src/publishProject.ts +++ b/src/publishProject.ts @@ -41,6 +41,7 @@ import { protocolFor } from "./appProtocol.js"; import { limitFor, normalizeViews, + ownScope, participantScope, type ArticleFields, articleCid, @@ -563,10 +564,19 @@ export interface AppViewTier { function scopeFor( authored: AuthoredApp, audience: Exclude, + 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. @@ -595,7 +605,7 @@ export function tierWrites(authored: AuthoredApp, audience: Exclude, 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 diff --git a/test/test_viewOwnRead.ts b/test/test_viewOwnRead.ts new file mode 100644 index 0000000..f94815c --- /dev/null +++ b/test/test_viewOwnRead.ts @@ -0,0 +1,205 @@ +// `views[].ownRead` — the opt-in that narrows ONE participant page to the reader's own rows. +// +// The default it overrides is deliberate and stays: a collection the app publishes to the world +// resolves to `scope: "all"` for a participant, because the rows are public and a page showing the +// reader less would be hiding what a stranger can read. What that default cannot express is a +// writers' desk — the page whose job is "what did I publish, so I can correct it" — where the whole +// collection is a read that grows with the archive to draw a list that does not. +// +// THE POINT TO PIN is that this narrows a QUERY and never a permission. The rows stay exactly as +// readable as they were, and the proof is that the projection contains no new vocabulary: the +// result is `scope: "own"` with the same selector every reader has honoured since the first +// release. A gate that accepted the key and projected nothing would satisfy a file of refusals and +// leave the feature doing nothing, so every refusal below is paired with the neighbouring +// declaration that must still publish, and with what the projection then says. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AuthoredAppZ } from "../src/publishManifest.js"; +import { normalizeViews, participantScope, ownScope } from "../src/appViews.js"; +import { publishProblems } from "../src/publishChecks.js"; +import { projectAppViews } from "../src/publishProject.js"; + +const OWNER = "editor@journal.jp"; +const STAMP = { publishedAt: 1_700_000_000_000, email: OWNER, uid: "u-owner" }; +const CIDS = [ + { cid: "articles", primaryKey: "id" }, + { cid: "notes", primaryKey: "id" }, +]; + +const app = (overrides: Record) => + AuthoredAppZ.parse({ + aid: "app_own_read", + members: { [OWNER]: { "*": "owner", articles: "participant" }, "writer@journal.jp": { articles: "participant" } }, + ...overrides, + }); + +const problemsFor = (overrides: Record): string[] => publishProblems(app(overrides), CIDS, OWNER); + +function refuses(problems: string[], fragment: string): void { + const bullets = problems.map((problem) => ` - ${problem}`).join("\n"); + assert.ok( + problems.some((problem) => problem.includes(fragment)), + `expected a problem mentioning ${JSON.stringify(fragment)}, got:\n${bullets || " (none)"}`, + ); +} + +/** A magazine: `articles` is published to the world AND submitted to by the roster, which is the + * exact shape whose participant scope widens to `all` before any own-row branch is reached. */ +const magazine = (views: Record[]): Record => ({ + collections: { articles: { statusField: "status", submitOnly: true } }, + public: { + enabled: true, + read: ["articles"], + submit: { + articles: { + auth: "verifiedEmail", + audience: "participant", + uidField: "byUid", + createFields: ["slug", "title", "body", "byUid", "status", "publishedAt"], + initialStatus: "published", + idFrom: "slug", + idField: "slug", + stampField: "publishedAt", + }, + }, + }, + views, +}); + +const desk = (view: Record = {}): Record => ({ + id: "write", + audience: "participant", + path: "views/desk.html", + collections: ["articles"], + ...view, +}); + +/** One audience's projected views, as they are published. */ +const viewsOf = (overrides: Record, audience: "member" | "participant") => + projectAppViews(app(overrides), STAMP).find((tier) => tier.audience === audience)?.config.views ?? []; + +/** The scope that projection gives one collection on one page. */ +const scopeIn = (views: ReturnType, viewId: string, cid: string) => + views.find((view) => view.id === viewId)?.collections.find((entry) => entry.cid === cid); + +// --- the default this key overrides ---------------------------------------- + +test("without the key, a participant reads a PUBLIC collection whole", () => { + // Not a quirk to route around — the second branch of `participantScope`, and the reason it is + // there: the rows are world-readable, so narrowing them by default would hide from the writer + // what any stranger can read. + assert.deepEqual(participantScope(app(magazine([desk()])), "articles", []), { cid: "articles", scope: "all" }); +}); + +test("`ownScope` answers the same question with the widening branches skipped", () => { + // Split out of `participantScope` rather than copied: the query `ownRead` hands a page must be + // the one the rules grant, and a second copy of those three lines is where that stops being true. + assert.deepEqual(ownScope(app(magazine([desk()])), "articles"), { cid: "articles", scope: "own", uidField: "byUid" }); +}); + +// --- the declaration -------------------------------------------------------- + +test("`ownRead` parses and survives normalization", () => { + // `.strict()` means an app.json writing this key does not parse at all until it exists. + const authored = normalizeViews(app(magazine([desk({ ownRead: ["articles"] })]))); + assert.ok(authored.ok); + assert.deepEqual(authored.views[0]?.ownRead, ["articles"]); +}); + +test("a view that declares no `ownRead` normalizes without the key at all", () => { + // Absent, not empty: an app that never asked for this must project byte-for-byte what it + // projected before the key existed, and `{}` vs `{ownRead: []}` is a changed document. + const result = normalizeViews(app(magazine([desk()]))); + assert.ok(result.ok); + assert.ok(!("ownRead" in (result.views[0] ?? {}))); +}); + +// --- what it projects ------------------------------------------------------- + +test("the opt-in narrows the participant page's query, and nothing else", () => { + const opted = viewsOf(magazine([desk({ ownRead: ["articles"] })]), "participant"); + assert.deepEqual(scopeIn(opted, "write", "articles"), { cid: "articles", scope: "own", uidField: "byUid" }); + + // NO NEW VOCABULARY. `scope: "own"` with a selector is what every reader has built a `where` + // from since the first release, so a host that has never heard of `ownRead` narrows correctly — + // which is why this key does not move APP_PROTOCOL. + const plain = viewsOf(magazine([desk()]), "participant"); + assert.deepEqual(scopeIn(plain, "write", "articles"), { cid: "articles", scope: "all" }); +}); + +test("one page may narrow while another page of the same app reads whole", () => { + // The reason the key is on the VIEW and not on the app: a writers' desk and a directory are both + // participant pages, and `participantRead` cannot say two different things about one collection. + const docs = viewsOf(magazine([desk({ id: "write", ownRead: ["articles"] }), desk({ id: "directory", path: "views/all.html" })]), "participant"); + assert.deepEqual(scopeIn(docs, "write", "articles"), { cid: "articles", scope: "own", uidField: "byUid" }); + assert.deepEqual(scopeIn(docs, "directory", "articles"), { cid: "articles", scope: "all" }); +}); + +test("the member tier is untouched by a participant page's opt-in", () => { + // `/m/` is a different view at a different tier, and the owner's desk still reads the archive + // whole. An opt-in that leaked across tiers would take the editor's own view away. + const docs = viewsOf( + magazine([desk({ ownRead: ["articles"] }), { id: "desk", audience: "member", path: "views/desk.html", collections: ["articles"] }]), + "member", + ); + assert.deepEqual(scopeIn(docs, "desk", "articles"), { cid: "articles", scope: "all" }); +}); + +test("a live subscription rides the narrowed query", () => { + // `live` is a subset of `collections`, not of the widened read, so the pair is legal — and the + // subscription a host opens is built from the same constraints as the read. + const problems = problemsFor(magazine([desk({ ownRead: ["articles"], live: ["articles"] })])); + assert.deepEqual(problems, []); + const docs = viewsOf(magazine([desk({ ownRead: ["articles"], live: ["articles"] })]), "participant"); + assert.deepEqual(scopeIn(docs, "write", "articles"), { cid: "articles", scope: "own", uidField: "byUid" }); +}); + +// --- what it refuses -------------------------------------------------------- + +test("a declaration that opts in publishes", () => { + // The pair to every refusal below. A gate that refused them all would pass its own suite. + assert.deepEqual(problemsFor(magazine([desk({ ownRead: ["articles"] })])), []); +}); + +test("`ownRead` naming a dataset the view was never handed is refused", () => { + refuses(problemsFor(magazine([desk({ ownRead: ["notes"] })])), "ownRead names 'notes', which is not in views[0].collections"); +}); + +test("`ownRead` on a member or public page is refused", () => { + // Neither tier has an owner to narrow to: a member reads the collection whole because that is + // what the tier is for, and a public visitor may be nobody at all. + refuses( + problemsFor(magazine([{ id: "desk", audience: "member", path: "views/desk.html", collections: ["articles"], ownRead: ["articles"] }])), + "ownRead names 'articles' on an audience of \"member\"", + ); + refuses( + problemsFor(magazine([{ id: "public", audience: "public", path: "views/home.html", collections: ["articles"], ownRead: ["articles"] }])), + "ownRead names 'articles' on an audience of \"public\"", + ); +}); + +test("`ownRead` on a collection with no way to say whose a row is, is refused", () => { + // THE ONE THAT WOULD BE SILENT. With no selector the scope is null, `tierViews` drops the + // collection, and the page is handed no dataset at all — less than the whole it asked to trim. + const anonymous = { + collections: { articles: { statusField: "status" } }, + public: { + enabled: true, + read: ["articles"], + submit: { articles: { auth: "none", createFields: ["title", "status"], initialStatus: "published" } }, + }, + views: [desk({ ownRead: ["articles"] })], + }; + refuses(problemsFor(anonymous), "nothing in public.submit.articles says which rows are the reader's"); +}); + +test("a cap on a page that opted in is refused, and the same cap without the opt-in is not", () => { + // The rule already existed for a participant whose collection was private; the opt-in creates + // the same situation on a PUBLIC one, so the check has to ask about this view rather than the + // app. An own-row query already carries a `where`, and ordering it needs a composite index no + // deployment declares — the read fails rather than returning fewer rows. + refuses(problemsFor(magazine([desk({ ownRead: ["articles"], limit: { articles: 10 } })])), "which a participant reads as their OWN ROWS"); + assert.deepEqual(problemsFor(magazine([desk({ limit: { articles: 10 } })])), []); +});