From 3e8f9cd49ef996f3ab78b178531887c8131e825c Mon Sep 17 00:00:00 2001 From: snakajima Date: Thu, 27 Aug 2026 04:26:06 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E9=96=8B=E3=81=AE=E3=83=88?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=83=9A=E3=83=BC=E3=82=B8=E3=82=92=E3=82=A2?= =?UTF-8?q?=E3=83=97=E3=83=AA=E3=81=AB=E8=BF=94=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type: "article"` は住所を 2 つ取っていた——索引の `/a/{slug}` と記事の `/a/{slug}/{id}`。前者はプラットフォームのものだったことが一度もない(索引は レコードの一覧で、手書きの公開ページは同じ datasets をすでに受け取っている)のに、 `path` と `type` は排他で public ビューはアプリに 1 つなので、記事を出すアプリは 公開の顔を丸ごと手放していた。 - `views[].type` を撤去。`article` ブロック単独が「このコレクションのレコードは `/a/{slug}/{id}` で markdown として描かれる」の宣言になり、`path` はすべての ビューで必須。`type` は名指しで断る(足すべきものを言う必要があるので、 strict の Unrecognized key では足りない) - `article.collection` を追加。索引が自前になった以上、雑誌の表紙が 2 本目の dataset を読む理由はいくらでもある。1 本しかないビューでは省ける - `view.open(cid, id)` ——フレームは `sandbox="allow-scripts"` のみで、 ページからリンクが張れない。URL ではなくレコードを名指し、slug は親が持つので、 ページは読んでいるアプリの外へ人を送れない - protocol は据え置き(2.0.0)。判定を `type` から `article` に付け替えただけで、 古い reader は `path` を描くので「別のアプリを見せる」ではなく劣化になった Co-Authored-By: Claude Opus 5 (1M context) --- eslint.config.js | 14 ++- src/appProtocol.ts | 22 ++-- src/appViews.ts | 129 ++++++++++++--------- src/index.ts | 2 + src/publishChecks.ts | 35 +++--- src/publishManifest.ts | 68 ++++++++---- src/publishProject.ts | 33 +++--- src/view/bridge.ts | 9 +- src/view/index.ts | 5 + src/view/message.ts | 61 ++++++++++ src/view/parent.ts | 54 +++++++++ src/view/protocol.ts | 16 +++ src/view/srcdoc.ts | 28 +++++ test/test_appProtocol.ts | 19 ++-- test/test_appViews.ts | 90 ++++++++++----- test/test_publishChecks.ts | 12 +- test/test_publishProject.ts | 54 +++++++-- test/test_viewOpen.ts | 216 ++++++++++++++++++++++++++++++++++++ 18 files changed, 695 insertions(+), 172 deletions(-) create mode 100644 test/test_viewOpen.ts diff --git a/eslint.config.js b/eslint.config.js index b01547a..f67ca78 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -197,9 +197,9 @@ export default tseslint.config( rules: { "sonarjs/no-try-promise": "off" }, }, { - // These three run the srcdoc bootstrap under `node:vm` — evaluating the injected script IS the + // These four run the srcdoc bootstrap under `node:vm` — evaluating the injected script IS the // test, and it is the only way to prove what the frame's own code does with a message. - files: ["test/test_viewGesture.ts", "test/test_viewLookup.ts", "test/test_viewNotice.ts"], + files: ["test/test_viewGesture.ts", "test/test_viewLookup.ts", "test/test_viewNotice.ts", "test/test_viewOpen.ts"], rules: { "sonarjs/code-eval": "off" }, }, { @@ -284,14 +284,18 @@ export default tseslint.config( }, { files: ["test/test_publishChecks.ts"], - rules: { "max-lines": ["error", { max: 1533, skipBlankLines: true, skipComments: true }] }, + rules: { "max-lines": ["error", { max: 1535, skipBlankLines: true, skipComments: true }] }, }, { files: ["src/view/parent.ts"], - rules: { "max-lines-per-function": ["error", { max: 181, skipBlankLines: true, skipComments: true, IIFEs: true }] }, + rules: { "max-lines-per-function": ["error", { max: 208, skipBlankLines: true, skipComments: true, IIFEs: true }] }, }, { + // The bootstrap is ONE template literal, so `skipComments` does not apply to it: what a page + // author reads about `view.open` is string content, and counts. Raising this is what documenting + // a verb costs, and it is cheaper than the alternative — a page that cannot get out of the + // frame because nobody could see how. files: ["src/view/srcdoc.ts"], - rules: { "max-lines-per-function": ["error", { max: 96, skipBlankLines: true, skipComments: true, IIFEs: true }] }, + rules: { "max-lines-per-function": ["error", { max: 115, skipBlankLines: true, skipComments: true, IIFEs: true }] }, }, ); diff --git a/src/appProtocol.ts b/src/appProtocol.ts index f934bf0..808e737 100644 --- a/src/appProtocol.ts +++ b/src/appProtocol.ts @@ -63,10 +63,15 @@ export const APP_PROTOCOL_BASE = "1.0.0"; /** THE CONTRACT THIS APP'S DOCUMENTS KEEP — which is not always the newest one this build can emit. * * The per-app stamp is back, and this is the day `APP_PROTOCOL`'s note above said it would be. An - * ARTICLE VIEW is the first key a reader must UNDERSTAND to be correct rather than one it may - * safely ignore: `views[].type` replaces the HTML a public page is drawn from, so a reader that - * does not know it finds no HTML, concludes the app publishes no view, and draws the GENERATED - * FORM in a magazine's place. Nothing errors. The visitor is shown a different app. + * ARTICLE DECLARATION is the first key a reader must UNDERSTAND to be correct rather than one it + * may safely ignore: `views[].article` turns on a second address under the app's public entrance, + * `/a/{slug}/{id}`, and a reader that does not know it draws the app's index there instead. Every + * link ever shared to an article lands on the wrong page. Nothing errors. + * + * It used to be worse, and the note is kept because the difference is the whole reason the platform + * gave the index back: while `views[].type` REPLACED the HTML a public page was drawn from, an + * unknowing reader found no HTML at all, concluded the app published no view, and drew the + * GENERATED FORM in a magazine's place — the visitor was shown a different app. * * So the major goes up — and it goes up ONLY FOR THE APPS THAT USE IT. Stamping every app 2.0.0 * would make every deployed reader refuse every app published after this build, including the ones @@ -77,11 +82,14 @@ export const APP_PROTOCOL_BASE = "1.0.0"; * older than this contract refuse the app — they show "this build cannot draw what it published" * rather than half of it. That is the intended outcome, and it is why the READER SHIPS FIRST. */ export function protocolFor(app: { - views?: readonly { type?: string | undefined }[] | undefined; + views?: readonly { article?: unknown }[] | undefined; public?: { submit?: Record | undefined } | undefined; }): string { - // A page the reader must know how to DRAW. - const drawnHere = (app.views ?? []).some((view) => view.type !== undefined); + // A page the reader must know how to DRAW. `article` rather than the `type` this clause was + // written for: the platform no longer draws the INDEX, so an unknowing reader finds the app's own + // HTML at `/a/{slug}` and draws it — what it gets wrong is `/a/{slug}/{id}`, where every shared + // link lands on that index instead of the article it names. + const drawnHere = (app.views ?? []).some((view) => view.article !== undefined); // And an id the reader must know how to BUILD, which is the half that is easy to miss because it // is nowhere near a view. `recordId` in an older reader has no `slug` branch, so it falls through // to the random uuid it uses for `idFrom: "auto"` — while the deployed rules now require the diff --git a/src/appViews.ts b/src/appViews.ts index 217d4ac..aab3060 100644 --- a/src/appViews.ts +++ b/src/appViews.ts @@ -69,6 +69,9 @@ const VIEW_ID_SHAPE = "must be lowercase letters, digits and hyphens, start with * carried only so a refusal names the key the author can go and edit. */ /** Which field of an article is which. See `ViewZ.article`. */ export interface ArticleFields { + /** Which collection holds the articles. Absent where `collections` names exactly one — see + * {@link articleCid}. */ + collection?: string | undefined; title: string; body: string; /** `| undefined` explicitly, as everywhere else here: this repository builds with @@ -83,11 +86,13 @@ export interface ArticleFields { export interface NormalizedView { id: string; audience: ViewAudience; - /** Absent exactly when `type` is present — see {@link NormalizedView.type}. */ + /** The author's HTML. Optional in the TYPE and required of every view — the + * legacy `public.view` spelling has no other shape, and `viewSourceProblems` + * is where its absence is refused. */ path?: string | undefined; - /** A page the PLATFORM draws, instead of the author's HTML at `path`. */ - type?: "article" | undefined; - /** Present with `type: "article"` and never without it. */ + /** The fields of the ARTICLE PAGE the platform draws at `/a/{slug}/{id}`, when + * this view declares one. It does not replace `path`: that page is a second + * address beside the author's, never instead of it. */ article?: ArticleFields | undefined; collections: string[]; /** The subset of `collections` this page WATCHES rather than reads once. @@ -121,6 +126,20 @@ const BOTH_FORMS = "app.json declares both `views` and `public.view`. These are the same thing — `public.view` is the older spelling — and publishing would have to choose one silently. " + 'Move the `public.view` entry into `views` as { id: "public", audience: "public", … } and delete it.'; +/** `"type": "article"` — a key that used to take the app's whole public face. + * + * It meant "the platform draws this page instead of your HTML", and it drew TWO: the index at + * `/a/{slug}` and one article at `/a/{slug}/{id}`. Only the second was ever the platform's, so + * the key is gone and the `article` block stands on its own. What an author has to do about it is + * ADD a page, which is the thing this refusal has to say — deleting the key alone leaves the view + * with nothing to draw, and the next refusal they would see is about `path`. */ +const retiredType = (id: string): string => + `views[id: "${id}"] declares \`"type": "article"\`, which no longer exists. The platform draws ONE page for an app — an article at ` + + `\`/a/{slug}/{id}\` — and the index at \`/a/{slug}\` is yours: it was never anything the platform could draw better than you can, and ` + + `taking it left an app that publishes articles with no public face of its own. Delete \`type\`, keep the \`article\` block, and add ` + + '`"path"` naming the HTML that lists them. Link an entry to its article with `view.open(cid, id)` — a sandboxed page cannot navigate ' + + "on its own."; + /** The two declarations, as one list, or the refusal that they are both there. * * `public.view` becomes an entry under the reserved id, so everything @@ -130,11 +149,17 @@ function declaredViews(app: AuthoredApp): NormalizedViewsResult { const authored = app.views; if (legacy !== undefined && authored !== undefined) return { ok: false, problems: [BOTH_FORMS] }; + // `type` is refused HERE rather than in `viewSourceProblems`, because past this point it does not + // exist: `NormalizedView` has no such key, so this is the last place holding what the author + // wrote. Refused rather than dropped for `BOTH_FORMS`'s reason — an author who wrote it is asking + // for a page, and silence would hand them a different one with nothing anywhere saying why. + const withType = (authored ?? []).filter((view) => view.type !== undefined); + if (withType.length > 0) return { ok: false, problems: withType.map((view) => retiredType(view.id)) }; + const views: NormalizedView[] = (authored ?? []).map((view, index): NormalizedView => ({ id: view.id, audience: view.audience, ...(view.path === undefined ? {} : { path: view.path }), - ...(view.type === undefined ? {} : { type: view.type }), ...(view.article === undefined ? {} : { article: view.article }), collections: view.collections, ...(view.live === undefined ? {} : { live: view.live }), @@ -157,69 +182,65 @@ function declaredViews(app: AuthoredApp): NormalizedViewsResult { return { ok: true, views: [...views, legacyView] }; } -/** WHAT DRAWS THIS PAGE, and the four ways of not saying it. - * - * A view is either HTML the author wrote (`path`) or a page the platform draws - * from the declaration (`type`). Neither leaves publish nothing to write; - * both leaves it two things and no way to choose, which — like the two - * spellings of `views` above — would be the author writing two answers and - * being shown neither. +/** WHAT DRAWS THIS PAGE — which, since the index came back to the app, is always the author. * - * `article` without `type` is the quiet one, and it is refused for the reason - * `idIn` beside the wrong `idFrom` is: nothing reads it there, so the author - * believes they have named the title field and the page they get is the - * generated form. */ + * Every view is HTML at `path`. There is no longer a second answer to choose between: the one page + * the platform draws is the ARTICLE at `/a/{slug}/{id}`, and that is a second address under this + * view rather than a different drawing of it. So `path` and `article` are not alternatives, and + * declaring both is the ordinary shape of a magazine. */ function viewSourceProblems(view: NormalizedView): string[] { - if (view.path !== undefined && view.type !== undefined) { - return [ - `${view.where} declares both \`path\` and \`type\`: a view is either HTML you wrote or a page the platform draws, and publishing would have to ` + - `choose one silently. Delete \`path\` to keep the ${view.type} page, or delete \`type\` to keep your own HTML.`, - ]; - } - if (view.path === undefined && view.type === undefined) { - return [ - `${view.where} declares neither \`path\` nor \`type\`, so there is nothing to draw. Name the HTML file with \`path\`, or ask for \`"type": "article"\`.`, - ]; - } - if (view.type === "article" && view.article === undefined) { - return [ - `${view.where} is \`"type": "article"\` but declares no \`article\` block, so nothing says which field is the title and which is the body. ` + - `Add "article": { "title": "title", "body": "body" }.`, - ]; - } - if (view.type === undefined && view.article !== undefined) { - return [ - `${view.where} declares an \`article\` block but no \`type\`: that block is read only by \`"type": "article"\`, so as written nothing names the ` + - `title or the body and the page drawn is the generated form. Add \`"type": "article"\`, or delete the block.`, - ]; - } - return []; + if (view.path !== undefined) return []; + return [ + `${view.where} names no \`path\`, so there is no page to publish. Every view is HTML you wrote: name the file, relative to the repository root.` + + (view.article === undefined + ? "" + : " The `article` block here says how to draw ONE article at `/a/{slug}/{id}`; the page that lists them is still yours."), + ]; } -/** An article view's collection: exactly one, because one page shows one - * running order. +/** WHICH collection holds the articles. * - * `collections` is a LIST for the HTML case, where a page may draw from - * several — and an article page cannot: which of three collections held the - * article at `/a/{slug}/{id}` would have no answer, and the id could name a - * row in more than one of them. */ + * One of them, because an article's URL is `/a/{slug}/{id}` with nothing in it to say which + * collection the id is in — and the same id could name a row in more than one. + * + * It used to be settled by refusing an article view that named more than one collection at all, + * which was cheap while the platform drew the whole page: there was nothing else on it. Now the + * page is the author's, and a magazine's index has every reason to read a second dataset — its + * sections, its masthead — so the choice is NAMED instead of counted. Left out where there is + * only one, because there the count still answers it and a key that can only repeat what is + * already there is one more thing to keep in step. */ +export function articleCid(view: { article?: { collection?: string | undefined } | undefined; collections: readonly string[] }): string | undefined { + if (view.article === undefined) return undefined; + return view.article.collection ?? (view.collections.length === 1 ? view.collections[0] : undefined); +} + function articleCollectionProblems(view: NormalizedView): string[] { - if (view.type === "article" && view.audience !== "public") { + if (view.article === undefined) return []; + if (view.audience !== "public") { // The public face only, for now. The member and roster tiers draw their // pages through a different bridge with its own intents, and an article // page there would be a second reader to keep in step for an audience that // has not asked for one. Refused rather than half-published: publishing it - // to a tier whose runtime ignores `type` would leave the staff looking at - // an empty page. + // to a tier whose runtime ignores it would leave the staff looking at a + // link that goes nowhere. + return [ + `${view.where} declares an \`article\` block with audience "${view.audience}". The article page is published under the PUBLIC entrance ` + + `(\`/a/{slug}/{id}\`) only; move the block to the public view, or delete it.`, + ]; + } + const named = view.article.collection; + if (named === undefined) { + if (view.collections.length === 1) return []; return [ - `${view.where} is \`"type": "article"\` with audience "${view.audience}". Platform-drawn pages are published for the PUBLIC face only; ` + - `give this view \`"audience": "public"\`, or write the page yourself with \`path\`.`, + `${view.where} declares an \`article\` block and names ${view.collections.length} collections, so nothing says which of them holds the articles — ` + + `and \`/a/{slug}/{id}\` has nothing in it to say either. Add \`"collection"\` to the \`article\` block.`, ]; } - if (view.type !== "article" || view.collections.length === 1) return []; + if (view.collections.includes(named)) return []; + const read = view.collections.map((cid) => `'${cid}'`).join(", "); return [ - `${view.where} is \`"type": "article"\` and names ${view.collections.length} collections. An article page shows ONE running order, and an article's ` + - `URL is \`/a/{slug}/{id}\` with nothing in it to say which collection the id is in. Name just the one that holds the articles.`, + `${view.where}.article.collection names '${named}', which is not in this view's \`collections\`. The article page is drawn from a dataset this ` + + `page reads, so the collection has to be one of them: ${read}.`, ]; } diff --git a/src/index.ts b/src/index.ts index 4827f3d..a0c0ef2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ export { // The pages an app shows, per audience: the declaration, where each audience's // documents live, and what the parent page needs in order to query for them. export { + articleCid, normalizeViews, participantScope, viewDocId, @@ -63,6 +64,7 @@ export { VIEW_ID_PATTERN, VIEW_TIER, type AppViewConfigDoc, + type ArticleFields, type NormalizedView, type NormalizedViewsResult, type ProjectedViewCollection, diff --git a/src/publishChecks.ts b/src/publishChecks.ts index 59b4706..52fca70 100644 --- a/src/publishChecks.ts +++ b/src/publishChecks.ts @@ -28,7 +28,7 @@ import type { CollectionFieldSpec, CollectionSchema } from "@mulmoclaude/core/collection"; import { isSafeCustomViewPath } from "@mulmoclaude/core/collection/server"; -import { declaresMoves, normalizeViews, participantScope, type NormalizedView, type ViewAudience } from "./appViews.js"; +import { articleCid, declaresMoves, normalizeViews, participantScope, type NormalizedView, 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"; @@ -1545,8 +1545,8 @@ const capIn = (map: Record | undefined, key: string): number | u * THE AUDIENCE IS THE LOAD-BEARING ONE. `maxBytes` is not a rule (see its declaration): publish * checks it and the host refuses the value before sending it, and neither of those binds somebody * writing straight to Firestore. What makes that acceptable is that the only people who may write - * an article are the participants a roster carries — people the owner invited by name. Let a - * collection with `type: "article"` be submitted to by the world and the cap becomes a comment. + * an article are the participants a roster carries — people the owner invited by name. Let an + * ARTICLE collection be submitted to by the world and the cap becomes a comment. * * EVERY TEXT FIELD THE PAGE DRAWS is capped, not only the body. Capping the body alone left the * long text one rename away: a contributor — or a `useSharedApp` agent that will not stop — puts @@ -1611,8 +1611,7 @@ function submitFor(app: AuthoredApp, cid: string): AuthoredSubmit | undefined { } function articleCostProblems(app: AuthoredApp, view: NormalizedView): string[] { - if (view.type !== "article") return []; - const cid = view.collections[0]; + const cid = articleCid(view); if (cid === undefined) return []; const submit = submitFor(app, cid); const rows = capIn(view.limit, cid); @@ -1661,19 +1660,19 @@ function articleCostProblems(app: AuthoredApp, view: NormalizedView): string[] { /** A colour for a page nobody draws. * - * `theme` is read by the runtime that DRAWS a page from the declaration, and only `views[].type` - * produces one. An app whose pages are all its own HTML styles them itself and never looks here, - * so the key would sit in the manifest meaning nothing — the same silent no-op `viewLiveProblems` - * refuses one key over. + * `theme` is read by the runtime that DRAWS a page from the declaration, and since the index went + * back to the app there is exactly one such page: the ARTICLE at `/a/{slug}/{id}`. An app whose + * pages are all its own HTML styles them itself and never looks here, so the key would sit in the + * manifest meaning nothing — the same silent no-op `viewLiveProblems` refuses one key over. * * It does NOT move the protocol, and that asymmetry is the point: a reader too old to know `hue` - * draws the page in its default colours, which is the page. A reader too old to know `type` draws - * the GENERATED FORM in a magazine's place, which is not. */ + * draws the article in its default colours, which is the article. A reader too old to know + * `article` sends every link ever shared to the index instead, which is not. */ function themeProblems(app: AuthoredApp, views: readonly NormalizedView[]): string[] { - if (app.theme === undefined || views.some((view) => view.type !== undefined)) return []; + if (app.theme === undefined || views.some((view) => view.article !== undefined)) return []; return [ - "theme sets a colour and no view declares `type`, so nothing draws a page from this app's declaration and the key does nothing. A page " + - "written as HTML carries its own colours. Delete `theme`, or publish a page the platform draws.", + "theme sets a colour and no view declares an `article` block, so nothing draws a page from this app's declaration and the key does nothing. " + + "A page written as HTML carries its own colours. Delete `theme`, or publish articles.", ]; } @@ -1941,15 +1940,15 @@ export function schemaRefProblems(app: AuthoredApp, schemas: { cid: string; sche * its own error rather than a second time here. */ function articleRefProblems(schemaOf: ReadonlyMap, view: AuthoredView, where: string): string[] { const { article } = view; - const cid = view.collections[0]; + const cid = articleCid(view); const schema = cid === undefined ? undefined : schemaOf.get(cid); - if (view.type !== "article" || article === undefined || schema === undefined) return []; + if (article === undefined || schema === undefined) return []; const fields = schema.fields ?? {}; 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 the index reads as a list of URL names" }, + { key: "title", field: article.title, missing: "the page falls back to the document id, so an article is headed by its URL name" }, { key: "body", field: article.body, missing: "every article renders EMPTY, which looks exactly like one nobody has written yet" }, - { key: "summary", field: article.summary, missing: "the index quietly shows the article's opening instead, and this declaration does nothing" }, + { key: "summary", field: article.summary, missing: "the article is drawn with no standfirst under its title, and this declaration does nothing" }, { key: "byline", field: article.byline, diff --git a/src/publishManifest.ts b/src/publishManifest.ts index 0e7cf05..bc595c7 100644 --- a/src/publishManifest.ts +++ b/src/publishManifest.ts @@ -476,31 +476,47 @@ const ViewZ = z audience: z.enum(VIEW_AUDIENCES), /** The HTML file this page is drawn from, relative to the repository root. * - * Optional since `type` exists, and EXACTLY ONE of the two is required — - * a view is either a page the author wrote or a page the platform draws. - * The pair is refused by `normalizeViews` rather than by zod, so the - * refusal can say which key to delete in the author's own words. */ + * REQUIRED — optional here and demanded by `normalizeViews`, so the + * refusal can say what the page is for in the author's own words rather + * than as a missing key. Every view is HTML the author wrote: the platform + * draws ONE page, and it is not a view (see `article` below). */ path: z.string().trim().min(1).optional(), - /** A page the PLATFORM draws from the declaration, instead of HTML. - * - * `article` is the first and, today, the only one: the collection named in - * `collections` holds articles, `article` below says which field is the - * title and which is the markdown body, and the runtime renders them — - * index at `/a/{slug}`, one article at `/a/{slug}/{id}`. - * - * IT IS NOT A SECOND DRAWING PATH. The prohibition in - * mulmoterminal's `plans/feat-shared-app-platform.md` is against a naive - * rendering of `public.read` living beside declared views; this is a - * DECLARED view, judged by the same gate, published to the same document, - * and an app that wants a bespoke index still writes `path` and gets the - * sandbox. What separates them is which side authored the page, which is - * the distinction that has always decided this. - * - * A reader that does not know this key would find no HTML and draw the - * GENERATED FORM in a magazine's place, so it moves the app's protocol - * major — see `protocolFor`. */ + /** REFUSED, and parsed only so that the refusal can be about it. + * + * `"type": "article"` used to mean "the platform draws this page instead of + * your HTML", and it took TWO addresses with it: the index at `/a/{slug}` + * and one article at `/a/{slug}/{id}`. The first of those was never the + * platform's to take — an index is a list of records, and every hand-written + * public page is handed the same datasets — so an app that published articles + * surrendered its whole public face to get them. See mulmoterminal's + * `plans/feat-shared-app-app-owned-index.md`. + * + * Now `path` draws `/a/{slug}` always, and the `article` block below draws + * `/a/{slug}/{id}`. Nothing is left for this key to say, and it is deleted + * rather than ignored: silently dropped, an author who wrote it would get a + * page they did not ask for and nothing anywhere would mention the key. */ type: z.literal("article").optional(), - /** Which field of an article is which, for `type: "article"`. + /** THE ARTICLE PAGE: this collection's records are markdown, drawn by the + * platform at `/a/{slug}/{id}`, and these are the fields it reads. + * + * The presence of this block is the whole declaration — there is no + * accompanying switch. What it turns on is a SECOND address under the app's + * public entrance, not a different drawing of `path`'s: an app declaring it + * still writes its own index, and links to an article with `view.open`. + * + * Why the platform draws that one page and not the rest: markdown is + * rendered on the host's own origin without a sandbox, so the render is a + * security boundary (mulmoserver's `articleMarkdown.ts`) and cannot be + * handed to a page. It is also the URL that gets shared, which has to draw + * signed out. + * + * It carries the app's protocol major, as `type` did before it and for a + * weaker reason: a reader that does not know it draws `path` — the app's own + * page, understood since 1.0.0 — so what it gets wrong is `/a/{slug}/{id}`, + * where it draws the index instead of the article somebody was linked to. + * Every shared link lands on the wrong page. That is a degradation rather + * than a different app, and the major is kept anyway because it costs these + * apps nothing: `idFrom: "slug"` already stamps them 2.0.0. See `protocolFor`. * * The DATE is deliberately absent: an article is ordered and dated by * `public.submit[cid].stampField`, the one field the rules pin to the @@ -517,6 +533,12 @@ const ViewZ = z // all legal fields that an author may reasonably want to draw an article from. Narrowing // them here would refuse declarations the rules and the runtime both handle, and the // refusal would be about a grammar that governs something else entirely. + /** WHICH collection holds them — a collection id, and so `NameZ` rather than the + * field-name shape its neighbours have. + * + * Optional, and required by `articleCollectionProblems` exactly when `collections` names + * more than one: with a single dataset the count already answers it. */ + collection: NameZ.optional(), title: z.string().trim().min(1), body: z.string().trim().min(1), summary: z.string().trim().min(1).optional(), diff --git a/src/publishProject.ts b/src/publishProject.ts index 50d875c..0c90b03 100644 --- a/src/publishProject.ts +++ b/src/publishProject.ts @@ -43,6 +43,7 @@ import { normalizeViews, participantScope, type ArticleFields, + articleCid, VIEW_CONFIG_ID, VIEW_TIER, viewDocId, @@ -150,13 +151,15 @@ export interface PublishedConfigDoc extends Record { collections: string[]; live?: string[]; limit?: Record; - /** A page the RUNTIME draws from the declaration, instead of the HTML this document is paired - * with — `views[].type`. Declared here as well as emitted, because a consumer that has to cast - * to reach a key is a consumer that will read it wrong: this is the type the published - * document actually has. */ - type?: "article" | undefined; - /** Which field of an article is which, for `type: "article"`. */ - article?: ArticleFields | undefined; + /** THE ARTICLE PAGE the runtime draws at `/a/{slug}/{id}` — beside the HTML this document is + * paired with, never instead of it. Declared here as well as emitted, because a consumer that + * has to cast to reach a key is a consumer that will read it wrong: this is the type the + * published document actually has. + * + * `collection` is always present here and optional in the manifest: the compiler resolves the + * single-collection case rather than leaving every reader to re-derive it, which is the sort + * of inference two readers eventually disagree about. */ + article?: (ArticleFields & { collection: string }) | undefined; /** The app's own hue, 0-359, for the page the runtime draws — `theme.hue`. * * ABSENT IS A REAL STATE and not a missing value: an app that declares no colour is drawn in @@ -380,20 +383,20 @@ function publicViewProjection( collections: string[]; live?: string[]; limit?: Record; - type?: "article" | undefined; - article?: ArticleFields | undefined; + article?: (ArticleFields & { collection: string }) | undefined; hue?: number | undefined; } { const capped = view.collections.map((cid) => ({ cid, limit: limitFor(app, view, { cid, scope: "all" }).limit })); const limit = Object.fromEntries(capped.flatMap((entry) => (entry.limit === undefined ? [] : [[entry.cid, entry.limit]]))); + const cid = articleCid(view); return { collections: view.collections, - // WHAT DRAWS THE PAGE, when it is not the HTML at `config/view`. A reader - // older than this contract does not know these two keys — which is why an - // app carrying them is stamped a higher major and refused whole, rather - // than drawn as the generated form (`protocolFor`). - ...(view.type === undefined ? {} : { type: view.type }), - ...(view.article === undefined ? {} : { article: view.article }), + // THE SECOND ADDRESS: one article at `/a/{slug}/{id}`, drawn from the declaration rather than + // from the HTML at `config/view` — which goes on drawing `/a/{slug}` whatever this says. A + // reader older than this contract does not know the key, which is why an app carrying it is + // stamped a higher major and refused whole rather than sending every shared link to the index + // (`protocolFor`). + ...(view.article === undefined || cid === undefined ? {} : { article: { ...view.article, collection: cid } }), // The app's hue, projected ONTO THE VIEW rather than beside it. What reads it is the runtime // drawing this page, and that runtime is handed the view — a colour parked at the top of the // document would be one more thing every reader has to know to look for, for a value that diff --git a/src/view/bridge.ts b/src/view/bridge.ts index 02ea9e7..ef2d8c5 100644 --- a/src/view/bridge.ts +++ b/src/view/bridge.ts @@ -1,4 +1,4 @@ -import type { LookupAsk, PendingSubmit, ViewDataset, ViewNotice, ViewSubmitConfig } from "./message.js"; +import type { LookupAsk, OpenAsk, PendingSubmit, ViewDataset, ViewNotice, ViewSubmitConfig } from "./message.js"; import { viewParent } from "./parent.js"; // The parent's side of the conversation with a sandboxed view. @@ -126,6 +126,12 @@ export interface BridgePorts { * OPTIONAL, and a host without it answers `known: false`. Absence is * "nobody looked", never "you have not answered". */ lookup?: ((ask: LookupAsk) => Promise<{ found: boolean; record?: Record }>) | undefined; + /** Take the reader to one ARTICLE, which a sandboxed page cannot do for itself — see + * `ViewParentPorts.navigate`. Returns whether it navigated. + * + * OPTIONAL, and a host without it answers `no-navigation`: nothing about the ask was wrong, this + * host simply does not go anywhere. */ + navigate?: ((ask: OpenAsk) => boolean | Promise) | undefined; /** Somewhere to put what the frame says about itself — an uncaught error, a * rejected promise, a modal the sandbox ignored. * @@ -169,6 +175,7 @@ export const viewBridge = (ports: BridgePorts, config: () => ViewSubmitConfig | // that was never there, and `ViewParentPorts` distinguishes them. ...(ports.mine === undefined ? {} : { mine: ports.mine }), ...(ports.lookup === undefined ? {} : { lookup: ports.lookup }), + ...(ports.navigate === undefined ? {} : { navigate: ports.navigate }), ...(ports.notice === undefined ? {} : { notice: ports.notice }), submit: ports.submit, // The old shape had nowhere to put a defect of the host's own, which is exactly what diff --git a/src/view/index.ts b/src/view/index.ts index 2554a6c..379b355 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -24,12 +24,17 @@ export { readLookupMessage, NOTICE_DETAIL_LIMIT, readNotice, + readOpenMessage, readSubmitMessage, VIEW_NOTICE_CODES, type LookupAnswer, type LookupAsk, type LookupRead, type LookupRefusal, + type OpenAnswer, + type OpenAsk, + type OpenRead, + type OpenRefusal, type PendingSubmit, type SubmitRead, type SubmitRefusal, diff --git a/src/view/message.ts b/src/view/message.ts index c3bde64..58c9fa0 100644 --- a/src/view/message.ts +++ b/src/view/message.ts @@ -25,6 +25,10 @@ export interface SubmitDeclaration { export interface ViewSubmitConfig { submit?: Record | undefined; + /** The collection whose records the platform draws as ARTICLES, when this app has one — the only + * cid `view.open` may name. Absent on every app that publishes none, where an open has no page + * to reach and is refused. */ + articleCid?: string | undefined; } /** One dataset, as the view receives it. */ @@ -186,6 +190,63 @@ export interface LookupAnswer { record?: Record; } +/** A view asking for ONE article to be opened: which collection, and which record. */ +export interface OpenAsk { + requestId: string; + cid: string; + id: string; +} + +/** WHAT AN ID MAY BE, on its way into a URL. + * + * The host builds `/a/{slug}/{id}` out of this, so the grammar is the defence that does not depend + * on one host remembering to encode: a `/` would address a different route, a `.` at the front is + * a relative segment, and an empty string is the index. Wider than the `slug` grammar + * (`publishManifest`'s `idFrom: "slug"`) on purpose — an app whose articles have generated ids has + * the same claim on a link — and narrower than a Firestore document id, which may hold anything at + * all. */ +const OPEN_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u; + +/** Why an article will not be opened. `not-an-open` is the one that means THIS IS NOT ONE — nobody + * is waiting on it, and the caller reads the message as something else. The other two mean it is + * one and will not be served, so the page's promise has to be settled with them. */ +export type OpenRefusal = "not-an-open" | "invalid-open" | "unknown-collection"; + +export type OpenRead = { ok: true; ask: OpenAsk } | { ok: false; reason: OpenRefusal; requestId: string }; + +/** Read an `open`, against the ONE collection this app draws articles from. + * + * A cid that is not that collection is refused rather than navigated to: `/a/{slug}/{id}` has + * nothing in it to say which collection an id belongs to, so the host would send the visitor to a + * page that reads a record of a different collection, or none — an address that looks broken. + * + * `articleCid` is null on an app that publishes no articles, where every open is refused: there is + * no such page to reach. */ +export const readOpenMessage = (data: unknown, articleCid: string | null): OpenRead => { + if (!isRecord(data) || data.type !== VIEW_MESSAGE.open || typeof data.requestId !== "string" || data.requestId === "") { + return { ok: false, reason: "not-an-open", requestId: "" }; + } + if (typeof data.cid !== "string" || typeof data.id !== "string" || !OPEN_ID.test(data.id)) { + return { ok: false, reason: "invalid-open", requestId: data.requestId }; + } + if (articleCid === null || data.cid !== articleCid) { + return { ok: false, reason: "unknown-collection", requestId: data.requestId }; + } + return { ok: true, ask: { requestId: data.requestId, cid: data.cid, id: data.id } }; +}; + +/** What the host did about an `open`. + * + * `opened: false` is not always a refusal, which is why the reason rides beside it: a host that + * offers no navigation at all — the author's preview pane, where there is no browser history to + * push onto — answers `no-navigation`, and the honest thing for a page to do about that is + * nothing. In production the answer usually never arrives at all: the host navigates, this + * document is replaced, and the promise goes with it. That is what a link does. */ +export interface OpenAnswer { + opened: boolean; + reason?: OpenRefusal | "no-navigation"; +} + /** What a frame may say about ITSELF, and the only codes a parent will hear. * * A FIXED list, matched exactly. The frame holds untrusted HTML, so `code` diff --git a/src/view/parent.ts b/src/view/parent.ts index 2f359f6..e689d22 100644 --- a/src/view/parent.ts +++ b/src/view/parent.ts @@ -3,8 +3,11 @@ import { isRecord, readLookupMessage, readNotice, + readOpenMessage, readSubmitMessage, type LookupAsk, + type OpenAnswer, + type OpenAsk, type PendingSubmit, type SubmitRead, type ViewDataset, @@ -179,6 +182,16 @@ export interface ViewParentPorts { * OPTIONAL, and a host without it answers `known: false`. Absence is "nobody looked", never "you * have not answered". */ lookup?: ((ask: LookupAsk) => Promise<{ found: boolean; record?: Record }>) | undefined; + /** Take the reader to one ARTICLE — the navigation a sandboxed page cannot perform for itself. + * + * Returns whether it actually navigated. A host that WOULD but did not — the author's preview + * pane, which has no browser history to push onto — returns false and the page is told + * `no-navigation` rather than a refusal, because nothing about the ask was wrong. + * + * OPTIONAL, and a host without it answers the same way. It is never told the URL: it holds the + * slug and builds the address from `{ cid, id }`, which is what keeps a page from sending a + * visitor out of the app it is part of. */ + navigate?: ((ask: OpenAsk) => boolean | Promise) | undefined; /** What to do about an intent. Omitted, every one is refused {@link READ_ONLY}. * * A GETTER: a host holding this in a reactive prop can have it replaced under the parent, and a @@ -233,6 +246,10 @@ export const viewParent = (ports: ViewParentPorts, config: () => ViewSubmitConfi const answerLookup = (requestId: string, found: { known: boolean; found: boolean; record?: Record }) => { post({ type: VIEW_MESSAGE.lookupResult, requestId, ...found }); }; + /** The answer to an `open`, on its own message name for `answerLookup`'s reason. */ + const answerOpen = (requestId: string, done: OpenAnswer) => { + post({ type: VIEW_MESSAGE.openResult, requestId, ...done }); + }; const sendState = () => { // Nothing before the document has answered on the channel — and the channel belongs to the @@ -270,6 +287,32 @@ export const viewParent = (ports: ViewParentPorts, config: () => ViewSubmitConfi answerLookup(ask.requestId, { known: true, ...found }); }; + /** Take the reader to an article. + * + * Answered EITHER WAY and as early as possible, which is the opposite of the usual arrangement + * here: a successful navigation replaces this document, so the answer a page is most likely to + * receive is a refusal. Sent before the port is called, the reply would race the navigation; sent + * after it, a host that navigates synchronously never gets to send one. So the port is called and + * the answer follows if there is still anything to send it on. + * + * A host that throws is a defect of the host's own, and the page is told `no-navigation` — which + * is exactly true, and is the one thing it can act on. */ + const go = async (ask: OpenAsk) => { + const port = ports.navigate; + if (port === undefined) { + answerOpen(ask.requestId, { opened: false, reason: "no-navigation" }); + return; + } + const opened = await Promise.resolve() + .then(() => port(ask)) + .catch((error: unknown) => { + ports.defect(error, ask.requestId); + return false; + }); + if (opened) return; + answerOpen(ask.requestId, { opened: false, reason: "no-navigation" }); + }; + /** A submission the frame sent, once it is known to be one. * * `busy` is the one refusal that is not about the message: a second request while a confirmation @@ -357,6 +400,17 @@ export const viewParent = (ports: ViewParentPorts, config: () => ViewSubmitConfi answerLookup(asked.requestId, { known: false, found: false }); return; } + const wanted = readOpenMessage(data, config()?.articleCid ?? null); + if (wanted.ok) { + void go(wanted.ask); + return; + } + if (wanted.reason !== "not-an-open") { + // Answered as an OPEN, for the reason a refused lookup is answered as a lookup: the page is + // waiting on `view.open`, which settles on `openResult` and reads `{ opened }`. + answerOpen(wanted.requestId, { opened: false, reason: wanted.reason }); + return; + } if (data.type === VIEW_MESSAGE.submit) { offer(readSubmitMessage(data, config())); return; diff --git a/src/view/protocol.ts b/src/view/protocol.ts index 208853e..a29c945 100644 --- a/src/view/protocol.ts +++ b/src/view/protocol.ts @@ -35,6 +35,18 @@ export const VIEW_MESSAGE = { * their uid by the parent, so a page passing somebody else's key learns * nothing about anybody else. */ lookup: "mc-public-view:lookup", + /** A view asking the host to OPEN one article — `/a/{slug}/{id}`. + * + * The one thing a sandboxed page cannot do for itself. The frame is + * `sandbox="allow-scripts"` with no `allow-top-navigation` and no + * `allow-popups`, so an `` out of it is inert; and since the app owns + * its index again, a magazine's front page is nothing but links to articles. + * + * IT CARRIES NO URL. The page names a collection and a record; the host holds + * the slug and builds the address. So a page cannot send a visitor anywhere + * except into an article of the app they are already reading — a property of + * the shape rather than of a check. */ + open: "mc-public-view:open", /** The answer to a `submit` OR an `intent` — one name, because the view * settles both from the same map keyed by `requestId`. */ result: "mc-public-view:submitResult", @@ -43,6 +55,10 @@ export const VIEW_MESSAGE = { * page that got one where it expected the other would read "not found" as * "refused". */ lookupResult: "mc-public-view:lookupResult", + /** The answer to an `open`. Its own name for `lookupResult`'s reason: it says + * `{ opened }`, and a page told `{ ok: false }` instead would report a refusal + * where the honest answer is "this host does not navigate". */ + openResult: "mc-public-view:openResult", /** The frame reporting something about ITSELF that the browser would * otherwise swallow: an uncaught error, a rejected promise nobody handled, * a modal the sandbox ignores. diff --git a/src/view/srcdoc.ts b/src/view/srcdoc.ts index c3385d2..fb513a1 100644 --- a/src/view/srcdoc.ts +++ b/src/view/srcdoc.ts @@ -110,6 +110,15 @@ const channelScript = (): string => ` // failed. NOT the same as \`found: false\`, and the page has to keep them // apart -- see the note on the wire name. if (settle) { pending.delete(data.requestId); settle({ known: data.known === true, found: data.found === true, record: data.record }); } + return; + } + if (data.type === ${JSON.stringify(VIEW_MESSAGE.openResult)}) { + const settle = pending.get(data.requestId); + // Usually never arrives: a host that navigates replaces this document and + // the promise goes with it. What settles here is the case where it did + // NOT -- so \`opened\` is the honest word, and the page decides whether + // \`reason\` is worth showing anyone. + if (settle) { pending.delete(data.requestId); settle({ opened: data.opened === true, reason: data.reason }); } } }; window.addEventListener("message", (event) => { @@ -371,6 +380,25 @@ ${gestureScript()} mine(cid, key) { return request({ type: ${JSON.stringify(VIEW_MESSAGE.lookup)}, cid, key }); }, + /** Take the reader to ONE ARTICLE -- \`/a/{slug}/{id}\`, the page the + * platform draws from this app's \`article\` declaration. + * + * THE ONLY WAY OUT OF THIS FRAME. It is sandboxed with scripts and nothing + * else, so a link out of it does nothing at all: no top navigation, no + * popup. Draw an entry however you like and call this from its click + * handler. + * + * It names a RECORD, never a URL. The host holds the app's slug and builds + * the address, so this cannot send anybody anywhere but into an article of + * the app they are reading. + * + * Answers { opened, reason } -- and usually does not answer at all, because + * a navigation that happened took this document with it. \`opened: false\` + * with \`no-navigation\` is a host that does not navigate, such as the + * author's preview pane; there is nothing for a page to do about it. */ + open(cid, id) { + return request({ type: ${JSON.stringify(VIEW_MESSAGE.open)}, cid, id }); + }, transition(cid, itemId, to) { return request({ type: ${JSON.stringify(VIEW_MESSAGE.intent)}, kind: "transition", cid, itemId, to }); }, diff --git a/test/test_appProtocol.ts b/test/test_appProtocol.ts index 4be45fb..d5b3a91 100644 --- a/test/test_appProtocol.ts +++ b/test/test_appProtocol.ts @@ -33,12 +33,15 @@ test("adding a key an older reader may ignore does not move the number", () => { }); test("a view the reader must UNDERSTAND moves the major, for that app alone", () => { - // The other half, and the reason the stamp is per app again. `views[].type` replaces the HTML a - // public page is drawn from: a reader that does not know it finds none, concludes the app - // publishes no view, and draws the GENERATED FORM in a magazine's place. Nothing errors — the - // visitor is simply shown a different app — so the major has to move and the older reader has to - // refuse. - assert.equal(protocolFor({ views: [{ type: "article" }] }), "2.0.0"); + // The other half, and the reason the stamp is per app again. `views[].article` turns on a SECOND + // address under the app's public entrance, `/a/{slug}/{id}`: a reader that does not know it draws + // the app's own index there instead, so every link ever shared to an article lands on the wrong + // page. Nothing errors, so the major has to move and the older reader has to refuse. + assert.equal(protocolFor({ views: [{ article: { title: "title", body: "body" } }] }), "2.0.0"); + // The KEY that is asked about is `article` and not the retired `type`, which no reader will ever + // see again — a view carrying it is refused at the gate, so a protocol derived from it would be + // derived from a declaration that cannot be published. + assert.equal(protocolFor({ views: [{}] }), APP_PROTOCOL_BASE); // And ONLY for that app. Stamping every app 2.0.0 would make every deployed reader refuse every // app published after this build, including ones whose documents did not change at all. assert.equal(protocolFor({ views: [{}] }), "1.0.0"); @@ -89,8 +92,8 @@ test("a slug id moves the major on its own, with no article view anywhere", () = // Stamped 1.0.0, that older reader would have gone ahead and done it. assert.equal(protocolFor({ public: { submit: { articles: { idFrom: "slug" } } } }), APP_PROTOCOL); // And the two are INDEPENDENT: neither implies the other, so both are asked. An app may name its - // records by slug and publish its own HTML, or draw an article index over generated ids. - assert.equal(protocolFor({ views: [{ type: "article" }], public: { submit: { notes: { idFrom: "auto" } } } }), APP_PROTOCOL); + // records by slug and publish no articles, or publish articles over generated ids. + assert.equal(protocolFor({ views: [{ article: { title: "title", body: "body" } }], public: { submit: { notes: { idFrom: "auto" } } } }), APP_PROTOCOL); assert.equal(protocolFor({ public: { submit: { bookings: { idFrom: "field" }, notes: {} } } }), APP_PROTOCOL_BASE); assert.equal(protocolFor({ public: { submit: {} } }), APP_PROTOCOL_BASE); }); diff --git a/test/test_appViews.ts b/test/test_appViews.ts index dbaa87a..e1d223f 100644 --- a/test/test_appViews.ts +++ b/test/test_appViews.ts @@ -8,7 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { normalizeViews, participantScope, viewDocId, writeFor, PUBLIC_VIEW_ID } from "../src/appViews.js"; +import { articleCid, normalizeViews, participantScope, viewDocId, writeFor, PUBLIC_VIEW_ID } from "../src/appViews.js"; import { projectAppViews } from "../src/publishProject.js"; import { AuthoredAppZ } from "../src/publishManifest.js"; import { byText } from "../src/byText.js"; @@ -513,20 +513,26 @@ test("a collection whose ONLY writable thing is the writer's delete still gets a assert.deepEqual(entry?.writers, [OWNER]); }); -// --- Platform-drawn pages (`views[].type`) ------------------------------------------------- +// --- The ARTICLE page (`views[].article`) --------------------------------------------------- // -// A view is either HTML the author wrote or a page the platform draws. These pin the four ways of -// failing to say which, plus the one shape an article page has to have — because every one of them -// fails by publishing SOMETHING rather than by erroring, and what a visitor then sees is a -// different app. - -const ARTICLE = { id: "public", audience: "public", type: "article", collections: ["articles"], article: { title: "title", body: "body", summary: "summary" } }; +// The platform draws ONE page for an app: an article at `/a/{slug}/{id}`. The index at `/a/{slug}` +// is the author's HTML and always was theirs to write — `type: "article"` took it, which left an app +// that publishes articles with no public face of its own. These pin the retirement of that key and +// the shape the block has to have, because every one of them fails by publishing SOMETHING rather +// than by erroring, and what a visitor then sees is a different app. + +const ARTICLE = { + id: "public", + audience: "public", + path: "views/home.html", + collections: ["articles"], + article: { title: "title", body: "body", summary: "summary" }, +}; -test("an article view normalizes with its field mapping", () => { +test("an article view normalizes with its field mapping, BESIDE the page the author wrote", () => { const result = normalizeViews(app({ views: [ARTICLE] })); assert.ok(result.ok); - assert.equal(result.views[0]?.type, "article"); - assert.equal(result.views[0].path, undefined, "a platform page names no file"); + assert.equal(result.views[0]?.path, "views/home.html", "the app still draws its own index"); assert.deepEqual(result.views[0].article, { title: "title", body: "body", summary: "summary" }); }); @@ -541,34 +547,62 @@ test("an article may name the field its byline is in, and most do not", () => { assert.equal("byline" in (plain.views[0]?.article ?? {}), false); }); -test("refuses a view that declares both a path and a type", () => { - refuses(problemsOf({ views: [{ ...ARTICLE, path: "views/public.html" }] }), "both `path` and `type`"); +test("refuses `type`, and says to add the page it used to take", () => { + // Refused rather than ignored. Dropped silently, an author who wrote it gets a page they did not + // ask for — and the refusal has to name what to ADD, because deleting the key alone leaves the + // view with nothing to draw and the next refusal they see would be about `path`. + const problems = problemsOf({ + views: [{ id: "public", audience: "public", type: "article", collections: ["articles"], article: { title: "t", body: "b" } }], + }); + refuses(problems, "no longer exists"); + refuses(problems, '`"path"` naming the HTML that lists them'); }); -test("refuses a view that declares neither", () => { - refuses(problemsOf({ views: [{ id: "public", audience: "public", collections: ["articles"] }] }), "neither `path` nor `type`"); +test("refuses a view that names no path, now that every view is HTML", () => { + const problems = problemsOf({ views: [{ id: "public", audience: "public", collections: ["articles"] }] }); + refuses(problems, "names no `path`"); }); -test("refuses an article view with no article block", () => { - refuses(problemsOf({ views: [{ id: "public", audience: "public", type: "article", collections: ["articles"] }] }), "no `article` block"); +test("tells an article view with no path that the page listing them is still its own", () => { + // The same refusal with the sentence that answers the question it raises: an author who declared + // an `article` block may reasonably believe the platform is drawing the whole thing. + refuses(problemsOf({ views: [{ ...ARTICLE, path: undefined }] }), "the page that lists them is still yours"); }); -test("refuses an article block with no type, which would silently draw the form", () => { - // The quiet one. Everything parses, publish succeeds, and the author believes they have named the - // title field while the visitor is shown the generated form. - refuses( - problemsOf({ views: [{ id: "public", audience: "public", path: "views/public.html", collections: ["articles"], article: { title: "t", body: "b" } }] }), - "`article` block but no `type`", +test("an article block may say WHICH collection holds the articles", () => { + const result = normalizeViews( + app({ views: [{ ...ARTICLE, collections: ["articles", "sections"], article: { collection: "articles", title: "t", body: "b" } }] }), ); + assert.ok(result.ok); + const view = result.views[0]; + assert.ok(view); + assert.equal(articleCid(view), "articles"); }); -test("refuses an article view over more than one collection", () => { - // `/a/{slug}/{id}` carries nothing that says which collection the id is in. - refuses(problemsOf({ views: [{ ...ARTICLE, collections: ["articles", "notes"] }] }), "shows ONE running order"); +test("and need not, where there is only one to mean", () => { + const result = normalizeViews(app({ views: [ARTICLE] })); + assert.ok(result.ok); + const view = result.views[0]; + assert.ok(view); + assert.equal(articleCid(view), "articles"); +}); + +test("refuses an article view over several collections that does not say which", () => { + // `/a/{slug}/{id}` carries nothing that says which collection the id is in. Several collections + // are allowed now — an index has every reason to read its sections too — so the answer is NAMED + // rather than counted. + refuses(problemsOf({ views: [{ ...ARTICLE, collections: ["articles", "notes"] }] }), "which of them holds the articles"); +}); + +test("refuses an article collection this page does not read", () => { + refuses( + problemsOf({ views: [{ ...ARTICLE, collections: ["articles"], article: { collection: "notes", title: "t", body: "b" } }] }), + "not in this view's `collections`", + ); }); -test("refuses an article view published to a members' tier", () => { - refuses(problemsOf({ views: [{ ...ARTICLE, id: "desk", audience: "member" }] }), "PUBLIC face only"); +test("refuses an article block on a members' tier", () => { + refuses(problemsOf({ views: [{ ...ARTICLE, id: "desk", audience: "member" }] }), "PUBLIC entrance"); }); // --- What a submitter may CORRECT (`selfUpdate`) -------------------------------------------- diff --git a/test/test_publishChecks.ts b/test/test_publishChecks.ts index e1d80d8..046dc41 100644 --- a/test/test_publishChecks.ts +++ b/test/test_publishChecks.ts @@ -1692,7 +1692,7 @@ const magazineDraft = (article: ArticleMap, maxBytes: Record): R }, }, }, - views: [{ id: "public", audience: "public", type: "article", collections: ["articles"], article }], + views: [{ id: "public", audience: "public", path: "views/home.html", collections: ["articles"], article }], }); /** The magazine's problems, with the field mapping — and the length caps — the caller wants to @@ -1772,7 +1772,7 @@ const magazinePage = (edit: (draft: { submit: Record; view: Rec const view: Record = { id: "public", audience: "public", - type: "article", + path: "views/home.html", collections: ["articles"], // 15 x 60,000 = 900,000 bytes, just inside the ceiling — and a realistic pair: a Japanese // magazine article of ordinary length measures about 60 KB. @@ -1906,7 +1906,7 @@ test("names the COLLECTION when it is called after an Object prototype key and h { id: "public", audience: "public", - type: "article", + path: "views/home.html", collections: ["constructor"], article: { title: "title", body: "prose" }, limit: { constructor: 10 }, @@ -1978,7 +1978,9 @@ test("refuses an index whose COLLECTION is named after an Object prototype key", }, }, }, - views: [{ id: "public", audience: "public", type: "article", collections: ["constructor"], article: { title: "title", body: "prose" }, limit: {} }], + views: [ + { id: "public", audience: "public", path: "views/home.html", collections: ["constructor"], article: { title: "title", body: "prose" }, limit: {} }, + ], }), [{ cid: "constructor", primaryKey: "id" }], OWNER, @@ -2048,7 +2050,7 @@ test("names the collection when a magazine has no submit block, rather than the app({ collections: { articles: { statusField: "status", transitions: { initial: ["published"] } } }, public: { enabled: true, read: ["articles"] }, - views: [{ id: "public", audience: "public", type: "article", collections: ["articles"], article: { title: "title", body: "prose" } }], + views: [{ id: "public", audience: "public", path: "views/home.html", collections: ["articles"], article: { title: "title", body: "prose" } }], }), [{ cid: "articles", primaryKey: "id" }], OWNER, diff --git a/test/test_publishProject.ts b/test/test_publishProject.ts index ab88c61..94e44eb 100644 --- a/test/test_publishProject.ts +++ b/test/test_publishProject.ts @@ -305,13 +305,13 @@ test("an app using uidField is stamped the same contract as one that does not", }); test("an app with an article view is stamped the newer contract, and it alone", () => { - // The reader must UNDERSTAND `views[].type` to be correct — without it there is no HTML to find, - // so an older build concludes the app publishes no view and draws the generated form. That is a - // different app on the visitor's screen with nothing erroring, which is what the major is for. + // The reader must UNDERSTAND `views[].article` to be correct: without it there is no second + // address, so an older build draws the app's own index at `/a/{slug}/{id}` and every link ever + // shared to an article lands on the wrong page, with nothing erroring. const app = AuthoredAppZ.parse({ ...authored(), public: { enabled: true, read: ["articles"], submit: {} }, - views: [{ id: "public", audience: "public", type: "article", collections: ["articles"], article: { title: "title", body: "body" } }], + views: [{ id: "public", audience: "public", path: "views/home.html", collections: ["articles"], article: { title: "title", body: "body" } }], }); assert.equal(projectApp(app, [], STAMP, null).config.protocol, APP_PROTOCOL); assert.notEqual(APP_PROTOCOL, APP_PROTOCOL_BASE); @@ -327,23 +327,61 @@ test("the app's hue reaches the drawn page, and leaves the protocol alone", () = ...authored(), theme: { hue: 200 }, public: { enabled: true, read: ["articles"], submit: {} }, - views: [{ id: "public", audience: "public", type: "article", collections: ["articles"], article: { title: "title", body: "body" } }], + views: [{ id: "public", audience: "public", path: "views/home.html", collections: ["articles"], article: { title: "title", body: "body" } }], }); const config = projectApp(withHue, [], STAMP, null).config; assert.equal(config.view?.hue, 200); // NOT a protocol move, and the asymmetry is the point: a reader too old to know `hue` draws the - // page in its own colours, which is still the page. One too old to know `type` draws the - // generated form in a magazine's place, which is not — so that one moves the major and this + // article in its own colours, which is still the article. One too old to know `article` sends + // every shared link to the index instead, which is not — so that one moves the major and this // does not. Pinned here because the two keys arrive on the same document. assert.equal(config.protocol, APP_PROTOCOL); }); +test("the projected article names its collection, whether or not the author had to", () => { + // RESOLVED ONCE, here, rather than by every reader re-deriving "the only collection this view + // names" — the sort of inference two readers eventually disagree about, on a document neither of + // them can ask about. + const one = projectApp( + AuthoredAppZ.parse({ + ...authored(), + public: { enabled: true, read: ["articles"], submit: {} }, + views: [{ id: "public", audience: "public", path: "views/home.html", collections: ["articles"], article: { title: "title", body: "body" } }], + }), + [], + STAMP, + null, + ).config; + assert.equal(one.view?.article?.collection, "articles"); + + const several = projectApp( + AuthoredAppZ.parse({ + ...authored(), + public: { enabled: true, read: ["articles", "sections"], submit: {} }, + views: [ + { + id: "public", + audience: "public", + path: "views/home.html", + collections: ["articles", "sections"], + article: { collection: "articles", title: "title", body: "body" }, + }, + ], + }), + [], + STAMP, + null, + ).config; + assert.equal(several.view?.article?.collection, "articles"); + assert.deepEqual(several.view.collections, ["articles", "sections"], "and the index still reads both"); +}); + test("an app that declares no hue publishes the document it published before the key existed", () => { const config = projectApp( AuthoredAppZ.parse({ ...authored(), public: { enabled: true, read: ["articles"], submit: {} }, - views: [{ id: "public", audience: "public", type: "article", collections: ["articles"], article: { title: "title", body: "body" } }], + views: [{ id: "public", audience: "public", path: "views/home.html", collections: ["articles"], article: { title: "title", body: "body" } }], }), [], STAMP, diff --git a/test/test_viewOpen.ts b/test/test_viewOpen.ts new file mode 100644 index 0000000..a0691a8 --- /dev/null +++ b/test/test_viewOpen.ts @@ -0,0 +1,216 @@ +// THE ONE WAY OUT OF THE FRAME. +// +// A view is `sandbox="allow-scripts"` and nothing else — no `allow-top-navigation`, no +// `allow-popups` — so an `` written in a page is inert. That was survivable while the +// platform drew a magazine's index itself. It is not survivable now that the index is the app's +// own HTML, because a front page is nothing but links to articles. +// +// So the page asks and the host navigates. Everything below is about the two properties that makes +// it safe to hand a sandboxed document: it names a RECORD rather than a URL, and it can only name +// the one collection this app draws articles from. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; + +import { viewBridge, type BridgeCells, type Channel } from "../src/view/bridge.js"; +import { readOpenMessage } from "../src/view/message.js"; +import { VIEW_MESSAGE } from "../src/view/protocol.js"; +import { publicViewBootstrap } from "../src/view/srcdoc.js"; + +const NONCE = "nonce-1"; +const ready = { type: VIEW_MESSAGE.ready, nonce: NONCE }; +const CONFIG = { submit: { articles: { createFields: ["title"] } }, articleCid: "articles" }; + +const cells = (): BridgeCells => ({ pending: { value: null }, sending: { value: false }, readied: { value: false } }); + +const fakeChannel = () => { + const posted: Record[] = []; + let handler: ((data: unknown) => void) | null = null; + const channel: Channel = { + post: (message) => posted.push(message), + onMessage: (fn) => { + handler = fn; + }, + close: () => {}, + }; + return { channel, posted, send: (data: unknown) => handler?.(data) }; +}; + +const ask = (fields: Record) => ({ type: VIEW_MESSAGE.open, requestId: "r1", cid: "articles", id: "my-first-post", ...fields }); + +/** A bridge with the handshake already done, so a test can go straight to the ask. */ +const opened = (ports: Partial[0]>, config: Record = CONFIG) => { + const far = fakeChannel(); + const bridge = viewBridge( + { channel: () => far.channel, submit: async () => ({ ok: true }), state: () => ({}), ...ports }, + () => config, + () => NONCE, + cells(), + ); + bridge.receive(ready); + far.send({ nonce: NONCE }); + far.posted.length = 0; // the state message; not what these tests are about + return { bridge, far }; +}; + +const settled = async (far: ReturnType) => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return far.posted.find((message) => message.type === VIEW_MESSAGE.openResult); +}; + +test("the host is told which record, and builds the address itself", async () => { + // THE PROPERTY THIS SHAPE EXISTS FOR. A page that could name a URL could send a visitor anywhere; + // naming a record, it can only reach an article of the app they are already reading, and that is + // a fact about the message rather than about a check somebody remembered to write. + const asked: unknown[] = []; + const { far } = opened({ + navigate: (request) => { + asked.push(request); + return true; + }, + }); + + far.send(ask({})); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(asked, [{ requestId: "r1", cid: "articles", id: "my-first-post" }]); + assert.equal(await settled(far), undefined, "a navigation that happened takes the document with it; there is nobody left to answer"); +}); + +test("a host that does not navigate says so, and it is not a refusal", async () => { + // The author's preview pane: there is no history to push onto, and nothing about the ask was + // wrong. A page told `no-navigation` has nothing to do about it — which is exactly why it must + // not arrive looking like "that article does not exist". + const without = opened({}); + without.far.send(ask({})); + assert.deepEqual(await settled(without.far), { type: VIEW_MESSAGE.openResult, requestId: "r1", opened: false, reason: "no-navigation" }); + + const declined = opened({ navigate: () => false }); + declined.far.send(ask({})); + assert.deepEqual(await settled(declined.far), { type: VIEW_MESSAGE.openResult, requestId: "r1", opened: false, reason: "no-navigation" }); +}); + +test("a host that throws SYNCHRONOUSLY is still an answer", async () => { + // A port that reads a router off a ref throws in the same turn. Unhandled, the rejection escapes + // and the page is left on a promise nothing will settle. + const defects: unknown[] = []; + const { far } = opened({ + navigate: () => { + throw new Error("no router"); + }, + defect: (error) => defects.push(error), + }); + far.send(ask({})); + assert.deepEqual(await settled(far), { type: VIEW_MESSAGE.openResult, requestId: "r1", opened: false, reason: "no-navigation" }); + assert.equal(defects.length, 1, "and the host hears about its own bug"); +}); + +test("a collection that is not the article one is refused, and the host is never sent", async () => { + // `/a/{slug}/{id}` has nothing in it to say which collection an id belongs to, so a host that + // navigated anyway would put the visitor on a page reading a record of a different collection — + // an address that looks broken to whoever is handed the link. + const asked: unknown[] = []; + const { far } = opened({ + navigate: (request) => { + asked.push(request); + return true; + }, + }); + far.send(ask({ cid: "notes" })); + assert.deepEqual(await settled(far), { type: VIEW_MESSAGE.openResult, requestId: "r1", opened: false, reason: "unknown-collection" }); + assert.deepEqual(asked, []); +}); + +test("an app that publishes no articles has no such page to reach", async () => { + const { far } = opened({ navigate: () => true }, { submit: { articles: { createFields: ["title"] } } }); + far.send(ask({})); + assert.deepEqual(await settled(far), { type: VIEW_MESSAGE.openResult, requestId: "r1", opened: false, reason: "unknown-collection" }); +}); + +test("an id that is not a path segment is refused before it reaches a URL", () => { + // The grammar is the defence that does not depend on one host remembering to encode. A `/` + // addresses a different route entirely, a leading `.` is a relative segment, and an empty id is + // the index. + for (const id of ["../secrets", "a/b", ".hidden", "", "a".repeat(129)]) { + assert.deepEqual(readOpenMessage(ask({ id }), "articles"), { ok: false, reason: "invalid-open", requestId: "r1" }, `accepted '${id}'`); + } + // Wider than the slug grammar on purpose: an app whose articles carry generated ids has the same + // claim on a link as one that names them. + for (const id of ["my-first-post", "9f8b2c1e-4a5d-4c3b-8e7f-1a2b3c4d5e6f", "note_2026.01"]) { + assert.equal(readOpenMessage(ask({ id }), "articles").ok, true, `refused '${id}'`); + } +}); + +test("an open with nobody waiting is not answered, and one that is not an open falls through", () => { + // No requestId, no promise: answering would be answering a message the page never sent. + assert.deepEqual(readOpenMessage({ type: VIEW_MESSAGE.open, cid: "articles", id: "x" }, "articles"), { + ok: false, + reason: "not-an-open", + requestId: "", + }); + // And a submission stays a submission — the readers are told apart by type, and one put through + // the wrong reader comes back refused with no request id and is answered by nobody. + assert.deepEqual(readOpenMessage({ type: VIEW_MESSAGE.submit, requestId: "r2", cid: "articles", values: {} }, "articles"), { + ok: false, + reason: "not-an-open", + requestId: "", + }); +}); + +test("a submission is still read as a submission once opens are routed first", async () => { + // The dispatch order. Anything routed ahead of the submission reader has to let a submission + // past, or the page's `submit()` is answered by nobody. + const { far } = opened({ navigate: () => true }); + far.send({ type: VIEW_MESSAGE.submit, requestId: "r2", cid: "secrets", values: {} }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + far.posted.find((message) => message.type === VIEW_MESSAGE.result), + { type: VIEW_MESSAGE.result, requestId: "r2", ok: false, error: "unknown-collection" }, + ); + assert.equal( + far.posted.find((message) => message.type === VIEW_MESSAGE.openResult), + undefined, + ); +}); + +test("the page calls it as `view.open`, and settles on the answer when one comes back", async () => { + // Driven through the bootstrap rather than the bridge: what a page actually has is + // `__MC_APP_VIEW`, and a verb the parent answers that the bootstrap never exposed would be a + // feature nobody can reach. + const posted: Record[] = []; + const listeners: ((event: Record) => void)[] = []; + const win: Record = { + addEventListener: (type: string, handler: (event: Record) => void) => { + if (type === "message") listeners.push(handler); + }, + removeEventListener: () => {}, + }; + const context = { window: win, parent: { postMessage: (message: Record) => posted.push(message) }, document: { currentScript: null } }; + vm.createContext(context); + vm.runInContext( + publicViewBootstrap(NONCE) + .replace(/^\s*