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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Payload plugin: a snapshot's content is read only when it is asked for by name (changes the unreleased plugin). A stored snapshot can be 15 MB, and Payload read all of it for every list, every relationship it filled in and every admin page. Now a read, create, update or delete that names no `select` answers with every field except `snapshot`; `select: { snapshot: true }`, REST `?select[snapshot]=true` or GraphQL's `select: true` gives the content, and the export endpoint is as before. A caller's own `select` is left exactly as asked. In the admin the content field is on the new-snapshot form only: a stored snapshot's page is sent without the content and without the JSON editor, and Payload's own Save there sends the status alone. Hosts' code that read `doc.snapshot` from a plain read, or from the document an after-read or after-change hook is given, has to select it. The lock on sealed content doesn't depend on this. Fixed on the way: the admin panels showed the old status after Payload's own Save; they now read from the site again whenever Payload reports the document saved. Tested: 10 new tests (8 on a real Payload with an in-memory MongoDB, one of them over HTTP, and 2 in jsdom), 3 new checks over HTTP in the example site (58 in all), and five deliberate breaks, each caught. Also run in headless Chrome on the example site, outside CI: a stored snapshot's page has no content and no editor, a sealed snapshot pasted into the new-snapshot form is stored with the same checksum, and Payload's Save sends one small request. Not explained: in that run, after a snapshot was made through the form and its page loaded again, Payload's read-only Counts group showed no fields under its heading; four shorter runs of the same steps drew them. Not tested: Postgres, SQLite, a browser with a window. See [ADR 0012](docs/architecture/adr/0012-payload-plugin.md), point 17.
- Payload plugin, part 5 of M7: admin panels (still not on npm). Each of the plugin's four collections gets a panel at the top of its edit page, on unless `admin: { panels: false }`. The snapshot panel says the status in words, what the snapshot holds (counts, a line per classification and phylogeny, sources with their licenses) and, for one made by accepting a proposal, which proposal and the two sealed person ids; a manager gets Publish and Withdraw. The view panel draws the saved view (the first 200 nodes) with the same tree as an outline for the keyboard and the lines readers are told, and holds Publish, with the reason, until the form is saved and the snapshot is published. The import panel shows the steps of a run, its warnings and diagnostics, looks again every 3 seconds while a job runs, and offers to run a queued, failed or interrupted job. The proposal panel says what a proposal would change, and a manager accepts it (naming the new version) or declines it. Every button asks first in the page and says what can't be undone; accepting lists what gets sealed for good and asks the manager to check it for personal details. The panels have no powers of their own: they call the host's site from the browser as the signed-in person, so the host's `access` callbacks decide, and a refusal reads "Nothing was changed." with the site's reason. New: the `@captiveorigins/payload-plugin-onetaxonomy/client` entry (found through Payload's import map, so run `payload generate:importmap` after adding the plugin), the `admin.panels` option, and one endpoint, `GET /api/<snapshots>/:id/summary` (and `summarizeSnapshot`), which answers counts and capped lists, never names or trees, with the same 404 as the other endpoints. For a snapshot made from a proposal it gives the name the host shows for each sealed id to signed-in requesters only, looked up per request and never stored. `@payloadcms/ui` and `react` are new optional peer dependencies, and the plugin now depends on `@captiveorigins/onetaxonomy-react` for the drawing. Colours come from Payload's theme variables; a test reads the installed `@payloadcms/ui` stylesheets and checks text at 4.5:1 and control edges at 3:1 in light and dark, and a status is never colour alone. The example site shows the panels to its curator and turns Payload's Gravatar avatar off. Tested: 94 new tests (74 render the panels in jsdom with Payload's hooks and `fetch` replaced by stand-ins, 11 on the stylesheet, 9 on the summary against a real Payload with an in-memory MongoDB), and 8 new checks over HTTP in the example site (55 in all). The snapshot, view and proposal panels were also clicked through once in headless Chrome on the example site, in light and dark; that run is not in CI. Not tested: the import panel in a real admin, a screen reader, Safari, Firefox, a phone, or a custom Payload theme. Found on the way and written in the README: Payload's own admin loads its JSON editor from cdn.jsdelivr.net and, unless `admin.avatar` is `'default'`, asks gravatar.com with a hash of the signed-in person's email address. See [ADR 0012](docs/architecture/adr/0012-payload-plugin.md), point 16.
- Benchmarks: frame times in a browser. `apps/playground/bench.html` (built with the playground) pans, zooms and scrolls made-up trees of 125 to 10,000 tips as SVG and on the canvas and times every frame; open it in any browser, press Run and save the numbers. `node scripts/bench-browser.mjs` runs it in the Chrome already installed, headless, at pixel density 1 and 2 and with the main thread slowed 4 times, and writes `docs/benchmarks-browser.md`; `--from <file>` adds a section from numbers saved in another browser. It uses a throwaway profile that it deletes, listens on this machine only and sends nothing anywhere. First numbers (headless Chrome 157, M2 Max, other work running): the canvas held 60 frames a second at every size up to 19,999 nodes, slowed thread included; SVG held it through 1,999 nodes, and through 499 on the slowed thread. A second section is from a headless build of WebKit 27.2 (Playwright's: the engine Safari and the Mac app use, but neither program), driven by hand as the doc describes, at normal speed only: the canvas held 60 frames a second at every size, and SVG held it through 1,999 nodes. When a browser's clock ticks in whole milliseconds, as WebKit's does, the script says so in the section and prints no decimal places. The 1,000-node switch is unchanged. Not measured: Safari itself, the Mac app's web view, Firefox, a window on a real screen, a real slow device, radial layouts. The benchmark page and script have no tests of their own.
- Payload plugin docs: moving a departed person's proposals to another account. The README's "When someone leaves" now says that `author` and `decidedBy` can't be changed through Payload, that the lock is a hook and so doesn't cover a host's own database code, and that a host making that move there must change only those two fields and leave `proposal`, `checksum` and the snapshot alone. A new test makes the move on MongoDB and checks that the proposal still matches its checksum, the version made from it still verifies, and the proposal stays locked. No code changed; an option to allow the move through Payload was considered and not built (ADR 0012).
Expand Down
6 changes: 4 additions & 2 deletions apps/payload-example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A small Payload and Next.js site that uses [`@captiveorigins/payload-plugin-onetaxonomy`](../../packages/payload). It shows the plugin set up the way a real site would: accounts with two roles, public pages that draw published views, and proposals that record a person by an id, with a public handle shown beside it and no email address.

> **Status: an example, not a product.** It runs on this machine only, on a throwaway in-memory MongoDB, with made-up accounts and the repository's illustrative fixtures. Tested: 55 checks over HTTP with sign-in cookies (`pnpm smoke`), on `next dev` and on the built site, with Payload 3.93 and Next.js 16; the three public pages were looked at in headless Chrome, in light and dark, at desktop width and at 520 px; and the admin's panels were clicked through once in headless Chrome as the curator, in light and dark (publishing a snapshot with the keyboard, a view, and accepting a proposal). That last run is not in CI. Not tested: a screen reader, Safari, Firefox, a phone, Postgres or SQLite, or a deployed site.
> **Status: an example, not a product.** It runs on this machine only, on a throwaway in-memory MongoDB, with made-up accounts and the repository's illustrative fixtures. Tested: 58 checks over HTTP with sign-in cookies (`pnpm smoke`), on `next dev` and on the built site, with Payload 3.93 and Next.js 16; the three public pages were looked at in headless Chrome, in light and dark, at desktop width and at 520 px; and the admin's panels were clicked through in headless Chrome as the curator, in light and dark (publishing a snapshot with the keyboard, a view, accepting a proposal, and storing a snapshot pasted into the new-snapshot form). Those runs are not in CI. Not tested: a screen reader, Safari, Firefox, a phone, Postgres or SQLite, or a deployed site.

## What it shows

Expand Down Expand Up @@ -50,6 +50,7 @@ pnpm --filter @captiveorigins/onetaxonomy-payload-example smoke
This builds the site, starts it on a free port with its own database, and sends it requests the way a browser would. It takes about 15 seconds on a recent Mac. It checks that:

- a visitor reads the published snapshot and views, through the pages, REST and GraphQL, and gets "not found" for anything staged;
- a visitor's plain read and list of snapshots hold no content, and a read that selects the content gets it;
- a visitor can't store a snapshot, make a proposal, or list accounts or proposals;
- a sign-in cookie can't be read by scripts, and counts for nothing from another site;
- a member can't make themself a curator, sees only their own account, and is sent away from the admin;
Expand All @@ -58,6 +59,7 @@ This builds the site, starts it on a free port with its own database, and sends
- a changed handle shows on the accepted proposal at once, and the sealed version doesn't change;
- an accepted proposal can't be deleted;
- the curator's edit pages in the admin carry the plugin's panels;
- a stored snapshot's admin page is sent without its content and without the content field, and the new-snapshot form still has that field;
- the new version's summary shows the curator both people by id with their handles, is "not found" for a visitor and the member while it is staged, and once published gives a visitor the ids and no names;
- a member can't publish it, and the curator's publish answers without the snapshot's content.

Expand All @@ -75,7 +77,7 @@ Set `DATABASE_URI` to a MongoDB address and the site uses it instead of the in-m

- **No page for making a proposal.** A member makes one through the API. The curator decides on it in the admin, on the proposal's panel.
- **No imports.** The `imports` option is off, so there are no import jobs and the import panel doesn't appear. It is tested in the plugin's own package.
- **Two requests Payload's admin makes to other sites.** The admin's JSON fields load their code editor (Monaco) from cdn.jsdelivr.net. That is Payload's, and this example leaves it. Payload's default avatar would also ask gravatar.com with a hash of the signed-in address; this example turns that off (`admin.avatar: 'default'`).
- **Two requests Payload's admin makes to other sites.** The admin's JSON fields load their code editor (Monaco) from cdn.jsdelivr.net, on the new-snapshot form and on a view's and a proposal's page. That is Payload's, and this example leaves it. Payload's default avatar would also ask gravatar.com with a hash of the signed-in address; this example turns that off (`admin.avatar: 'default'`).
- **No tenants.** The plugin's tenant scope is tested in its own package, not shown here.
- **No sign-up.** A curator adds accounts in the admin.
- **Nothing for production.** No email, no rate limits, no backups, no deployment setup.
Expand Down
8 changes: 8 additions & 0 deletions apps/payload-example/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ async function checks() {
const published = publicSnaps.json?.docs ?? []
check('a visitor sees the published snapshot and not the staged one', publicSnaps.status === 200 && published.length === 1 && published[0].snapshotId === 'fixture-amniotes' && published[0].status === 'published', published.map((d) => [d.snapshotId, d.status]))
const snap = published[0]?.id
// A snapshot's content comes only when it is asked for by name; the list and a plain read leave it out.
const plain = await get(`/api/taxonomy-snapshots/${snap}?depth=0`)
const named = await get(`/api/taxonomy-snapshots/${snap}?depth=0&select[snapshot]=true`)
check('a list and a plain read of a snapshot leave its content out, and a read that selects it gets it', plain.status === 200 && plain.json?.checksum === published[0]?.checksum && !('snapshot' in plain.json) && !publicSnaps.text.includes('schemaVersion') && !plain.text.includes('schemaVersion') && named.json?.snapshot?.checksum === published[0]?.checksum && named.json?.snapshot?.concepts?.length > 0, { plain: plain.text.length, list: publicSnaps.text.length, named: named.text.length })
const search = await get(`/api/taxonomy-snapshots/${snap}/search?q=aves`)
check('a visitor can search the published snapshot', search.status === 200 && JSON.stringify(search.json).includes('Aves'), { status: search.status })
const viewId = links[0]?.split('/').pop()
Expand Down Expand Up @@ -215,7 +219,11 @@ async function checks() {
for (const [what, pathname] of [['snapshot', `taxonomy-snapshots/${next?.doc}`], ['view', `taxonomy-views/${viewId}`], ['proposal', `taxonomy-proposals/${proposal?.id}`]]) {
const page = await get(`/admin/collections/${pathname}`, curator)
check(`the curator's ${what} page in the admin carries the plugin's panel`, page.status === 200 && page.text.includes('onetaxonomy-panel'), { status: page.status })
// The stored snapshot can be 15 MB. Its page must not carry it, in the form or anywhere else in what is sent.
if (what === 'snapshot') check('the snapshot\'s admin page is sent without the snapshot\'s content and without the field for it', !page.text.includes('schemaVersion') && !page.text.includes('id="field-snapshot"'), { length: page.text.length, content: page.text.includes('schemaVersion'), field: (page.text.match(/.{60}id="field-snapshot".{60}/) ?? [])[0] })
}
const blank = await get('/admin/collections/taxonomy-snapshots/create', curator)
check('the new-snapshot form in the admin still has the field to paste one into', blank.status === 200 && blank.text.includes('id="field-snapshot"'), { status: blank.status })
const summary = await get(`/api/taxonomy-snapshots/${next?.doc}/summary`, curator)
const from = summary.json?.derivedFrom?.proposal
check('version 2\'s summary shows the curator both people by id, with their handles looked up beside them', summary.status === 200 && from?.author === String(me?.id) && from?.authorName === 'Morgan Renamed' && from?.acceptedBy === String(curatorId) && from?.acceptedByName === 'Cara Curator', from ?? { status: summary.status })
Expand Down
Loading
Loading