diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e84eea6..df556d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,16 +41,18 @@ jobs: # documenting and survives a runner restructure.) - name: Validate plugin/marketplace manifests run: node test/manifest.test.js - # Static guard: worker/server and every injected chrome module must parse. - # (overlay.js is deleted — the monolith was split into these modules.) + # Static guard: worker/server, the shell boot builder, and the only script + # injected into author HTML (frame-probe) must parse. The provider UI + # itself is a React app under shell/src; its built runtime is checked by + # the `shell runtime` job below. - name: Syntax check run: | node --check worker/worker.js node --check server/server.js - node --check server/chrome.js node --check server/shell.js node --check server/frame-probe.js - node --check server/manage.js + node --check server/runtime-assets.js + node --check bin/tdoc-bundle bash -n bin/tdoc-publish bash -n bin/tdoc-pull bash -n bin/tdoc-new @@ -68,3 +70,28 @@ jobs: # the dangerous classes (quoting/word-splitting/injection), not lint nits. - name: ShellCheck CLIs run: shellcheck --severity=warning --exclude=SC2034 bin/tdoc-publish bin/tdoc-doctor bin/tdoc-pull bin/tdoc-new bin/tdoc-unpublish bin/tdoc-update + + shell-runtime: + name: shell runtime + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + cache: npm + # The React provider UI (shell/src) is built by Vite into server/runtime/ + # and the output is COMMITTED: skill users run server/server.js straight + # from the checkout, and bin/tdoc-bundle embeds the same bytes into the + # Worker — neither runs npm install. Vite's output is content-hashed and + # deterministic, so rebuilding must reproduce the committed bytes exactly. + # A diff here means someone edited shell/src without `npm run build:shell`. + - run: npm ci + - run: npm run build:shell + - name: Committed runtime matches shell/src + run: | + git add -N server/runtime + git diff --exit-code --stat server/runtime || { + echo "server/runtime is stale — run npm run build:shell and commit the result" >&2 + exit 1 + } diff --git a/.gitignore b/.gitignore index 63d939d..c929801 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ vercel/node_modules/ *.swo # Node +node_modules node_modules/ npm-debug.log yarn-error.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 048ae7a..75a8695 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,9 +57,47 @@ Install the optional browser dep with `npm i -D playwright && npx playwright ins `npm test` must be green before any commit to `main`. +## The provider UI is a React app — build it, then commit the build + +The top bar, comment layer, dialogs, sign-in, and the `/me` Docs Hub live in +`shell/src` (React + Vite, headless primitives from `@base-ui/react` wrapped in +`shell/src/ui/`). Author HTML never sees React: it renders only inside the +sandboxed `/frame` iframe, where `server/frame-probe.js` is the sole script. + +```bash +npm install # once — React, Vite, and the Base UI primitives +npm run dev:shell # Vite dev server for shell/src +npm run build:shell # writes server/runtime/{manifest.json, shell..js, shell..css} +``` + +Chrome changes are checked **against the version they replace**, not by eye +alone. `test/visual/` holds two Playwright harnesses that run the old and new +implementation side by side on the same fixture doc and write composite +screenshots per scene (bar, menus, dialogs, comment card, dark mode, phone +drawer, owner Share panel, /me): + +```bash +git worktree add /tmp/tdoc-main origin/main +node test/visual/local-compare.js /tmp/cmp /tmp/tdoc-main . # Local Studio +SKILL_DIR=/tmp/tdoc-main OUT_DIR=/tmp/wb-old node bin/tdoc-bundle # hosted: bundle both +SKILL_DIR=$PWD OUT_DIR=/tmp/wb-new node bin/tdoc-bundle +node test/visual/hosted-compare.mjs /tmp/cmp-hosted /tmp/wb-old/_worker.bundled.js /tmp/wb-new/_worker.bundled.js +``` + +The rule for a migration or restyle: match the old chrome where it can be +matched; where it cannot, the new one still has to look finished. + +`server/runtime/` is **committed on purpose**: skill users run +`server/server.js` straight from the checkout and `bin/tdoc-bundle` embeds the +same bytes into the Worker — neither runs `npm install`. The Vite output is +content-hashed and deterministic, so after any edit under `shell/src` run +`npm run build:shell` and commit the result together with the source. CI's +`shell runtime` job rebuilds and fails on a byte diff. See `IMPLEMENTATION.md` +for the runtime boundary and component layers. + ## Hard rule: run tests before every push -The skill ships JS that runs in users' browsers and a worker that runs on Cloudflare, both deployed on every `/tdoc publish`. Run `npm test` before pushing; for overlay or worker changes also run the matching gated suite via `npm run test:all`. Doc-only changes still need a `grep` for stale references (counts, command names, version numbers). +The skill ships JS that runs in users' browsers and a worker that runs on Cloudflare, both deployed on every `/tdoc publish`. Run `npm test` before pushing; for shell (`shell/src`, `server/frame-probe.js`) or worker changes also run the matching gated suite via `npm run test:all`. Doc-only changes still need a `grep` for stale references (counts, command names, version numbers). ## AGENTS.md diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 16aa04b..da6c9e6 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -1,145 +1,74 @@ -# ARTIFACT-SHELL — INTEGRATED IMPLEMENTATION BLUEPRINT - -Produced by a multi-agent design workflow (5 agents, code-grounded to real -line numbers). Branch `feat/artifact-shell-iframe`. Execute steps in order; -each is a single commit with its own verification. Boundary test -(`test/artifact-shell.test.js`) stays green at every step, flipping from -`?shell=1` to plain `/d/` only at Step 6. - -## Milestones -- **Milestone 1 (this PR, mergeable): Steps 1–6** — server-side full shell at - 1:1 chrome parity; single-path cut on the local server. Keeps `?shell=1` as - scaffolding until Step 6, then the doc route is shell-only. -- **Milestone 2: Step 7** — worker.js parity (published path) + bundle/CSP/ - widget reconciliation + existing-doc bake migration rollout. - -## Two shared contracts (freeze before writing code) - -### Contract 1 — `TDOC_CHROME` global (`server/chrome.js`, Step 1) -Inlined nonced `', start); + assert(end >= 0, `${name} script is not terminated`); + return JSON.parse(html.slice(start + marker.length, end)); +} + async function putSession(env, login, sid) { // Worker parseCookie only accepts hex tdoc_sid values. const id = sid || [...crypto.getRandomValues(new Uint8Array(16))] @@ -502,23 +507,23 @@ async function issue(worker, env, login = 'alice', label = login) { const aliceMe = await worker.fetch(req('/me', { cookie: alice.cookie }), env, {}); assert(aliceMe.status === 200, `/me alice ${aliceMe.status}`); - const aliceHtml = await aliceMe.text(); - assert(aliceHtml.includes('alice-doc'), 'alice must see her slug'); - assert(!aliceHtml.includes('data-slug="bob-doc"'), 'alice must not see bob'); - assert(!aliceHtml.includes('data-slug="legacy"'), 'alice must not see operator legacy docs'); + const aliceBoot = bootData(await aliceMe.text(), '__TDOC_APP_BOOT__'); + assert(aliceBoot.docs.some((doc) => doc.slug === 'alice-doc'), 'alice must see her slug'); + assert(!aliceBoot.docs.some((doc) => doc.slug === 'bob-doc'), 'alice must not see bob'); + assert(!aliceBoot.docs.some((doc) => doc.slug === 'legacy'), 'alice must not see operator legacy docs'); const bobMe = await worker.fetch(req('/me', { cookie: bob.cookie }), env, {}); - const bobHtml = await bobMe.text(); - assert(bobHtml.includes('bob-doc'), 'bob must see his slug'); - assert(!bobHtml.includes('alice-doc'), 'bob must not see alice'); + const bobBoot = bootData(await bobMe.text(), '__TDOC_APP_BOOT__'); + assert(bobBoot.docs.some((doc) => doc.slug === 'bob-doc'), 'bob must see his slug'); + assert(!bobBoot.docs.some((doc) => doc.slug === 'alice-doc'), 'bob must not see alice'); const julieSid = await putSession(env, 'julie'); const julieMe = await worker.fetch(req('/me', { cookie: julieSid }), env, {}); assert(julieMe.status === 200, `/me julie ${julieMe.status}`); - const julieHtml = await julieMe.text(); - assert(julieHtml.includes('data-slug="legacy"'), 'operator must still see unhosted legacy docs'); - assert(!julieHtml.includes('data-slug="alice-doc"'), 'operator /me must not list other tenants'); - assert(!julieHtml.includes('data-slug="bob-doc"'), 'operator /me must not list other tenants'); + const julieBoot = bootData(await julieMe.text(), '__TDOC_APP_BOOT__'); + assert(julieBoot.docs.some((doc) => doc.slug === 'legacy'), 'operator must still see unhosted legacy docs'); + assert(!julieBoot.docs.some((doc) => doc.slug === 'alice-doc'), 'operator /me must not list other tenants'); + assert(!julieBoot.docs.some((doc) => doc.slug === 'bob-doc'), 'operator /me must not list other tenants'); }); await t('hosted create enforces per-account doc quota; retry of same slug does not consume another slot', async () => { @@ -845,12 +850,14 @@ async function issue(worker, env, login = 'alice', label = login) { const r = await worker.fetch(req('/d/multi-doc/v/3', { token: owner.token }), env, {}); assert(r.status === 200, `owner token denied the HTML: ${r.status}`); const body = await r.text(); - assert(/v3/.test(body), 'the document body did not come back'); - // All three versions reachable, not just the one being viewed. - const nav = body.match(/"versions":\s*(\[[^\]]*\])/); - assert(nav, 'the page did not carry a version list'); - assert(JSON.parse(nav[1]).length === 3, - `owner should see 3 versions, saw ${JSON.parse(nav[1]).length}`); + const config = bootData(body, '__TDOC_SHELL__'); + const boot = bootData(body, '__TDOC_SHELL_BOOT__'); + assert(config.versions.length === 3, + `owner should see 3 versions, saw ${config.versions.length}`); + assert(boot.frameSrc === '/d/multi-doc/v/3/frame', 'shell must point at the isolated author frame'); + const frame = await worker.fetch(req(boot.frameSrc, { token: owner.token, dest: 'iframe' }), env, {}); + assert(frame.status === 200, `owner token denied the author frame: ${frame.status}`); + assert(/v3/.test(await frame.text()), 'the document body did not come back from the frame route'); }); await t('reading over a token does not sign the CLI in', async () => { diff --git a/test/jul36-owner-manage.test.js b/test/jul36-owner-manage.test.js index 74ccb4d..68d2cc5 100644 --- a/test/jul36-owner-manage.test.js +++ b/test/jul36-owner-manage.test.js @@ -1,330 +1,75 @@ -// JUL-36 — owner manage UX (Delete / Unpublish / Share settings) guard. -// -// Product ask: the doc-view top bar exposes owner-only manage controls -// directly (not just on /me), with a styled confirm modal instead of native -// confirm(). Security constraint from review: owner gating is SERVER-side -// (the overlay never render-then-hides a dead button), and every mutation -// route re-checks auth INSIDE itself. -// -// 2026-08-13 tail: browser owner mutations now authorize off the owner's -// SESSION COOKIE (no more pasted admin token) via the shared -// authorizeOwnerMutation() gate, which is safe ONLY because every doc -// response now carries a CSP (worker.js cspHeader()) that blocks author -// ', start); + assert(end >= 0, `${name} script is not terminated`); + return JSON.parse(html.slice(start + marker.length, end)); } (async () => { @@ -179,16 +176,14 @@ function pane(html, id) { ] }); const r = await worker.fetch(req('/me', { cookie }), env, {}); assert(r.status === 200, `/me ${r.status}`); - const html = await r.text(); - const iNew = html.indexOf('data-slug="z-newer"'); - const iOld = html.indexOf('data-slug="a-older"'); - assert(iNew >= 0 && iOld >= 0, 'both rows must render'); - assert(iNew < iOld, 'latest-updated doc must come first (KV order is alphabetical, so this proves the sort)'); - assert(html.includes('id="doc-sort"'), 'sort select missing'); - assert(html.includes('data-updated="2026-03-01T00:00:00.000Z"'), 'rows must carry data-updated for client re-sort'); - assert(html.includes('data-created='), 'rows must carry data-created'); - assert(html.includes('data-pane="pane-recent"') && html.includes('data-pane="pane-starred"'), 'Recent/Starred tabs missing'); - assert(html.includes('updated 2026-03-01'), 'row meta should show the update day'); + const boot = bootData(await r.text(), '__TDOC_APP_BOOT__'); + assert(boot.page === 'docs-hub', 'Docs Hub boot discriminator missing'); + assert(boot.docs.length === 2, 'both rows must be present'); + assert(boot.docs[0].slug === 'z-newer' && boot.docs[1].slug === 'a-older', + 'latest-updated doc must come first (KV order is alphabetical, so this proves the sort)'); + assert(boot.docs[0].updated === '2026-03-01T00:00:00.000Z', 'updated timestamp missing'); + assert(boot.docs.every((doc) => Object.hasOwn(doc, 'created')), 'created timestamp missing'); + assert(Array.isArray(boot.recent) && Array.isArray(boot.starred), 'Recent/Starred data missing'); }); await t('a signed-in visit to a readable doc records recents:; /me shows it with a byline', async () => { @@ -201,12 +196,11 @@ function pane(html, id) { assert(recents && recents.items.length === 1 && recents.items[0].slug === 'bobs-doc', `visit not recorded: ${await env.META.get('recents:alice')}`); const me = await worker.fetch(req('/me', { cookie }), env, {}); - const html = await me.text(); - const recent = pane(html, 'pane-recent'); - assert(recent.includes('data-slug="bobs-doc"'), 'Recent pane must list the visited doc'); - assert(recent.includes('by bob'), 'Recent row must say whose doc it is'); + const boot = bootData(await me.text(), '__TDOC_APP_BOOT__'); + assert(boot.recent.some((doc) => doc.slug === 'bobs-doc'), 'Recent data must list the visited doc'); + assert(boot.recent.find((doc) => doc.slug === 'bobs-doc').owner === 'bob', 'Recent row must identify whose doc it is'); // Not alice's doc — it must NOT appear in her own catalog list. - assert(!pane(html, 'pane-mine').includes('data-slug="bobs-doc"'), "someone else's doc must stay out of My docs"); + assert(!boot.docs.some((doc) => doc.slug === 'bobs-doc'), "someone else's doc must stay out of My docs"); }); await t('anonymous and denied visits record nothing; HEAD records nothing', async () => { @@ -253,13 +247,13 @@ function pane(html, id) { assert(noDoc.status === 404, `missing doc should 404, got ${noDoc.status}`); const priv = await worker.fetch(req('/api/star', { method: 'POST', cookie, body: { slug: 'hidden', starred: true } }), env, {}); assert(priv.status === 404, `unreadable doc must 404 (no existence oracle), got ${priv.status}`); - let html = await (await worker.fetch(req('/me', { cookie }), env, {})).text(); - assert(pane(html, 'pane-starred').includes('data-slug="starrable"'), 'Starred pane must list the doc'); + let boot = bootData(await (await worker.fetch(req('/me', { cookie }), env, {})).text(), '__TDOC_APP_BOOT__'); + assert(boot.starred.some((doc) => doc.slug === 'starrable'), 'Starred data must list the doc'); const unstar = await worker.fetch(req('/api/star', { method: 'POST', cookie, body: { slug: 'starrable', starred: false } }), env, {}); assert(unstar.status === 200, `unstar ${unstar.status}`); assert(JSON.parse(await env.META.get('stars:alice')).items.length === 0, 'unstar must remove the item'); - html = await (await worker.fetch(req('/me', { cookie }), env, {})).text(); - assert(!pane(html, 'pane-starred').includes('data-slug="starrable"'), 'unstarred doc must leave the pane'); + boot = bootData(await (await worker.fetch(req('/me', { cookie }), env, {})).text(), '__TDOC_APP_BOOT__'); + assert(!boot.starred.some((doc) => doc.slug === 'starrable'), 'unstarred doc must leave the data'); }); await t('starred/recent rows vanish when the doc becomes unreadable or is deleted', async () => { @@ -272,9 +266,9 @@ function pane(html, id) { const meta = JSON.parse(await env.META.get('meta:was-open')); meta.access = { visibility: 'private' }; await env.META.put('meta:was-open', JSON.stringify(meta)); - const html = await (await worker.fetch(req('/me', { cookie }), env, {})).text(); - assert(!pane(html, 'pane-recent').includes('data-slug="was-open"'), 'Recent must drop unreadable docs'); - assert(!pane(html, 'pane-starred').includes('data-slug="was-open"'), 'Starred must drop unreadable docs'); + const boot = bootData(await (await worker.fetch(req('/me', { cookie }), env, {})).text(), '__TDOC_APP_BOOT__'); + assert(!boot.recent.some((doc) => doc.slug === 'was-open'), 'Recent must drop unreadable docs'); + assert(!boot.starred.some((doc) => doc.slug === 'was-open'), 'Starred must drop unreadable docs'); }); await t('folders as places: folder row + count + crumbs; delete returns docs to root', async () => { @@ -294,26 +288,21 @@ function pane(html, id) { const move = await worker.fetch(req('/api/folders/move', { method: 'POST', cookie, body: { slugs: ['my-doc'], folder: folder.id } }), env, {}); assert(move.status === 200, `move ${move.status}: ${await move.clone().text()}`); - let html = await (await worker.fetch(req('/me', { cookie }), env, {})).text(); - assert(html.includes(`data-folder="${folder.id}"`), 'moved row must carry its folder id'); - // Location model: the folder renders as a ROW in the table (name + count), - // navigation is crumbs + ?folder=, and filed rows carry a search-time - // location hint. - assert(html.includes(`data-folder-id="${folder.id}"`), 'folder must render as a folder row'); - assert(/folder-row[^>]*data-name="Work"/.test(html), 'folder row must carry its name'); - assert(html.includes('>1 doc<'), 'folder row must show its doc count'); - assert(html.includes('id="crumbs"'), 'breadcrumb container must render'); - assert(html.includes('in Work'), 'filed doc row must carry the location hint'); + let boot = bootData(await (await worker.fetch(req('/me', { cookie }), env, {})).text(), '__TDOC_APP_BOOT__'); + assert(boot.docs.find((doc) => doc.slug === 'my-doc')?.folder === folder.id, + 'moved row must carry its folder id'); + assert(boot.folders.some((item) => item.id === folder.id && item.name === 'Work' && item.parent === ''), + 'folder boot data must carry its name and root parent'); const bogus = await worker.fetch(req('/api/folders/move', { method: 'POST', cookie, body: { slugs: ['my-doc'], folder: 'f_nope' } }), env, {}); assert(bogus.status === 404, `move to unknown folder should 404, got ${bogus.status}`); const del = await worker.fetch(req('/api/folders?id=' + folder.id, { method: 'DELETE', cookie }), env, {}); assert(del.status === 200, `delete folder ${del.status}`); - html = await (await worker.fetch(req('/me', { cookie }), env, {})).text(); - assert(html.includes('data-slug="my-doc"'), 'doc must survive folder deletion'); - assert(html.includes('data-folder=""'), 'doc must fall back to the root after its folder is deleted'); - assert(!html.includes('data-folder-id='), 'deleted folder must not leave a folder row behind'); + boot = bootData(await (await worker.fetch(req('/me', { cookie }), env, {})).text(), '__TDOC_APP_BOOT__'); + assert(boot.docs.some((doc) => doc.slug === 'my-doc'), 'doc must survive folder deletion'); + assert(boot.docs.find((doc) => doc.slug === 'my-doc').folder === '', 'doc must fall back to the root after its folder is deleted'); + assert(!boot.folders.some((item) => item.id === folder.id), 'deleted folder must not remain in boot data'); }); await t('folders shelve only your own docs; rename works; state is per-login', async () => { @@ -362,29 +351,30 @@ function pane(html, id) { assert(state.docs['deep-doc'] === a.body.folder.id, "doc must move up to the deleted folder's parent, not to root"); const cRec = state.folders.find((f) => f.id === c.body.folder.id); assert(cRec && cRec.parent === a.body.folder.id, "subfolder must reparent to the deleted folder's parent"); - const html = await (await worker.fetch(req('/me', { cookie }), env, {})).text(); - assert(html.includes(`data-parent="${a.body.folder.id}"`), 'folder rows must carry data-parent for level filtering'); - assert(html.includes('draggable="true"'), 'doc rows must be draggable'); + const boot = bootData(await (await worker.fetch(req('/me', { cookie }), env, {})).text(), '__TDOC_APP_BOOT__'); + assert(boot.folders.some((folder) => folder.id === c.body.folder.id && folder.parent === a.body.folder.id), + 'folder boot data must carry parent relationships'); + assert(boot.docs.find((doc) => doc.slug === 'deep-doc')?.folder === a.body.folder.id, + 'document boot data must carry its new parent'); }); - await t('doc-page bar carries the star beside the title — signed-in viewers only, state server-rendered', async () => { + await t('doc-page boot carries viewer star state for signed-in viewers only', async () => { const env = makeEnv(mod.CommentsStore); await seedDoc(env, 'bar-doc', { owner: 'bob' }); - // chrome.js/chrome.css ship inline on every page, so the button's source - // strings are always present. Only the RENDERED markup has the class - // attribute closed with a double quote — key the checks off that. - const rendered = /class="tdoc-star-btn( is-starred)?" aria-pressed="(true|false)"/; const anonHtml = await (await worker.fetch(req('/d/bar-doc/v/1'), env, {})).text(); - assert(!rendered.test(anonHtml), 'anonymous doc view must not render the bar star'); + assert(bootData(anonHtml, '__TDOC_SHELL__').viewerStar === null, + 'anonymous doc view must not receive viewer star state'); const cookie = await putSession(env, 'alice'); let html = await (await worker.fetch(req('/d/bar-doc/v/1', { cookie }), env, {})).text(); - assert(/class="tdoc-star-btn" aria-pressed="false"/.test(html), 'signed-in view must render the empty-star state'); - assert(!html.includes('class="tdoc-star-btn is-starred"'), 'unstarred doc must not render as starred'); + assert(bootData(html, '__TDOC_SHELL__').viewerStar?.starred === false, + 'signed-in view must receive the empty-star state'); await worker.fetch(req('/api/star', { method: 'POST', cookie, body: { slug: 'bar-doc', starred: true } }), env, {}); html = await (await worker.fetch(req('/d/bar-doc/v/1', { cookie }), env, {})).text(); - assert(/class="tdoc-star-btn is-starred" aria-pressed="true"/.test(html), 'starred doc must render the filled state'); - const me = await (await worker.fetch(req('/me', { cookie }), env, {})).text(); - assert(!rendered.test(me), 'the /me site bar must not carry the doc star (rows have their own)'); + assert(bootData(html, '__TDOC_SHELL__').viewerStar?.starred === true, + 'starred doc must receive the filled state'); + const meBoot = bootData(await (await worker.fetch(req('/me', { cookie }), env, {})).text(), '__TDOC_APP_BOOT__'); + assert(meBoot.starred.some((doc) => doc.slug === 'bar-doc' && doc.starred), + 'the Docs Hub must receive row-level starred state'); }); console.log(`\n${pass} passed, ${fail} failed`); diff --git a/test/me-management.test.js b/test/me-management.test.js index adb72ff..2debd8e 100644 --- a/test/me-management.test.js +++ b/test/me-management.test.js @@ -1,235 +1,73 @@ -// Owner catalog (/me) guard. -// -// 2026-08-13 rework (julie: "删改实在是太丑了 uiux 请improve。而且不能只在/me page"): -// /me is a clean catalog — title/slug/version + search + multi-select batch -// delete + quiet ⋯ Delete. The per-row visibility/history/commenting/ -// allowed_users dropdowns and the admin-token input are GONE: access -// controls moved to the doc-page Share panel (overlay.js showManageModal, -// see jul36-owner-manage.test.js), and Delete now authorizes off the owner's -// session cookie instead of a pasted token (safe only because of the CSP on -// every doc response — see csp.test.js). /me is gated by canSeeMyDocs -// (hosted: any signed-in GitHub user; BYOK: TDOC_OWNER), so its own -// same-origin fetches are already cookied. -// -// Gate (小cc review #2): the /me HTML response must not contain access data -// of any kind — especially `allowed_users` — since none of that is rendered -// here anymore. - +// `/me` is a React Docs Hub driven by structured boot data. Access policy stays +// on each document's owner-only Share dialog and never leaks into the catalog. const fs = require('fs'); const path = require('path'); let pass = 0, fail = 0; -function ok(n) { console.log(` ✓ ${n}`); pass++; } -function bad(n, e) { console.log(` ✗ ${n}\n ${e}`); fail++; } -function t(n, fn) { try { fn(); ok(n); } catch (e) { bad(n, e.message); } } -function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } - -const worker = fs.readFileSync(path.join(__dirname, '..', 'worker', 'worker.js'), 'utf8'); -// The monolith is gone: bar CSS lives in chrome.css, bar markup in chrome.js, -// and the shell renders the bar server-side as the FIRST body element. -const overlay = fs.readFileSync(path.join(__dirname, '..', 'server', 'chrome.css'), 'utf8') + fs.readFileSync(path.join(__dirname, '..', 'server', 'shell.js'), 'utf8'); -const start = worker.indexOf('async function indexHtml(env, session'); -const end = worker.indexOf('// ─────────────────────────────────────────────────────────────────────────', start); -if (start < 0 || end < 0 || end <= start) throw new Error('indexHtml block missing'); -const index = worker.slice(start, end); - -const deleteStart = worker.indexOf("if (p === '/api/doc' && method === 'DELETE')"); -const deleteEnd = worker.indexOf("return text('Not found'", deleteStart); -if (deleteStart < 0 || deleteEnd < 0 || deleteEnd <= deleteStart) throw new Error('/api/doc DELETE block missing'); -const deleteRoute = worker.slice(deleteStart, deleteEnd); - -console.log('/me owner catalog'); - -t('/me no longer exposes the per-row access-control form', () => { - assert(!index.includes('class="access-form"'), '/me must not render the old access-form'); - assert(!index.includes('name="visibility"'), '/me must not render a visibility control'); - assert(!index.includes('name="history_visibility"'), '/me must not render a history-visibility control'); - assert(!index.includes('name="commenting"'), '/me must not render a commenting control'); - assert(!index.includes('name="allowed_users"'), '/me must not render an allowed-users control'); -}); - -t('/me no longer asks for an admin token', () => { - assert(!index.includes('id="admin-token"'), '/me must not render the admin-token input'); - assert(!index.includes('Admin token'), '/me must not reference "Admin token" anywhere'); - assert(!index.includes('TDOC_UPLOAD_TOKEN'), '/me HTML must not reference TDOC_UPLOAD_TOKEN'); - assert(!index.includes("'Authorization'"), '/me must not build an Authorization header'); -}); - -t('/me never computes or emits allowed_users (gate: no access data leaks into the catalog)', () => { - assert(!index.includes('allowed_users'), '/me source must not reference allowed_users at all'); - assert(!index.includes('accessFromMeta'), '/me must not compute an access policy per row anymore'); -}); - -t('/me keeps title, slug, version, search, batch select, and a quiet ⋯ delete per row', () => { - assert(index.includes('doc-title'), 'missing doc title link'); - assert(index.includes('doc-meta'), 'missing slug/version meta line'); - // Delete is tucked behind a ⋯ overflow menu, not a prominent per-row button. - assert(index.includes('class="row-menu-btn"'), 'missing ⋯ overflow trigger'); - assert(index.includes('class="row-delete"'), 'missing delete item inside the menu'); - assert(!index.includes('class="delete-doc"'), 'the loud standalone delete button should be gone'); - // Search + multi-select batch delete (still no access forms). - assert(index.includes('id="doc-search"'), 'missing catalog search input'); - assert(index.includes('class="doc-check"'), 'missing per-row select checkbox'); - assert(index.includes('id="batch-delete"'), 'missing batch delete control'); - assert(index.includes('id="select-all"'), 'missing select-all control'); -}); - -t('/me search is client-side over title/slug (no extra catalog round-trips)', () => { - assert(index.includes('applySearch'), 'missing search apply helper'); - assert(index.includes('dataset.title'), 'search must read title from the rendered row'); - assert(index.includes('dataset.slug'), 'search must read slug from the rendered row'); - assert(index.includes('No matches.'), 'missing empty search state'); - // `.doc-row { display:flex }` would otherwise override the UA [hidden] rule - // and leave "filtered" rows visible — pin the !important hide. - assert(/\.doc-row\[hidden\][^}]*display:\s*none\s*!important/.test(index), - 'filtered rows must force display:none !important so search actually hides them'); -}); - -t('/me batch delete reuses session DELETE /api/doc (no token, no access forms)', () => { - assert(index.includes('batchDelete.addEventListener'), 'batch delete must be wired'); - assert(index.includes('selectedRows'), 'batch delete must operate on the selected set'); - // Still no access-policy batching — JUL-36 keeps policy on the doc Share panel. - assert(!index.includes('/api/doc/access'), '/me must not batch-patch access policy'); -}); - -t('/me deletes remote docs through DELETE /api/doc using the session (no token)', () => { - assert(index.includes("fetch('/api/doc?slug=' + encodeURIComponent(slug)"), 'delete button must call remote delete endpoint'); - assert(index.includes("method: 'DELETE'"), 'delete button must use DELETE'); - assert(index.includes("credentials: 'same-origin'"), 'delete fetch should be explicit about sending the session cookie'); - assert(!index.includes("'Authorization': 'Bearer'"), 'delete must not send a bearer token'); - assert(deleteRoute.includes('await authorizeOwnerMutation(req, env, slug)'), 'remote delete must accept session-or-token auth'); -}); - -t('/me still uses the styled confirm modal, never native confirm()', () => { - const nativeConfirmCall = /(?:^|[^\w.])confirm\(/m; - const stripped = index.split('\n').filter(l => !l.trim().startsWith('//') && !l.trim().startsWith('*')).join('\n'); - assert(!nativeConfirmCall.test(stripped), '/me must not call the native confirm()'); - assert(index.includes('showConfirm('), '/me must use the styled showConfirm() modal'); - // Quiet inline toast feedback — no third-party script, no inline status row. - assert(index.includes("This can't be undone."), 'confirm body should be short and plain'); - const userFacing = index.split('\n').filter(l => !l.trim().startsWith('//') && !l.trim().startsWith('*')).join('\n'); - assert(!/remote storage/i.test(userFacing), '/me copy must not say "remote storage"'); - assert(!index.includes("'/api/comments?slug='"), 'delete confirm must not pre-flight comment counts'); - assert(!/'); - assert(index.includes("function toast("), '/me must use a tiny inline toast for feedback'); - assert(!index.includes('id="status"'), 'inline status row should be gone — toast replaces it'); - assert(index.includes("toast('Deleted')"), 'success feedback should be a quiet toast("Deleted")'); - assert(!index.includes('data-versions'), 'unused data-versions should be gone from /me rows'); - assert(index.includes('applySearch();'), 'single-row delete must re-run applySearch so empty/filter state stays consistent'); - assert(index.includes('name="viewport"'), '/me must send a mobile viewport meta'); +function t(name, fn) { try { fn(); console.log(` ✓ ${name}`); pass++; } catch (error) { console.log(` ✗ ${name}\n ${error.message}`); fail++; } } +function assert(value, message) { if (!value) throw new Error(message); } + +const root = path.join(__dirname, '..'); +const worker = fs.readFileSync(path.join(root, 'worker/worker.js'), 'utf8'); +// The Docs Hub is a page orchestrator + a state/mutation hook + shared row +// components; behavior assertions look across all three. +const hub = ['shell/src/docs-hub.jsx', 'shell/src/hooks/use-docs-hub.js', 'shell/src/docs-hub/rows.jsx'] + .map((file) => fs.readFileSync(path.join(root, file), 'utf8')).join('\n'); +const shellApi = fs.readFileSync(path.join(root, 'shell/src/document/api.js'), 'utf8'); +const shell = fs.readFileSync(path.join(root, 'server/shell.js'), 'utf8'); + +console.log('/me React Docs Hub'); + +t('worker computes structured catalog data without access-policy fields', () => { + const start = worker.indexOf('async function indexData('); + const end = worker.indexOf('\nfunction ', start + 20); + const block = worker.slice(start, end); + assert(start >= 0, 'indexData missing'); + assert(block.includes('Promise.all'), 'metadata should load in parallel'); + assert(!/allowed_users|history_visibility|commenting/.test(block), 'catalog leaks access policy'); + assert(!/readComments\(|DOCS\.head/.test(block), 'catalog performs per-row content/comment I/O'); }); -t('/me catalog does not fold comment logs or HEAD R2 per row', () => { - // The slow /me load: N serial readComments (full event-log fold) + N R2 HEADs - // just to paint titles. Catalog reads KV meta only. - assert(!index.includes('readComments('), '/me must not call readComments while rendering the catalog'); - assert(!index.includes('DOCS.head'), '/me must not HEAD R2 objects while rendering the catalog'); - assert(index.includes('Promise.all'), '/me should fetch meta rows in parallel'); +t('/me route uses the shared React app shell with a CSP nonce', () => { + const start = worker.indexOf("if (p === '/me' && method === 'GET')"); + const end = worker.indexOf('// ---- interactive island', start); + const route = worker.slice(start, end); + assert(route.includes("page: 'docs-hub'"), 'Docs Hub boot discriminator missing'); + assert(route.includes('SHELL.appHtml({'), 'shared app shell missing'); + assert(route.includes("'Content-Security-Policy': cspHeader(nonce)"), 'CSP missing'); + assert(route.includes('canSeeMyDocs(env, s, url.origin)'), 'catalog access gate missing'); + assert(!/injectSiteChrome|indexHtml/.test(route), 'legacy /me renderer remains'); }); -t('overlay top bar occupies layout instead of floating over the document', () => { - assert(overlay.includes('.tdoc-bar { position: relative;'), - 'bar must sit in document flow, not overlay the page'); - assert(!overlay.includes('.tdoc-bar { position: fixed;'), - 'bar must not be position:fixed'); - assert(/\\n' \+\n'
/.test(overlay) || overlay.includes('
'), - 'bar must be the first body child so it occupies the top of the layout'); - assert(!overlay.includes('padding-top: 44px !important'), - 'in-flow bar must not reserve a fake padding-top gap'); - assert(!overlay.includes('body.tdoc-has-oldver-strip { padding-top: 72px !important; }'), - 'old-version strip must occupy flow, not extra body padding'); +t('Docs Hub exposes search, sort, tabs, folders, star, selection, and batch actions', () => { + for (const needle of [ + 'search-field', 'select-all', 'batch-delete', 'row-menu-btn', 'row-delete', + 'My docs', 'Recent', 'Starred', 'saveFolder', 'moveDocs', 'toggleStar', + ]) assert(hub.includes(needle), `missing Docs Hub behavior: ${needle}`); }); -t('/me reuses the SHARED bar component and hides Share / Duplicate / Copy', () => { - // /me is a plain site page now: content stays inline (tdoc-generated), the - // shared chrome.js bar goes in via injectSiteChrome. Same intent as before — - // ONE bar component, never a bespoke second bar. - const meStart = worker.indexOf("if (p === '/me' && method === 'GET')"); - const meEnd = worker.indexOf('// ---- interactive island', meStart); - assert(meStart >= 0 && meEnd > meStart, '/me route block missing'); - const meRoute = worker.slice(meStart, meEnd); - assert(meRoute.includes('injectSiteChrome('), '/me must use injectSiteChrome (shared bar component)'); - assert(meRoute.includes('isCatalog: true'), '/me must render the catalog bar variant'); - assert(meRoute.includes("'Content-Security-Policy': cspHeader(nonce)"), '/me needs the same CSP as docs'); - assert(worker.includes('CHROME.buildBar'), 'the site bar must come from the shared CHROME.buildBar'); - assert(!worker.includes('function siteChromeCss'), '/me must not fork a second top bar'); - assert(!index.includes('class="who"'), 'identity belongs in the shared identity chip'); - assert(index.includes('nonce="${nonce}"'), '/me catalog script must carry the CSP nonce'); - const chrome = fs.readFileSync(path.join(__dirname, '..', 'server', 'chrome.js'), 'utf8'); - // Copy lives in the ⋯ overflow, and the overflow/CTA/fork clusters are all - // isSiteBar-gated in the shared component, so the catalog bar drops - // Copy/Duplicate/Download/Share together. - assert(chrome.includes(''), 'Copy lives in the ⋯ overflow menu'); - assert(chrome.includes("(isSiteBar ? '' : secondaryMenuHtml)"), 'catalog must hide Copy (⋯ overflow is isSiteBar-gated)'); - assert(chrome.includes("(isSiteBar ? '' : primaryCtaHtml)"), 'catalog must hide Share'); - assert(chrome.includes("(isSiteBar ? '' : forkBtnHtml)"), 'catalog must hide Duplicate/Download'); - assert(chrome.includes('id="tdoc-title"'), - 'doc pages still show the title in the left cluster'); - assert(!chrome.includes('tdoc-bar-center'), - 'title must not sit in a fake-centered middle slot'); - assert(chrome.includes('src="/tdoc_logo.svg"'), - 'bar mark must be the tdoc logo, not a text pill'); - assert(overlay.includes("wire('#tdoc-bar-mark','click',function(){ location.href='/me'; })"), - 'tdoc logo must go to /me (the hub), not /'); - // The generic .tdoc-bar button rule gives inline-flex + align-items:center and - // no horizontal centring, so without this the 24px mark sits flush left in its - // 32px box and the hover highlight is 8px off-centre. - const markRule = overlay.match(/\.tdoc-bar button\.tdoc-bar-mark \{[^}]*\}/); - assert(markRule, 'bar mark rule missing'); - assert(/justify-content:\s*center/.test(markRule[0]), - 'bar mark must centre its logo, or the hover highlight sits off to one side'); - // The mark carries its own opaque white field, so dark mode must RESTORE it - // like a photograph. Letting it invert with the page turns the field black -- - // the bar's own colour -- and the drawing reads as a see-through outline. - assert(!/\.tdoc-bar-mark img\s*\{[^}]*filter:\s*none/.test(overlay), - 'bar mark must not opt out of the dark-mode image restore, or its white field inverts to black'); - const localServer = fs.readFileSync(path.join(__dirname, '..', 'server', 'server.js'), 'utf8'); - assert(/p === '\/me'[\s\S]{0,180}Location: '\/'/.test(localServer), - 'local studio must 302 /me to / so the logo click does not 404'); - assert(index.includes('class="wrap"'), 'catalog content must sit in a wrap so the bar can be full-bleed'); - // Structural in the shell: comment chrome is driven by frame messages, and - // /me (a plain page via injectSiteChrome) has no .tdoc-doc-frame — so the - // comment machinery stays dormant by construction. - assert(overlay.includes("document.querySelector('.tdoc-doc-frame')"), - 'comment chrome must key off the doc frame (absent on /me)'); +t('Docs Hub mutations use session cookies and never ask for an admin token', () => { + assert(shellApi.includes("credentials: 'same-origin'"), 'session credentials missing'); + assert(!/TDOC_UPLOAD_TOKEN|admin-token|Authorization/.test(hub), 'token admin path remains'); + assert(!hub.includes('/api/doc/access'), 'catalog must not own access controls'); }); -t('/me does not introduce a bespoke cookie-only admin-auth path', () => { - // The session path now used everywhere is the SHARED authorizeOwnerMutation - // gate (session OR token) — not a one-off same-origin/cookie check bolted - // onto just this route. - assert(!worker.includes('requireAdminAuth'), 'worker must not add a separate cookie-based admin-auth function'); - assert(!worker.includes('isSameOriginRequest'), 'same-origin is not sufficient when docs are arbitrary same-origin HTML'); +t('shell builder emits a generic empty app root rather than /me HTML', () => { + assert(shell.includes('
'), 'generic React root missing'); + assert(shell.includes('window.__TDOC_APP_BOOT__'), 'app boot payload missing'); + assert(!/doc-search|batch-delete/.test(shell), 'Docs Hub markup leaked into server builder'); }); -t('/me non-owner bounce goes to landing with notice, not github.com', () => { - const meStart = worker.indexOf("if (p === '/me' && method === 'GET')"); - const meEnd = worker.indexOf('// ---- doc view ----', meStart); - assert(meStart >= 0 && meEnd > meStart, '/me route block missing'); - const meRoute = worker.slice(meStart, meEnd); - assert(meRoute.includes('canSeeMyDocs(env, s, url.origin)'), - '/me must gate on canSeeMyDocs (hosted per-user or BYOK TDOC_OWNER)'); - assert(!worker.includes('isOwnerSession gate in the'), - '/me must not document the retired owner-only gate'); - assert(!overlay.includes('cfg.canSeeMyDocs || cfg.isOwner'), - 'My docs must not fall back to this-doc isOwner'); - assert(!overlay.includes('else if (me && me.isOwner) canSeeMyDocs'), - 'device-flow must not treat isOwner as canSeeMyDocs'); - assert(!overlay.includes("typeof me.isOwner === 'boolean') isOwner"), - 'device-flow must not clobber per-doc isOwner from /api/auth/me'); - assert(meRoute.includes("Location: `/?notice=${notice}`") || meRoute.includes("Location: '/?notice="), - '/me must redirect to landing ?notice=…'); - assert(!meRoute.includes('github.com/tornado-doc/tdoc'), - '/me must not redirect to the GitHub repo'); +t('remote delete uses the shared owner authorization gate', () => { + const start = worker.indexOf("if (p === '/api/doc' && method === 'DELETE')"); + const route = worker.slice(start, start + 2200); + assert(route.includes('await authorizeOwnerMutation(req, env, slug)'), 'shared owner gate missing'); }); -t('landing supports sign-in + toast notices; /auth/done soft-lands OAuth callback', () => { - assert(worker.includes('function landingHtml(env, notice)'), 'landingHtml must take a notice'); - assert(worker.includes("id=\"signin\""), 'landing must offer Sign in with GitHub'); - assert(worker.includes('function authDoneHtml('), 'authDoneHtml missing'); - assert(worker.includes("/auth/done"), 'must serve /auth/done for OAuth callback URL'); - assert(worker.includes("Location: '/?notice=notfound'"), - 'unknown GET paths must bounce to landing with notfound notice'); +t('unknown and unauthorized navigation preserves a neutral landing notice', () => { + assert(worker.includes('function neutralLandingResponse(env, notice)'), 'React landing response missing'); + assert(worker.includes("Location: '/?notice=notfound'"), 'not-found notice redirect missing'); + assert(worker.includes("page: 'neutral-landing'"), 'neutral landing boot missing'); }); console.log(`\n${pass} passed, ${fail} failed`); diff --git a/test/no-drift.test.js b/test/no-drift.test.js index 0bbcbcd..139014a 100644 --- a/test/no-drift.test.js +++ b/test/no-drift.test.js @@ -48,8 +48,6 @@ const norm = (s) => s == null ? null : s.replace(/\s+/g, ' ').trim(); const worker = read('worker/worker.js'); const server = read('server/server.js'); -// overlay.js is deleted; chrome.js is the shared client-markup module now. -const overlay = read('server/chrome.js'); const githubOauth = require(path.join(root, 'shared/github-oauth.js')); console.log('no-drift (duplicated helper guard)'); diff --git a/test/oldver-strip.test.js b/test/oldver-strip.test.js index 1f0cc0a..e13d0bc 100644 --- a/test/oldver-strip.test.js +++ b/test/oldver-strip.test.js @@ -88,7 +88,8 @@ t('worker shellDocumentWorker still contains the published+multi-version guard', // latestVersion must be robust to unsorted versions (Math.max over the list). assert(/Math\.max\(\.\.\.vlist\.map/.test(src), 'latestVersion must be derived from the whole version list'); - assert(src.includes('tdoc-oldver-strip'), 'strip markup missing from the shell render'); + const react = fs.readFileSync(path.join(__dirname, '..', 'shell/src/document-shell.jsx'), 'utf8'); + assert(react.includes('tdoc-oldver-strip'), 'strip markup missing from the React shell'); }); diff --git a/test/overlay-inbox.test.js b/test/overlay-inbox.test.js index cfc0e9a..57f297d 100644 --- a/test/overlay-inbox.test.js +++ b/test/overlay-inbox.test.js @@ -1,92 +1,56 @@ -// Inbox click → doc/comment deep-link contract. #180 -// -// The inbox lives in the shell client script (server/shell.js) now. Chrome UI -// is mostly un-runnable DOM, so this suite pins the wiring in source: -// destination URL builder, stopPropagation on the row, consume-once deep-link -// re-arming for same-doc clicks, and mark-read on open. -// -// Run with: node test/overlay-inbox.test.js - const fs = require('fs'); const path = require('path'); let pass = 0, fail = 0; -function ok(n) { console.log(` ✓ ${n}`); pass++; } -function bad(n, e) { console.log(` ✗ ${n}\n ${e}`); fail++; } -function t(n, fn) { try { fn(); ok(n); } catch (e) { bad(n, e.message); } } -function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } - -const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'shell.js'), 'utf8'); +function t(name, fn) { try { fn(); console.log(` ✓ ${name}`); pass++; } catch (error) { console.log(` ✗ ${name}\n ${error.message}`); fail++; } } +function assert(value, message) { if (!value) throw new Error(message); } -function sliceFn(name) { - const start = src.indexOf(`function ${name}(`); - if (start === -1) throw new Error(`fn ${name} not found`); - let i = src.indexOf('{', start), depth = 0; - for (; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}') { depth--; if (depth === 0) { i++; break; } } - } - return src.slice(start, i); -} +const root = path.join(__dirname, '..'); +const dialog = fs.readFileSync(path.join(root, 'shell/src/notifications-dialog.jsx'), 'utf8'); +const hook = fs.readFileSync(path.join(root, 'shell/src/hooks/use-notifications.js'), 'utf8'); +const shell = fs.readFileSync(path.join(root, 'shell/src/document-shell.jsx'), 'utf8'); +const topBar = fs.readFileSync(path.join(root, 'shell/src/top-bar.jsx'), 'utf8'); -console.log('shell inbox (#180 notification deep-link)'); - -t('inboxTargetUrl is the only destination builder', () => { - const open = sliceFn('openInboxTarget'); - assert(open.includes('inboxTargetUrl(row)'), 'openInboxTarget must use inboxTargetUrl'); - assert(open.includes('location.assign(href)'), 'other-doc clicks must navigate'); - assert(!open.includes('/d/'), 'openInboxTarget must not build /d/ URLs itself'); -}); +console.log('React notifications and deep links'); -t('inboxTargetUrl never emits /d/undefined and encodes its parts', () => { - const fn = sliceFn('inboxTargetUrl'); - assert(fn.includes("if (!destSlug) return ''"), 'empty slug must return empty, never /d/undefined'); - assert(fn.includes('encodeURIComponent(destSlug)'), 'slug must be encoded'); - assert(fn.includes('encodeURIComponent(target)'), 'comment id must be encoded'); +t('notificationTarget is the single encoded destination builder', () => { + assert(dialog.includes('export function notificationTarget'), 'destination builder missing'); + assert(dialog.includes('if (!item?.slug) return'), 'empty slug guard missing'); + assert(dialog.includes('encodeURIComponent(item.slug)'), 'slug encoding missing'); + assert(dialog.includes('encodeURIComponent(target)'), 'comment encoding missing'); + assert(topBar.includes('notificationTarget(item)'), 'TopBar bypasses destination builder'); }); -t('inbox row click stops the document unpin handler from seeing it', () => { - const write = sliceFn('writeInboxRows'); - assert(write.includes('e.stopPropagation'), 'row click must stopPropagation'); - assert(write.includes('openInboxTarget(row)'), 'row click must open the target'); - assert(write.includes('closeAuxModal()'), 'row click must close the panel'); +t('opening Notifications loads rows and selecting one marks it read', () => { + assert(topBar.includes('notifications.load()'), 'open does not load notifications'); + assert(topBar.includes('notifications.markRead(item)'), 'selection does not mark read'); + assert(hook.includes("markNotificationsRead([item.id])"), 'read API call missing'); }); -t('same-doc inbox click re-arms the consume-once deep link in place', () => { - const open = sliceFn('openInboxTarget'); - assert(open.includes('deepLinkDone = false'), 'same-doc must re-arm the consumed deep link'); - assert(open.includes('history.replaceState'), 'same-doc must update the URL without navigation'); - assert(open.includes('!cfg.isCatalog && location.pathname === destPath'), - 'catalog must not take the in-place path'); +t('same-document notifications re-arm the deep link without navigation', () => { + assert(shell.includes('const sameDocument = item.slug === config.slug'), 'same-document comparison missing'); + assert(shell.includes("history.replaceState(null, '', target)"), 'URL is not updated in place'); + assert(shell.includes('setDeepTarget(commentId)'), 'deep-link state is not re-armed'); }); -t('opening a comment card marks its notification read', () => { - const openCard = sliceFn('openCard'); - assert(openCard.includes('markInboxSeen(id)'), 'openCard must mark the notification read'); - const seen = sliceFn('markInboxSeen'); - assert(seen.includes("'/api/notifications/read'"), 'mark-read must hit the notifications API'); - assert(seen.includes("credentials:'same-origin'") || seen.includes("credentials: 'same-origin'"), - 'mark-read must send the session cookie'); +t('other-document notifications navigate to the canonical destination', () => { + assert(shell.includes('location.href = target'), 'cross-document navigation missing'); }); -t('inbox poll is fingerprint-diffed and paused while typing or hidden', () => { - const tick = sliceFn('tickInbox'); - assert(tick.includes('document.hidden'), 'poll must not run in background tabs'); - assert(tick.includes("textarea:focus"), 'poll must not run while typing'); - assert(tick.includes('inboxFingerprint'), 'poll must diff payloads before repainting'); - assert(tick.includes('loadComments()'), 'a changed inbox refreshes comments'); +t('polling pauses in hidden tabs and while the user is typing', () => { + assert(hook.includes('document.hidden'), 'background-tab guard missing'); + assert(hook.includes("input, textarea, [contenteditable=\"true\"]"), 'typing guard missing'); + assert(hook.includes('30_000'), 'poll interval missing'); }); -t('reply deep-links expand the thread when the card opens', () => { - const deep = sliceFn('tryDeepLink'); - assert(deep.includes("classList.add('open')"), 'reply deep-link must expand .tdoc-replies'); +t('reply deep-links resolve to the parent and open the thread', () => { + assert(shell.includes('comment.replies?.some'), 'reply-to-parent lookup missing'); + assert(shell.includes('expandReplies={deepReply}'), 'reply thread expansion missing'); }); -t('deep-link reads ?comment= from the page URL, consume-once', () => { - const cap = sliceFn('captureDeepLink'); - assert(cap.includes("URLSearchParams(location.search).get('comment')"), - 'captureDeepLink must read location.search comment'); - assert(cap.includes('deepLinkDone'), 'deep link must be consume-once (no re-fire after posts)'); +t('identity menu shows unread state and notifications command', () => { + assert(topBar.includes('tdoc-unread-dot'), 'unread dot missing'); + assert(topBar.includes('Notifications{notifications.unread'), 'unread menu label missing'); }); console.log(`\n${pass} passed, ${fail} failed`); diff --git a/test/overlay-pure.test.js b/test/overlay-pure.test.js index 9cb84cc..be244b8 100644 --- a/test/overlay-pure.test.js +++ b/test/overlay-pure.test.js @@ -1,205 +1,94 @@ -// Overlay pure-function tests (partial resolution of #23). -// -// The review flagged the 2758-line overlay IIFE as having "no unit-testable -// surface". A full decomposition into ES modules + bundle change is deferred -// (high-risk: the overlay is almost all un-runnable DOM code). This test takes -// the pragmatic first step: it gives the genuinely PURE functions (string/math, -// no DOM) real unit coverage by VM-extracting them from the source — so the -// anchor-matching string core is now guarded against regression, and these -// functions become a documented, testable surface. -// -// Pure functions covered: escapeHtml, normalizeNeedle, normalizeContext, -// normalizeQuery, commonPrefixLen, commonSuffixLen, isVisibleClientRect, -// nearestClientRect, endRectOnLine. -// -// Run with: node test/overlay-pure.test.js - +// Pure helpers at the React/frame boundary. const fs = require('fs'); const path = require('path'); const vm = require('vm'); let pass = 0, fail = 0; -function ok(n) { console.log(` ✓ ${n}`); pass++; } -function bad(n, e) { console.log(` ✗ ${n}\n ${e}`); fail++; } -function t(n, fn) { try { fn(); ok(n); } catch (e) { bad(n, e.message); } } -function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } +function t(name, fn) { try { fn(); console.log(` ✓ ${name}`); pass++; } catch (error) { console.log(` ✗ ${name}\n ${error.message}`); fail++; } } +function assert(value, message) { if (!value) throw new Error(message); } + +const root = path.join(__dirname, '..'); +const sources = [ + 'server/frame-probe.js', + 'shell/src/document/model.js', + 'shell/src/sign-in-dialog.jsx', + 'shell/src/notifications-dialog.jsx', + 'shell/src/document-shell.jsx', +].map((file) => fs.readFileSync(path.join(root, file), 'utf8')); +const combined = sources.join('\n'); -// isGithubHttpsUrl moved to server/signin.js when the two device-flow -// implementations were merged into one. Search both files for a function so -// this stays a test of behaviour rather than of file layout. -const src = ['frame-probe.js', 'chrome.js', 'shell.js', 'signin.js'].map(f => fs.readFileSync(path.join(__dirname, '..', 'server', f), 'utf8')).join('\n') - + '\n' + fs.readFileSync(path.join(__dirname, '..', 'server', 'signin.js'), 'utf8'); -function sliceFn(name) { - // overlay functions are indented inside the IIFE; match `function name(` - const start = src.indexOf(`function ${name}(`); - if (start === -1) throw new Error(`fn ${name} not found in overlay.js or signin.js`); - let i = src.indexOf('{', start), depth = 0; - for (; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}') { depth--; if (depth === 0) { i++; break; } } +function sliceFunction(name) { + const start = combined.indexOf(`function ${name}(`); + if (start < 0) throw new Error(`${name} missing`); + let index = combined.indexOf('{', start); + let depth = 0; + for (; index < combined.length; index++) { + if (combined[index] === '{') depth++; + else if (combined[index] === '}' && --depth === 0) return combined.slice(start, index + 1); } - return src.slice(start, i); + throw new Error(`${name} is not balanced`); } -// isGithubHttpsUrl uses the global URL constructor; expose it in the sandbox. const box = { URL }; vm.createContext(box); vm.runInContext([ - 'escapeHtml', 'normalizeNeedle', 'normalizeContext', - 'commonPrefixLen', 'commonSuffixLen', 'isGithubHttpsUrl', - 'isAnthropicCompanyMark', 'tdocLogoUrl', 'agentLogoUrl', - 'inboxTargetUrl', 'findCommentRoot', -].map(sliceFn).join('\n\n'), box); -box.cfg = {}; // the shell inboxTargetUrl reads the page cfg for fallbacks -const { escapeHtml, normalizeNeedle, normalizeContext, - commonPrefixLen, commonSuffixLen, isGithubHttpsUrl, agentLogoUrl, - inboxTargetUrl, findCommentRoot } = box; + 'normalizeNeedle', 'normalizeContext', 'commonPrefixLen', 'commonSuffixLen', + 'isGitHubUrl', 'avatarFor', 'notificationTarget', +].map(sliceFunction).join('\n'), box); -console.log('overlay-pure (#23 testable surface)'); +console.log('React/frame pure helpers'); -// escapeHtml — the overlay renders comment text/author via innerHTML, so this -// is the XSS-relevant escaper. -t('escapeHtml encodes all five dangerous characters', () => { - assert(escapeHtml(`&`) === '<a href="x" onclick='y'>&'); -}); -t('escapeHtml coerces non-strings without throwing', () => { - assert(escapeHtml(42) === '42'); - assert(escapeHtml(null) === 'null'); -}); -t('escapeHtml leaves safe text untouched', () => { - assert(escapeHtml('hello world 123') === 'hello world 123'); +t('anchor normalization collapses whitespace consistently', () => { + assert(box.normalizeNeedle(' a b\n c ') === 'a b c'); + assert(box.normalizeContext(' a b ') === ' a b '); }); -// normalize* — used by text-anchor matching; whitespace collapsing must be -// consistent or anchors drift. -t('normalizeNeedle collapses internal whitespace and trims', () => { - assert(normalizeNeedle(' a b\n\tc ') === 'a b c'); - assert(normalizeNeedle('') === ''); - assert(normalizeNeedle(null) === ''); -}); -t('normalizeContext collapses whitespace but does NOT trim (preserves edges)', () => { - assert(normalizeContext(' a b ') === ' a b '); - assert(normalizeContext(null) === ''); -}); -// (normalizeQuery was an overlay alias of normalizeNeedle; gone with the monolith.) -// common prefix/suffix — used by the fuzzy re-anchor fallback. -t('commonPrefixLen counts the shared leading run', () => { - assert(commonPrefixLen('abcXYZ', 'abcDEF') === 3); - assert(commonPrefixLen('', 'abc') === 0); - assert(commonPrefixLen('same', 'same') === 4); -}); -t('commonSuffixLen counts the shared trailing run', () => { - assert(commonSuffixLen('XYZabc', 'DEFabc') === 3); - assert(commonSuffixLen('abc', '') === 0); - assert(commonSuffixLen('tail', 'tail') === 4); -}); -t('prefix/suffix handle no-overlap', () => { - assert(commonPrefixLen('abc', 'xyz') === 0); - assert(commonSuffixLen('abc', 'xyz') === 0); +t('fuzzy anchor prefix/suffix scores are stable', () => { + assert(box.commonPrefixLen('abcX', 'abcY') === 3); + assert(box.commonSuffixLen('Xabc', 'Yabc') === 3); + assert(box.commonPrefixLen('a', 'b') === 0); }); -// isGithubHttpsUrl — audit fix: startDeviceFlow only window.open()s the -// verification URL if it's an https github.com URL, never an arbitrary string. -t('isGithubHttpsUrl accepts https github.com URLs', () => { - assert(isGithubHttpsUrl('https://github.com/login/device') === true); - assert(isGithubHttpsUrl('https://github.com') === true); -}); -t('isGithubHttpsUrl rejects non-github / non-https / junk', () => { - assert(isGithubHttpsUrl('http://github.com/login/device') === false, 'http rejected'); - assert(isGithubHttpsUrl('https://evil.com/login') === false, 'other host rejected'); - assert(isGithubHttpsUrl('https://github.com.evil.com') === false, 'suffix-spoof rejected'); - assert(isGithubHttpsUrl('javascript:alert(1)') === false, 'js scheme rejected'); - assert(isGithubHttpsUrl('not a url') === false, 'garbage rejected'); - assert(isGithubHttpsUrl(null) === false, 'null rejected'); +t('device flow opens only HTTPS GitHub URLs', () => { + assert(box.isGitHubUrl('https://github.com/login/device')); + assert(!box.isGitHubUrl('http://github.com/login/device')); + assert(!box.isGitHubUrl('https://github.com.evil.test')); + assert(!box.isGitHubUrl('javascript:alert(1)')); }); -t('agentLogoUrl maps grok/claude/codex/cursor/gemini logins to product marks', () => { - assert(agentLogoUrl({ login: 'grok' }).includes('xai-org'), 'grok'); - assert(agentLogoUrl({ login: 'claude-code' }).includes('claude'), 'claude'); - assert(!agentLogoUrl({ login: 'claude' }).includes('anthropic'), 'claude is not the company mark'); - assert(agentLogoUrl({ login: 'codex' }).includes('openai'), 'codex'); - assert(agentLogoUrl({ login: 'cursor' }).includes('cursor'), 'cursor'); - assert(agentLogoUrl({ login: 'gemini' }).includes('gemini'), 'gemini'); - assert(agentLogoUrl({ login: 'tdoc-agent' }).includes('tdoc_logo.svg'), 'tdoc logo'); - assert(agentLogoUrl({ login: 'mystery-bot' }).includes('tdoc_logo.svg'), 'unmatched uses tdoc logo'); - assert(!String(agentLogoUrl({ login: 'tdoc-agent' })).includes('⚡'), 'no lightning'); -}); -t('agentLogoUrl prefers an explicit https avatar_url', () => { - assert(agentLogoUrl({ login: 'grok', avatar_url: 'https://example.com/me.png' }) === 'https://example.com/me.png'); +t('agent avatars map to product marks without the Anthropic company avatar', () => { + assert(box.avatarFor({ kind: 'agent', login: 'claude-code' }).includes('simpleicons.org/claude')); + assert(box.avatarFor({ kind: 'agent', login: 'codex' }).includes('openai')); + assert(box.avatarFor({ kind: 'agent', login: 'grok' }).includes('xai-org')); + assert(box.avatarFor({ kind: 'agent', login: 'gemini' }).includes('googlegemini')); }); -t('agentLogoUrl never shows the Anthropic company AI mark for Claude', () => { - const star = 'https://cdn.simpleicons.org/claude/d97757'; - const company = 'https://github.com/anthropics.png'; - assert(agentLogoUrl({ login: 'claude', avatar_url: company }) === star, 'stored company mark ignored'); - assert(agentLogoUrl({ login: 'claude-code', avatar_url: company }) === star, 'claude-code'); - assert(agentLogoUrl({ login: 'tdoc-agent', avatar_url: company }) === star, 'orphan company mark remapped'); - assert(!String(agentLogoUrl({ login: 'claude' }) || '').includes('anthropics'), 'mapped url is not anthropics'); -}); -// (childrenOf died with the overlay reply-tree renderer; the shell renders -// replies flat via chrome.js buildCard, covered by artifact-shell.test.js.) -t('inboxTargetUrl builds /d//v/?comment=', () => { - assert(inboxTargetUrl( - { slug: 'conway-life', version: 3, comment_id: 'c_1' }, - { slug: 'other', version: 1 } - ) === '/d/conway-life/v/3?comment=c_1'); -}); -t('inboxTargetUrl falls back to the current doc when the row has no slug', () => { - box.cfg = { slug: 'sample-doc', version: 2 }; - assert(inboxTargetUrl({ comment_id: 'r_9', thread_id: 'c_1' }) === '/d/sample-doc/v/2?comment=r_9'); - box.cfg = {}; -}); -t('inboxTargetUrl never emits /d/undefined', () => { - assert(inboxTargetUrl({}, {}) === ''); - assert(inboxTargetUrl({ comment_id: 'c_1' }, { version: 1 }) === ''); - assert(inboxTargetUrl(null, null) === ''); -}); -t('inboxTargetUrl encodes slug and comment id', () => { - assert(inboxTargetUrl( - { slug: 'a b', version: 1, comment_id: 'c 1' }, - {} - ) === '/d/a%20b/v/1?comment=c%201'); -}); -t('inboxTargetUrl treats bad versions as 1', () => { - assert(inboxTargetUrl({ slug: 'd', version: 'nope', comment_id: 'c' }, {}) === '/d/d/v/1?comment=c'); - assert(inboxTargetUrl({ slug: 'd', version: 0 }, {}) === '/d/d/v/1'); -}); -// (isVisibleClientRect / endRectOnLine / nearestClientRect died with the overlay -// popup geometry; the probe's selectionRect + boundary tests cover that behavior.) -t('findCommentRoot maps a reply id to its top-level card', () => { - const list = [ - { id: 'c1', replies: [{ id: 'r1' }, { id: 'r2' }] }, - { id: 'c2', replies: [] }, - ]; - box.commentList = list; // the shell version reads the page comment list - assert(findCommentRoot('c1') === 'c1'); - assert(findCommentRoot('r2') === 'c1'); - assert(findCommentRoot('c2') === 'c2'); - assert(findCommentRoot('missing') === null); // shell: unknown id resolves to null (deep link ignored) - box.commentList = []; - assert(findCommentRoot('') === null); - assert(findCommentRoot('c1') === null); // empty list → nothing to map to + +t('notification destinations validate and encode slug/version/comment', () => { + assert(box.notificationTarget(null) === ''); + assert(box.notificationTarget({ slug: 'a b', version: 3, comment_id: 'c 1' }) === '/d/a%20b/v/3?comment=c%201'); + assert(box.notificationTarget({ slug: 'd', version: 0 }) === '/d/d/v/1'); }); -// --- dark-default hint + copy primitive (source guards; live behavior in ui.test.js) --- -// These features are DOM/clipboard-bound so they can't run pure here; guard the -// wiring at the source so an accidental removal or the flashCopied() name -// collision (two same-named fns → last wins) can't slip back in unnoticed. -t('default-theme hint: probe reports it, shell applies only without a stored pref', () => { - assert(/data-tdoc-default-theme/.test(src), 'probe must report the doc-declared default theme'); - assert(/defaultTheme === 'dark'/.test(src), 'shell must apply the dark hint'); - assert(/localStorage.getItem\('tdoc-theme'\)/.test(src), 'a stored tdoc-theme must win over the hint'); +t('frame copy and theme protocols remain framework-free', () => { + assert(combined.includes('[data-tdoc-copy]'), 'copy primitive missing'); + assert(combined.includes('tdoc:copyText'), 'copy bridge missing'); + assert(combined.includes('data-tdoc-default-theme'), 'theme hint missing'); + assert(combined.includes("message.defaultTheme === 'dark'"), 'shell theme hint handling missing'); }); -t('the probe exposes the data-tdoc-copy click-to-copy primitive', () => { - assert(/\[data-tdoc-copy\]/.test(src), 'delegated handler targets [data-tdoc-copy]'); - assert(/tdoc:copyText/.test(src), 'copy text is bridged to the shell (frame clipboard is unreliable)'); - assert(/function tdocFallbackCopy/.test(src), 'execCommand fallback must run on the user gesture'); + +t('React text rendering does not reintroduce HTML string escaping helpers', () => { + const comments = fs.readFileSync(path.join(root, 'shell/src/document/comment-card.jsx'), 'utf8'); + assert(comments.includes('{comment.text}'), 'comment text is not rendered as React text'); + assert(!comments.includes('dangerouslySetInnerHTML'), 'comment content bypasses React escaping'); }); -t('copy primitives use distinctly named flash helpers (no flashCopied collision)', () => { - assert(/function flashCopy\(/.test(src), 'the probe flashCopy helper must exist'); - // Copy-as-Markdown moved into the ⋯ menu and now confirms with a toast, so the - // old bar-button flashCopied helper is gone — there must be none left to collide. - const n = (src.match(/function flashCopied\(/g) || []).length; - assert(n === 0, `flashCopied should be gone (toast now), found ${n}`); + +t('all comment mutations share the sign-in fallback', () => { + const hook = fs.readFileSync(path.join(root, 'shell/src/hooks/use-comments.js'), 'utf8'); + assert(hook.includes('const mutate = useCallback'), 'shared mutation wrapper missing'); + assert(hook.includes('error.status === 401 && onUnauthorized'), '401 sign-in fallback missing'); + for (const operation of ['createComment', 'toggleReaction', 'removeComment', 'updateCommentAnchor']) { + assert(hook.includes(`mutate(() => ${operation}`), `${operation} bypasses the shared mutation wrapper`); + } }); console.log(`\n${pass} passed, ${fail} failed`); diff --git a/test/pins-layout.test.js b/test/pins-layout.test.js index 3fd8a11..a694eae 100644 --- a/test/pins-layout.test.js +++ b/test/pins-layout.test.js @@ -1,114 +1,55 @@ -// Pins layout tests (v0.8.0 pins-in-margin feature). -// -// The 0.8.0 release replaced the margin card-stack with pins whose placement is -// decided by a pure clustering/spreading/overflow algorithm: layoutPins() in -// server/overlay.js. That math had ZERO automated coverage — a regression in -// the thresholds or the overflow-fold would ship green. This VM-extracts the -// pure function (same pattern as overlay-pure.test.js) and pins its invariants: -// - same-line comments (within SAME_LINE_GAP) merge into one cluster -// - comments just outside SAME_LINE_GAP spread (>= PIN_MIN_GAP apart) -// - multiple comments on ONE tall element distribute down it; on a SHORT -// element they cluster -// - the overflow-fold guarantees no placed pin exceeds articleTop+articleHeight -// -// Run with: node test/pins-layout.test.js - const fs = require('fs'); const path = require('path'); const vm = require('vm'); let pass = 0, fail = 0; -function ok(n) { console.log(` ✓ ${n}`); pass++; } -function bad(n, e) { console.log(` ✗ ${n}\n ${e}`); fail++; } -function t(n, fn) { try { fn(); ok(n); } catch (e) { bad(n, e.message); } } -function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } - -const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'chrome.js'), 'utf8'); -function sliceFn(name) { - const start = src.indexOf(`function ${name}(`); - if (start === -1) throw new Error(`fn ${name} not found in overlay.js`); - let i = src.indexOf('{', start), depth = 0; - for (; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}') { depth--; if (depth === 0) { i++; break; } } - } - return src.slice(start, i); -} +function t(name, fn) { try { fn(); console.log(` ✓ ${name}`); pass++; } catch (error) { console.log(` ✗ ${name}\n ${error.message}`); fail++; } } +function assert(value, message) { if (!value) throw new Error(message); } +const source = fs.readFileSync(path.join(__dirname, '..', 'shell/src/document/model.js'), 'utf8'); +const start = source.indexOf('export function layoutPins('); +const body = source.slice(start).replace(/export /g, ''); const box = {}; vm.createContext(box); -vm.runInContext(sliceFn('layoutPins'), box); +vm.runInContext(`${body}\nthis.layoutPins = layoutPins;`, box); const { layoutPins } = box; +const pin = (id, docY, extra = {}) => ({ id, docY, ...extra }); -// The real constants from overlay.js (PIN_SIZE=28 → PIN_MIN_GAP=32, SAME_LINE_GAP=12). -const CONSTS = { PIN_SIZE: 28, PIN_MIN_GAP: 32, SAME_LINE_GAP: 12 }; -const GEO = { articleTop: 0, articleHeight: 10000 }; // roomy by default -// Build a placeable row (mirrors commentY output shape). -const row = (id, y, extra = {}) => ({ c: { id }, y, el: null, elTop: 0, elHeight: 0, ...extra }); - -console.log('pins-layout (v0.8.0 pins core)'); - -// ---- same-line clustering ---- -t('two comments within SAME_LINE_GAP merge into ONE cluster', () => { - const placed = layoutPins([row('a', 100), row('b', 108)], GEO, CONSTS); // 8px apart < 12 - assert(placed.length === 1, `expected 1 cluster, got ${placed.length}`); - assert(placed[0].items.length === 2, 'both comments should be in the cluster'); -}); +console.log('React pin layout'); -t('two comments just OUTSIDE SAME_LINE_GAP stay separate and spread >= PIN_MIN_GAP', () => { - const placed = layoutPins([row('a', 100), row('b', 120)], GEO, CONSTS); // 20px apart > 12 - assert(placed.length === 2, `expected 2 clusters, got ${placed.length}`); - const gap = placed[1].y - placed[0].y; - assert(gap >= CONSTS.PIN_MIN_GAP, `pins must be >= PIN_MIN_GAP apart, got ${gap}`); +t('pins within 12px cluster', () => { + const result = layoutPins([pin('a', 100), pin('b', 108)], 1000); + assert(result.length === 1 && result[0].items.length === 2, 'same-line comments did not cluster'); }); -t('overlapping-but-not-same-line pins are pushed down to PIN_MIN_GAP', () => { - // 100 and 118: 18px apart (> SAME_LINE_GAP so separate, < PIN_MIN_GAP so must spread) - const placed = layoutPins([row('a', 100), row('b', 118)], GEO, CONSTS); - assert(placed.length === 2, 'should be two separate pins'); - assert(placed[1].y - placed[0].y >= CONSTS.PIN_MIN_GAP, 'second pin pushed down to min gap'); +t('separate pins maintain a 32px minimum gap', () => { + const result = layoutPins([pin('a', 100), pin('b', 118)], 1000); + assert(result.length === 2, 'distinct pins clustered'); + assert(result[1].y - result[0].y >= 32, 'minimum gap regressed'); }); -// ---- same-element spread vs cluster ---- -t('multiple comments on ONE TALL element distribute down it (individual pins)', () => { - const el = {}; // identity token for "same element" - const rows = [ - row('a', 200, { el, elTop: 200, elHeight: 300 }), - row('b', 200, { el, elTop: 200, elHeight: 300 }), - row('c', 200, { el, elTop: 200, elHeight: 300 }), - ]; - const placed = layoutPins(rows, GEO, CONSTS); - assert(placed.length === 3, `tall element should spread to 3 pins, got ${placed.length}`); +t('comments on a tall element spread down the element', () => { + const shared = { elementKey: '#chart', elementTop: 200, elementHeight: 300 }; + const result = layoutPins([ + pin('a', 200, shared), pin('b', 200, shared), pin('c', 200, shared), + ], 1000); + assert(result.length === 3, `expected 3 pins, got ${result.length}`); }); -t('multiple comments on a SHORT element cluster into one badge', () => { - const el = {}; - const rows = [ - row('a', 200, { el, elTop: 200, elHeight: 20 }), // usable < PIN_MIN_GAP → no spread - row('b', 200, { el, elTop: 200, elHeight: 20 }), - ]; - const placed = layoutPins(rows, GEO, CONSTS); - assert(placed.length === 1, `short element should cluster, got ${placed.length}`); - assert(placed[0].items.length === 2, 'both comments in one cluster'); +t('comments on a short element remain clustered', () => { + const shared = { elementKey: '#small', elementTop: 200, elementHeight: 20 }; + const result = layoutPins([pin('a', 200, shared), pin('b', 200, shared)], 1000); + assert(result.length === 1 && result[0].items.length === 2, 'short element should cluster'); }); -// ---- overflow-fold invariant ---- -t('OVERFLOW: no placed pin exceeds articleTop+articleHeight (tail folds in)', () => { - const tight = { articleTop: 0, articleHeight: 100 }; // only ~3 pins fit at 32px gap - const rows = []; - for (let i = 0; i < 10; i++) rows.push(row('c' + i, i * 40)); // 10 pins, way past 100px - const placed = layoutPins(rows, tight, CONSTS); - const limit = tight.articleTop + tight.articleHeight; - for (const cl of placed) { - assert(cl.y <= limit, `pin at y=${cl.y} exceeds bottom limit ${limit}`); - } - // every comment is still accounted for (folded into a cluster, never dropped) - const total = placed.reduce((n, cl) => n + cl.items.length, 0); - assert(total === 10, `all 10 comments must be placed, got ${total}`); +t('overflow folds into the last visible cluster without dropping comments', () => { + const result = layoutPins(Array.from({ length: 10 }, (_, index) => pin(`c${index}`, index * 40)), 100); + assert(result.every((cluster) => cluster.y <= 100), 'pin exceeded document height'); + assert(result.reduce((sum, cluster) => sum + cluster.items.length, 0) === 10, 'comments were dropped'); }); t('empty input yields no clusters', () => { - assert(layoutPins([], GEO, CONSTS).length === 0, 'empty rows → no pins'); + assert(layoutPins([], 100).length === 0, 'empty input is not empty'); }); console.log(`\n${pass} passed, ${fail} failed`); diff --git a/test/publish.test.js b/test/publish.test.js index 9648914..5438c6b 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -72,7 +72,9 @@ function freePort() { await t('Click Publish opens modal with slug + Publish action', async () => { await page.click('#tdoc-publish-btn'); - await page.waitForSelector('#tdoc-aux-modal', { timeout: 2000 }); + // The React shell renders dialogs through the shared AppDialog facade + // (Base UI portal) — the contract is an accessible dialog, not a fixed id. + await page.waitForSelector('[role="dialog"] .ui-dialog-popup, .ui-dialog-popup[role="dialog"]', { timeout: 2000 }); const slugTxt = await page.$eval('#tdoc-pub-slug', el => el.textContent); if (slugTxt !== SLUG) throw new Error(`slug "${slugTxt}"`); const go = await page.$('#tdoc-pub-go'); @@ -81,7 +83,7 @@ function freePort() { await t('Clicking Publish (dry-run) → result URL surfaces', async () => { await page.click('#tdoc-pub-go'); - await page.waitForSelector('#tdoc-pub-result[style*="display: block"], #tdoc-pub-result:not([style*="display: none"])', { timeout: 5000 }); + await page.waitForSelector('#tdoc-pub-url', { timeout: 5000 }); const url = await page.$eval('#tdoc-pub-url', el => el.textContent.trim()); if (!url.startsWith('https://')) throw new Error(`dry url: "${url}"`); if (!url.includes(SLUG)) throw new Error(`url missing slug: "${url}"`); diff --git a/test/responsive.test.js b/test/responsive.test.js index 1c973df..15c98e8 100644 --- a/test/responsive.test.js +++ b/test/responsive.test.js @@ -81,7 +81,7 @@ async function tPub(name, fn) { }); return { narrow: document.body.classList.contains('tdoc-narrow'), - hasComments: document.querySelectorAll('.tdoc-margin-comment').length > 0, + hasComments: document.querySelector('.tdoc-fab') !== null || document.querySelectorAll('.tdoc-margin-comment').length > 0, bar: !!document.querySelector('.tdoc-bar'), more: vis('#tdoc-more-btn'), fab: vis('.tdoc-fab'), @@ -130,7 +130,7 @@ async function tPub(name, fn) { await page.waitForTimeout(250); const open = await page.evaluate(() => document.querySelector('#tdoc-comment-layer.open') !== null); if (!open) throw new Error('drawer did not gain .open'); - await page.evaluate(() => document.querySelector('#tdoc-comment-layer').classList.remove('open')); + await page.keyboard.press('Escape'); await page.waitForTimeout(120); }); await t('narrow mode: tapping the backdrop scrim dismisses the drawer', async () => { @@ -139,16 +139,16 @@ async function tPub(name, fn) { await page.evaluate(() => document.querySelector('.tdoc-fab').click()); await page.waitForTimeout(250); const scrimShown = await page.evaluate(() => { - const s = document.querySelector('#tdoc-drawer-scrim'); + const s = document.querySelector('.tdoc-drawer-backdrop'); return s && getComputedStyle(s).display !== 'none'; }); if (!scrimShown) throw new Error('scrim not visible while drawer is open'); - await page.evaluate(() => document.querySelector('#tdoc-drawer-scrim').click()); + await page.evaluate(() => document.querySelector('.tdoc-drawer-backdrop').click()); await page.waitForTimeout(250); const stillOpen = await page.evaluate(() => document.querySelector('#tdoc-comment-layer.open') !== null); if (stillOpen) throw new Error('drawer stayed open after tapping the scrim'); const scrimHidden = await page.evaluate(() => { - const s = document.querySelector('#tdoc-drawer-scrim'); + const s = document.querySelector('.tdoc-drawer-backdrop'); return !s || getComputedStyle(s).display === 'none'; }); if (!scrimHidden) throw new Error('scrim stayed visible after the drawer closed'); @@ -158,8 +158,8 @@ async function tPub(name, fn) { await t('narrow mode: More opens the secondary menu', async () => { await page.evaluate(() => document.querySelector('#tdoc-more-btn').click()); await page.waitForTimeout(150); - const open = await page.evaluate(() => document.querySelector('#tdoc-secondary-menu.open') !== null); - await page.evaluate(() => { const m = document.querySelector('#tdoc-secondary-menu'); if (m) m.classList.remove('open'); }); + const open = await page.evaluate(() => document.querySelector('.ui-menu-popup [data-action="copy"]') !== null); + await page.keyboard.press('Escape'); if (!open) throw new Error('secondary menu did not open'); }); } @@ -190,16 +190,15 @@ async function tPub(name, fn) { await t('⋯ menu carries a Copy as Markdown action', async () => { await page.evaluate(() => document.querySelector('#tdoc-more-btn').click()); await page.waitForTimeout(120); - const hasCopy = await page.evaluate(() => !!document.querySelector('#tdoc-secondary-menu.open [data-action="copy"]')); - await page.evaluate(() => { const m = document.querySelector('#tdoc-secondary-menu'); if (m) m.classList.remove('open'); }); + const hasCopy = await page.evaluate(() => !!document.querySelector('.ui-menu-popup [data-action="copy"]')); + await page.keyboard.press('Escape'); if (!hasCopy) throw new Error('no Copy action in the ⋯ menu'); }); // Published-only: identity chip present. await tPub('sign-in / identity chip present', async () => { const present = await page.evaluate(() => { - const slot = document.querySelector('#tdoc-identity-slot'); - return !!(slot && slot.children.length > 0); + return !!document.querySelector('.tdoc-chip'); }); if (!present) throw new Error('identity slot empty'); }); diff --git a/test/runtime-provenance.test.js b/test/runtime-provenance.test.js index 61771cf..af67b28 100644 --- a/test/runtime-provenance.test.js +++ b/test/runtime-provenance.test.js @@ -45,7 +45,7 @@ t('Worker has a bundled build-info placeholder and runtime endpoint', () => { 'worker must expose a build-info placeholder for bundling'); assert(worker.includes("if (p === '/api/runtime') return json({ ok: true, runtime: runtimeInfo() });"), 'worker must expose /api/runtime'); - assert(worker.includes("runtime: cfg.runtime || runtimeInfo()"), + assert(worker.includes('runtime: runtimeInfo()'), 'published boot config must include runtime provenance'); }); diff --git a/test/signin-github-tab.test.js b/test/signin-github-tab.test.js index f346aab..cf12f0e 100644 --- a/test/signin-github-tab.test.js +++ b/test/signin-github-tab.test.js @@ -1,68 +1,52 @@ -// #179: the sign-in dialog hands GitHub off to a new tab the visitor opens. -// -// The device flow's only route to GitHub used to be a scripted -// window.open() fired after `await api('/api/auth/device/start')`. That is -// outside the click's activation window, so Safari and in-app webviews -// either swallow it — leaving a dialog whose verification URL was plain -// text with nothing to click — or open it in the current tab. Losing this -// tab loses the poll loop, and the sign-in can never finish. -// -// #179 answered that with a native AND an auto -// window.open() "convenience popup". The popup was the mistake: on desktop it -// lands, so the dialog yanks the visitor to a GitHub tab the instant it opens -// — reads as a hijack, not a sign-in. So the anchor is now the ONLY hop, and -// the visitor takes it. The guard: the anchor exists, points at the -// verification URL, cannot navigate this page, and nothing opens GitHub for -// the visitor. +// GitHub device sign-in remains a deliberate native-link handoff from the +// reusable React dialog. No scripted popup may steal the current interaction. const fs = require('fs'); const path = require('path'); let pass = 0, fail = 0; -function ok(n) { console.log(` ✓ ${n}`); pass++; } -function bad(n, e) { console.log(` ✗ ${n}\n ${e}`); fail++; } -function t(n, fn) { try { fn(); ok(n); } catch (e) { bad(n, e.message); } } -function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } +function t(name, fn) { try { fn(); console.log(` ✓ ${name}`); pass++; } catch (error) { console.log(` ✗ ${name}\n ${error.message}`); fail++; } } +function assert(value, message) { if (!value) throw new Error(message); } -const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'signin.js'), 'utf8'); +const src = fs.readFileSync(path.join(__dirname, '..', 'shell', 'src', 'sign-in-dialog.jsx'), 'utf8'); +const api = fs.readFileSync(path.join(__dirname, '..', 'shell', 'src', 'document', 'api.js'), 'utf8'); -console.log('sign-in hands GitHub to a new tab (#179)'); +console.log('React sign-in GitHub handoff'); -t('the dialog ships a real anchor to the verification URL', () => { - assert(/ { + assert(/ { - const anchor = src.match(//); - assert(anchor, 'could not isolate the anchor'); - assert(/target="_blank"/.test(anchor[0]), 'anchor must target _blank'); - assert(/rel="noopener noreferrer"/.test(anchor[0]), - 'anchor must carry noopener noreferrer: the new tab gets no handle on this one'); +t('the anchor opens a protected new tab', () => { + assert(/target="_blank"/.test(src), 'anchor must target _blank'); + assert(/rel="noopener noreferrer"/.test(src), 'anchor must isolate the new tab'); }); -t('only an https github.com URL is ever linked or opened', () => { - assert(/if \(isGithubHttpsUrl\(uri\)\) \{/.test(src), - 'the anchor and the popup must both sit behind isGithubHttpsUrl'); +t('only an HTTPS github.com URL is linked', () => { + assert(/url\.protocol === 'https:'/.test(src), 'HTTPS check missing'); + assert(/github\\\.com\$/.test(src), 'github.com hostname check missing'); + assert(/isGitHubUrl\(verificationUrl\)/.test(src), 'anchor is not guarded'); }); -t('nothing opens GitHub for the visitor — they tap the anchor', () => { - // A dialog that auto-opens a GitHub tab the instant it appears reads as a - // hijack; on desktop the scripted open lands and the visitor is gone before - // reading anything. The anchor tap is the only hop. - assert(!/window\.open\(/.test(src), - 'the dialog must not open GitHub itself; the visitor taps the anchor'); - assert(/getElementById\('tds-open'\)\.addEventListener\('click'/.test(src), - 'the anchor tap must be wired: it copies the code as the visitor leaves'); - assert(/Click Open GitHub to approve/.test(src), - 'the status must point the visitor at the Open GitHub button'); +t('nothing opens GitHub automatically', () => { + assert(!/window\.open|\bopen\(/.test(src), 'dialog opens GitHub itself'); + assert(/onClick=\{\(\) => device\?\.user_code && copyText/.test(src), 'anchor tap should copy the device code'); }); -t('the poll keeps running however the visitor reaches GitHub', () => { - const after = src.slice(src.indexOf('if (isGithubHttpsUrl(uri))')); - assert(/\(function poll\(\)/.test(after), - 'the poll must start regardless of how the user reaches GitHub'); +t('polling continues after the handoff and can be cancelled', () => { + assert(/const poll = \(deviceCode\)/.test(src) && /pollDeviceSignIn\(deviceCode\)/.test(src), 'poll loop missing'); + // The effect's `device` state is the value captured at mount (null); polling + // through it never sends a real code. The code must travel via the closure. + assert(!/pollDeviceSignIn\(device\.device_code\)/.test(src), 'poll reads device_code from stale React state'); + assert(/poll\(result\.device_code\)/.test(src), 'start() must hand the fresh device code to the poll loop'); + assert(/cancelled = true/.test(src) && /clearTimeout\(timer\)/.test(src), 'poll cleanup missing'); + assert(/authorization_pending/.test(src) && /slow_down/.test(src), 'device-flow polling states missing'); +}); + +t('device endpoints stay behind the client API boundary', () => { + assert(/export function startDeviceSignIn/.test(api), 'start API wrapper missing'); + assert(/export function pollDeviceSignIn/.test(api), 'poll API wrapper missing'); + assert(!/fetch\('/.test(src), 'dialog bypasses the API module'); }); console.log(`\n${pass} passed, ${fail} failed.`); diff --git a/test/tdoc-start.test.js b/test/tdoc-start.test.js index 4b4947a..ff12bdd 100644 --- a/test/tdoc-start.test.js +++ b/test/tdoc-start.test.js @@ -1,369 +1,94 @@ -// Onboarding (#142): the /start page, the modal served with it, and the route. -// -// The flow this file guards was rewritten once already. It used to open with a -// GitHub device-flow sign-in, then ask which runtime you use, then hand over a -// token. Every one of those steps turned out to be friction with nothing -// behind it, so the assertions here are mostly about what must NOT come back. +// Onboarding contracts: /start remains framework-free author HTML while the +// provider-owned modal is a reusable React dialog in the outer shell. const fs = require('fs'); const path = require('path'); + let pass = 0, fail = 0; -function ok(n) { console.log(` ✓ ${n}`); pass++; } -function bad(n, e) { console.log(` ✗ ${n}\n ${e}`); fail++; } -function t(n, fn) { try { fn(); ok(n); } catch (e) { bad(n, e.message); } } -function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } +function t(name, fn) { try { fn(); console.log(` ✓ ${name}`); pass++; } catch (error) { console.log(` ✗ ${name}\n ${error.message}`); fail++; } } +function assert(value, message) { if (!value) throw new Error(message); } const root = path.join(__dirname, '..'); const meta = JSON.parse(fs.readFileSync(path.join(root, 'landing', 'tdoc-start', 'meta.json'), 'utf8')); const latest = meta.versions[meta.versions.length - 1].n; const html = fs.readFileSync(path.join(root, 'landing', 'tdoc-start', `v${latest}`, 'index.html'), 'utf8'); const worker = fs.readFileSync(path.join(root, 'worker', 'worker.js'), 'utf8'); -const onboard = fs.readFileSync(path.join(root, 'server', 'onboard.js'), 'utf8'); -// Strip to a fixed point. One pass is the js/incomplete-multi-character- -// sanitization pattern: `<

>` comes out the other side as `

`. -const stripTags = (src) => { - let out = src.replace(//gi, ' '), prev; - do { prev = out; out = out.replace(/<[^>]+>/g, ' '); } while (out !== prev); - return out; -}; +const dialog = fs.readFileSync(path.join(root, 'shell', 'src', 'onboarding-dialog.jsx'), 'utf8'); +const documentShell = fs.readFileSync(path.join(root, 'shell', 'src', 'document-shell.jsx'), 'utf8'); +const probe = fs.readFileSync(path.join(root, 'server', 'frame-probe.js'), 'utf8'); +const recipe = fs.readFileSync(path.join(root, 'FIRST-DOC.md'), 'utf8'); +const stripTags = (source) => source.replace(//gi, ' ').replace(/<[^>]+>/g, ' '); const text = stripTags(html); -console.log('onboarding /start (#142)'); - -t('the page runs no author JavaScript', () => { - // Published docs run under a nonce-only CSP, so an author \n` + - `window.__TDOC__ = ${safeJsonForScript(bootCfg)};\n` + - `window.__TDOC_SHELL__ = ${safeJsonForScript(bootCfg)};\n` + - `${SIGNIN_JS}\n` + - `${SHELL.shellScript()}`; - let out = rawHtml; - out = /]*>/i.test(out) ? out.replace(/]*>/i, (m) => `${m}\n${chromeCssTag}`) : chromeCssTag + out; - out = /]*>/i.test(out) ? out.replace(/]*>/i, (m) => `${m}\n
${barInner}
`) : `
${barInner}
` + out; - out = out.includes('') ? out.replace('', `${scripts}\n`) : out + scripts; - return out; -} - // The doc whose latest version IS the site homepage (#127). tdoc.dev/ renders // this published tdoc rather than a hardcoded marketing page, so the landing // page is authored, reviewed, and versioned through tdoc itself. @@ -1568,14 +1536,6 @@ const START_SLUG = 'tdoc-start'; // your agent. Same landing-doc mechanism as `/start`. const TEMPLATES_SLUG = 'tdoc-templates'; -// The onboarding modal, bundled in by bin/tdoc-bundle. Kept as a placeholder -// here so the source file stays readable and the bundle stays one artifact. -const ONBOARD_JS = `__TDOC_ONBOARD_JS__`; - -// The one GitHub device-flow client, shared by the overlay and the neutral -// landing page so a fix or a new provider lands once. See server/signin.js. -const SIGNIN_JS = `__TDOC_SIGNIN_JS__`; - // Render one published doc version as a full overlay page. Extracted so `/` // (the homepage) and `/d//v/` render through the SAME path — access // gate, version picker, owner-manage payload, nonce + CSP — instead of the @@ -1685,11 +1645,11 @@ async function landingResponse(env, req, slug = LANDING_SLUG) { try { const meta = await loadDocMeta(env, slug); const latest = meta?.versions?.[meta.versions.length - 1]?.n; - if (!latest) return html(landingHtml(env)); + if (!latest) return neutralLandingResponse(env); const res = await serveDocVersion(env, req, slug, Number(latest), true); - return res.ok ? res.response : html(landingHtml(env)); + return res.ok ? res.response : neutralLandingResponse(env); } catch { - return html(landingHtml(env)); + return neutralLandingResponse(env); } } @@ -1698,81 +1658,25 @@ async function landingResponse(env, req, slug = LANDING_SLUG) { // just brand + sign-in (when auth is configured) + a link to the open-source // project. Docs are link-only. `notice` is an optional toast reason when we // bounce users here from /me or an unknown path. -function landingHtml(env, notice) { - const authOk = !!(env && String(env.GITHUB_CLIENT_ID || '').trim()); - const authWeb = !!(env && env.GITHUB_CLIENT_SECRET); - const toastMsg = ({ +function neutralLandingResponse(env, notice) { + const messages = { me: 'My docs is only available after you sign in as the worker owner.', signin: 'Sign in with GitHub to continue.', notfound: 'That page was not found. Sign in or open a doc from its shared link.', - })[notice] || ''; - const toastJson = JSON.stringify(toastMsg); - return ` - -tdoc - -

tdoc

-

Prompt-native, commentable documents.

-
- ${authOk ? '' : ''} -
-

-

Open a document from its shared link · - github.com/tornado-doc/tdoc

-
- - - -`; -} - -function authDoneHtml() { - return ` - -tdoc — signed in - -

You're signed in

-

You can close this tab and return to tdoc.

-`; + }; + const nonce = rand(16); + return html(SHELL.appHtml({ + title: 'tdoc', + nonceAttr: ` nonce="${nonce}"`, + runtimeJsPath: SHELL_RUNTIME_JS_PATH, + runtimeCssPath: SHELL_RUNTIME_CSS_PATH, + bootJson: safeJsonForScript({ + page: 'neutral-landing', + authConfigured: !!String(env?.GITHUB_CLIENT_ID || '').trim(), + webAuth: !!env?.GITHUB_CLIENT_SECRET, + notice: messages[notice] || '', + }), + }), { headers: { 'Content-Security-Policy': cspHeader(nonce) } }); } // Web OAuth redirect flow (browsers). Device flow stays for CLIs; this is the @@ -1780,23 +1684,23 @@ function authDoneHtml() { // Approve, so nobody is stranded on GitHub's "Congratulations" page. Active // only when GITHUB_CLIENT_SECRET is set (the token exchange requires it), so a // deploy without the secret silently keeps the device flow. -function authErrorHtml(msg) { - const safe = String(msg || 'Sign-in failed.').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c]); - return ` - -tdoc — sign-in - -

Sign-in failed

-

${safe}

-

Back to tdoc

-`; +function authStatusResponse(message, { error = false, status = 200 } = {}) { + const nonce = rand(16); + return html(SHELL.appHtml({ + title: error ? 'tdoc - sign-in failed' : 'tdoc - signed in', + nonceAttr: ` nonce="${nonce}"`, + runtimeJsPath: SHELL_RUNTIME_JS_PATH, + runtimeCssPath: SHELL_RUNTIME_CSS_PATH, + bootJson: safeJsonForScript({ + page: 'status', + title: error ? 'Sign-in failed' : "You're signed in", + message, + error, + }), + }), { + status, + headers: { 'Content-Security-Policy': cspHeader(nonce) }, + }); } // Only ever redirect to a same-origin path we produced. Reject absolute URLs @@ -1963,1097 +1867,70 @@ function docReadableBy(env, session, meta) { return canReadDoc(accessFromMeta(meta || {}), session, env, meta); } -async function indexHtml(env, session, origin, nonce) { - // Catalog is title/slug/version from KV meta only. Do NOT HEAD R2 or fold - // comment logs here — that was N serial Durable-Object + R2 round trips - // per page load. Search + batch select are client-side over the rendered - // rows (no extra KV/R2 work). Delete confirm is immediate (no comment - // pre-flight) so the catalog stays snappy. - let list = []; +async function indexData(env, session, origin) { + let keys = []; let cursor; do { - const r = await env.META.list({ prefix: 'meta:', cursor }); - list = list.concat(r.keys); - cursor = r.cursor; - if (r.list_complete) break; + const page = await env.META.list({ prefix: 'meta:', cursor }); + keys = keys.concat(page.keys); + cursor = page.cursor; + if (page.list_complete) break; } while (cursor); - const hosted = hostedRegistrationEnabled(env, origin); - const catalog = await Promise.all(list.map(async (k) => { - const slug = k.name.slice('meta:'.length); - const metaRaw = await env.META.get(k.name); + const catalog = await Promise.all(keys.map(async (key) => { + const slug = key.name.slice('meta:'.length); let meta = {}; - try { meta = JSON.parse(metaRaw || '{}'); } catch {} + try { meta = JSON.parse(await env.META.get(key.name) || '{}'); } catch {} const versions = Array.isArray(meta.versions) ? meta.versions : []; - const latest = versions[versions.length - 1]?.n || 1; - const created = meta.created || versions[0]?.created || ''; - const updated = versions[versions.length - 1]?.created || created; - return { slug, title: meta.title || slug, latest, created, updated, meta }; + return { + slug, + title: meta.title || slug, + latest: versions[versions.length - 1]?.n || 1, + created: meta.created || versions[0]?.created || '', + updated: versions[versions.length - 1]?.created || meta.created || versions[0]?.created || '', + owner: hostedGithubLogin(meta) || '', + meta, + }; })); - const docs = catalog.filter((row) => { - if (hosted) return isDocOwnerSession(env, session, row.meta); - // BYOK operator catalog: keep other people's hosted copies off the list (#146). - const hostedLogin = hostedGithubLogin(row.meta); - if (hostedLogin && hostedLogin !== sessionLogin(session)) return false; - return true; - }); - // Newest activity first — the catalog default. The sort select re-orders - // client-side off each row's data-updated/data-created attributes. - docs.sort((a, b) => String(b.updated).localeCompare(String(a.updated))); - const visible = docs; - - // Viewer-scoped state: stars and recents may point at docs the viewer does - // not own (a colleague's shared doc). Rows render only for docs that still - // exist on this worker AND are still readable by this viewer. - const viewerLogin = sessionLogin(session); - const [starItems, recentItems, folderState] = viewerLogin - ? await Promise.all([loadStars(env, viewerLogin), loadRecents(env, viewerLogin), loadFolderState(env, viewerLogin)]) - : [[], [], { folders: [], docs: {} }]; - const bySlug = new Map(catalog.map((r) => [r.slug, r])); - const starredSet = new Set(starItems.map((i) => i.slug)); - const readableRow = (slug) => { - const row = bySlug.get(slug); - return row && docReadableBy(env, session, row.meta) ? row : null; - }; - const recentRows = recentItems - .map((i) => { const r = readableRow(i.slug); return r && { ...r, at: i.at }; }) - .filter(Boolean); - const starRows = starItems - .map((i) => { const r = readableRow(i.slug); return r && { ...r, at: i.at }; }) - .filter(Boolean); - - const day = (iso) => (typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : ''); - const starBtn = (slug) => ``; - - // Location model (Drive-style, one level for now — a folder `parent` - // field is reserved for nesting): folders render as rows above the doc - // list, filed docs leave the root view, navigation is ?folder=. - const folderById = new Map(folderState.folders.map((f) => [f.id, f])); - const folderCounts = {}; - for (const row of visible) { - const fid = folderState.docs[row.slug]; - if (fid) folderCounts[fid] = (folderCounts[fid] || 0) + 1; - } - const locHint = (slug) => { - const f = folderById.get(folderState.docs[slug]); - return f ? `` : ''; - }; - - const rows = visible.map(({ slug, title, latest, created, updated }) => `
- -
- ${escapeHtml(title)} -
${escapeHtml(slug)} · v${latest}${day(updated) ? ` · updated ${day(updated)}` : ''}${locHint(slug)}
-
-
- ${starBtn(slug)} - - -
-
`); - - // Recent / Starred panes: read-only rows (no select, no manage menu — the - // viewer may not own these docs), a byline for someone else's doc, and the - // same star toggle. - const flatRow = (row, label) => { - const owner = hostedGithubLogin(row.meta); - const by = owner && owner !== viewerLogin ? `by ${owner} · ` : ''; - return `
-
- ${escapeHtml(row.title)} -
${escapeHtml(by)}${label} ${day(row.at)}
-
-
${starBtn(row.slug)}
-
`; - }; - const recentList = recentRows.map((r) => flatRow(r, 'visited')).join(''); - const starList = starRows.map((r) => flatRow(r, 'starred')).join(''); - - const folderRows = folderState.folders - .slice() - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) - .map((f) => { - const n = folderCounts[f.id] || 0; - const sub = folderState.folders.filter((x) => (x.parent || '') === f.id).length; - return `
- -
- ${escapeHtml(f.name)} -
${sub ? `${sub} ${sub === 1 ? 'folder' : 'folders'} · ` : ''}${n} ${n === 1 ? 'doc' : 'docs'}
-
-
- - -
-
`; - }).join(''); - const foldersJson = JSON.stringify(folderState.folders.map((f) => ({ id: f.id, name: f.name, parent: f.parent || '' }))).replace(/My docs - - -
-

My docs

-
- - - -
-
-${rows.length === 0 ? '

No published docs yet. Hit Create a doc to see how, or browse templates for a look to start from.

' : - `
- - - -
- -
- - - - - -
-
${folderRows}
-
${rows.join('')}
- `} -
- - -
- - - - // Create-a-doc tutorial. /me cannot create anything — the doc is written by - // the user's own agent — so this explains where creation actually happens. - (function () { - var bg = document.getElementById('mk-bg'); - var openBtn = document.getElementById('mk-open'); - if (!bg || !openBtn) return; - function show(on) { bg.hidden = !on; if (on) document.getElementById('mk-x').focus(); } - openBtn.onclick = function () { show(true); }; - document.getElementById('mk-x').onclick = function () { show(false); }; - bg.addEventListener('click', function (e) { if (e.target === bg) show(false); }); - document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !bg.hidden) show(false); }); - })(); - - // Tabs + stars — wired independently of the catalog block below, which - // bails out early when the viewer has no docs of their own (Recent and - // Starred can still have rows in that case). - (function () { - var tabs = Array.prototype.slice.call(document.querySelectorAll('.tab')); - tabs.forEach(function (tab) { - tab.addEventListener('click', function () { - tabs.forEach(function (t) { - var active = t === tab; - t.classList.toggle('is-active', active); - t.setAttribute('aria-selected', String(active)); - var pane = document.getElementById(t.dataset.pane); - if (pane) pane.hidden = !active; - }); - }); - }); - - // Star toggle: optimistic flip (every button for the same slug, across - // panes), revert on failure. Session cookie authorizes /api/star. - // One BroadcastChannel instance for this page: posts our own star changes - // (so open doc tabs repaint their bar star) and hears other tabs' changes - // (a channel never delivers a message back to the instance that sent it). - var channel = null; - try { channel = new BroadcastChannel('tdoc-doc-state'); } catch (e) {} - function paintStar(slug, on) { - document.querySelectorAll('.star-btn').forEach(function (b) { - if (b.dataset.slug !== slug) return; - b.classList.toggle('is-starred', on); - b.textContent = on ? '★' : '☆'; - b.setAttribute('aria-pressed', String(on)); - }); - } - function starredEmpty(pane) { - if (!pane.querySelector('.doc-row')) { - pane.innerHTML = '

Star docs to find them again quickly.

'; - } - } - // A fresh star must show up when the user flips to the Starred tab NOW — - // the server render is behind us. Clone the essentials from any rendered - // row for that slug (My docs or Recent) into a flat row, DOM-built. - function addToStarredPane(slug) { - var pane = document.getElementById('pane-starred'); - if (!pane) return; - var rows = Array.prototype.slice.call(document.querySelectorAll('.doc-row')); - if (rows.some(function (r) { return r.dataset.slug === slug && pane.contains(r); })) return; - var src = null; - rows.forEach(function (r) { if (!src && r.dataset.slug === slug && r.querySelector('.doc-title')) src = r; }); - if (!src) return; - var list = pane.querySelector('.doc-list'); - if (!list) { - pane.innerHTML = '
'; - list = pane.querySelector('.doc-list'); - } - var row = document.createElement('div'); - row.className = 'doc-row flat-row'; - row.dataset.slug = slug; - row.dataset.title = src.dataset.title || slug; - var info = document.createElement('div'); - info.className = 'doc-info'; - var a = document.createElement('a'); - a.className = 'doc-title'; - a.href = src.querySelector('.doc-title').href; - a.textContent = src.dataset.title || slug; - var meta = document.createElement('div'); - meta.className = 'doc-meta'; - meta.textContent = 'starred ' + new Date().toISOString().slice(0, 10); - info.appendChild(a); - info.appendChild(meta); - var actions = document.createElement('div'); - actions.className = 'row-actions'; - var b = document.createElement('button'); - b.className = 'star-btn is-starred'; - b.dataset.slug = slug; - b.setAttribute('aria-pressed', 'true'); - b.textContent = '★'; - actions.appendChild(b); - row.appendChild(info); - row.appendChild(actions); - list.insertBefore(row, list.firstChild); - } - document.addEventListener('click', async function (e) { - var btn = e.target && e.target.closest ? e.target.closest('.star-btn') : null; - if (!btn) return; - e.stopPropagation(); - var slug = btn.dataset.slug; - var starred = !btn.classList.contains('is-starred'); - paintStar(slug, starred); - try { - var res = await fetch('/api/star', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slug: slug, starred: starred }), - }); - if (!res.ok) throw new Error('HTTP ' + res.status); - var pane = document.getElementById('pane-starred'); - if (starred) { - addToStarredPane(slug); - } else if (pane) { - // Unstarring removes the row from the Starred pane right away. - pane.querySelectorAll('.doc-row').forEach(function (row) { - if (row.dataset.slug === slug) row.remove(); - }); - starredEmpty(pane); - } - if (channel) { try { channel.postMessage({ type: 'star', slug: slug, starred: starred }); } catch (e2) {} } - } catch (err) { - paintStar(slug, !starred); - } - }); - - // Server-rendered pages go stale two ways: a bfcache Back into an old - // copy, and star changes made from a doc page in another tab. Reload on - // both signals (deferred to the next focus while hidden) — this page has - // no client data layer to patch instead (deliberate; see issue #287). - var stale = false; - window.addEventListener('pageshow', function (e) { if (e.persisted) location.reload(); }); - if (channel) channel.addEventListener('message', function (ev) { - var d = ev.data || {}; - if (d.type !== 'star') return; - if (document.hidden) stale = true; - else location.reload(); - }); - document.addEventListener('visibilitychange', function () { - if (!document.hidden && stale) location.reload(); - }); - })(); - -(() => { - // Tiny top-right toast — no third-party runtime on the privileged /me page. - function toast(message, kind = '') { - if (!message) return; - document.querySelectorAll('.tdoc-toast').forEach((n) => n.remove()); - const t = document.createElement('div'); - t.className = 'tdoc-toast'; - t.textContent = message; - t.setAttribute('role', 'status'); - t.style.cssText = 'position:fixed;top:62px;right:18px;z-index:1000001;background:' + - (kind === 'error' ? '#b42318' : '#1652f0') + - ';color:#fff;padding:12px 16px;border-radius:8px;font:14px system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.18)'; - document.body.appendChild(t); - setTimeout(() => t.remove(), 4000); - } - // Styled confirm — replaces window.confirm(). Resolves true/false; never - // silently proceeds (Cancel and the backdrop both resolve false). - function showConfirm({ title, body, confirmLabel, danger }) { - return new Promise((resolve) => { - const bg = document.createElement('div'); - bg.className = 'tdoc-modal-bg'; - bg.innerHTML = '
' + - '

' + - '
' + - '' + - '' + - '
'; - bg.querySelector('h3').textContent = title; - bg.querySelector('p').innerHTML = body; - const goBtn = bg.querySelector('[data-act="go"]'); - goBtn.textContent = confirmLabel; - goBtn.className = danger ? 'danger' : 'primary'; - const done = (v) => { bg.remove(); resolve(v); }; - bg.querySelector('[data-act="cancel"]').onclick = () => done(false); - bg.addEventListener('click', (e) => { if (e.target === bg) done(false); }); - goBtn.onclick = () => done(true); - document.body.appendChild(bg); - }); - } - // Styled text prompt (folder names) — same modal chrome as showConfirm. - // Resolves the trimmed value, or null on cancel/backdrop. - function showPrompt({ title, confirmLabel, value = '', placeholder = '' }) { - return new Promise((resolve) => { - const bg = document.createElement('div'); - bg.className = 'tdoc-modal-bg'; - bg.innerHTML = '
' + - '

' + - '
' + - '' + - '' + - '
'; - bg.querySelector('h3').textContent = title; - const input = bg.querySelector('input'); - input.value = value; - input.placeholder = placeholder; - const goBtn = bg.querySelector('[data-act="go"]'); - goBtn.textContent = confirmLabel; - goBtn.className = 'primary'; - const done = (v) => { bg.remove(); resolve(v); }; - const go = () => { const v = input.value.trim(); if (v) done(v); }; - bg.querySelector('[data-act="cancel"]').onclick = () => done(null); - bg.querySelector('[data-act="go"]').onclick = go; - bg.addEventListener('click', (e) => { if (e.target === bg) done(null); }); - input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); }); - document.body.appendChild(bg); - input.focus(); - }); - } - // Folder picker for Move — a button per destination, DOM-built (no - // innerHTML with user-named folders). Resolves {folder} or null. - function pickFolder(title) { - return new Promise((resolve) => { - const bg = document.createElement('div'); - bg.className = 'tdoc-modal-bg'; - const box = document.createElement('div'); - box.className = 'tdoc-modal'; - const h = document.createElement('h3'); - h.textContent = title; - box.appendChild(h); - const done = (v) => { bg.remove(); resolve(v); }; - const listBox = document.createElement('div'); - listBox.className = 'move-list'; - const add = (id, name) => { - const b = document.createElement('button'); - b.type = 'button'; - b.textContent = name; - b.onclick = () => done({ folder: id }); - listBox.appendChild(b); - return b; - }; - add('', 'My docs (no folder)'); - const addTree = (parentId, depth) => { - FOLDERS - .filter((f) => (f.parent || '') === parentId) - .slice() - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) - .forEach((f) => { - const b = add(f.id, f.name); - if (b) b.style.paddingLeft = (12 + depth * 18) + 'px'; - addTree(f.id, depth + 1); - }); - }; - addTree('', 0); - box.appendChild(listBox); - const actions = document.createElement('div'); - actions.className = 'actions'; - const cancel = document.createElement('button'); - cancel.type = 'button'; - cancel.textContent = 'Cancel'; - cancel.onclick = () => done(null); - actions.appendChild(cancel); - box.appendChild(actions); - bg.appendChild(box); - bg.addEventListener('click', (e) => { if (e.target === bg) done(null); }); - document.body.appendChild(bg); - }); - } - async function moveDocs(slugs, folder) { - const res = await fetch('/api/folders/move', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slugs, folder: folder || null }), - }); - if (!res.ok) throw new Error('HTTP ' + res.status); - } - // ⋯ overflow menu — one open at a time; a click anywhere else closes it. - function closeMenus(except) { - document.querySelectorAll('.row-menu').forEach((m) => { - if (m === except) return; - m.hidden = true; - m.previousElementSibling.setAttribute('aria-expanded', 'false'); - }); - } - document.querySelectorAll('.row-menu-btn').forEach((btn) => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const menu = btn.nextElementSibling; - const willOpen = menu.hidden; - closeMenus(willOpen ? menu : null); - menu.hidden = !willOpen; - btn.setAttribute('aria-expanded', String(willOpen)); - }); - }); - document.addEventListener('click', () => closeMenus(null)); - document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenus(null); }); - - // Delete: no token — the browser is already signed in as the owner (this - // page 302s away for anyone else), so the session cookie alone authorizes - // DELETE /api/doc (authorizeOwnerMutation in worker.js). Plain same-origin - // fetch sends the cookie automatically; no Authorization header needed. - // Confirm copy stays quiet ("This can't be undone.") — no version/comment - // inventory, no infra jargon, no pre-flight comment fetch. - async function deleteDoc(slug) { - const res = await fetch('/api/doc?slug=' + encodeURIComponent(slug), { - method: 'DELETE', - credentials: 'same-origin', - }); - if (!res.ok) { - let body = {}; - try { body = await res.json(); } catch {} - throw new Error(body.error || ('HTTP ' + res.status)); - } - } - document.querySelectorAll('.row-delete').forEach((button) => { - button.addEventListener('click', async () => { - closeMenus(null); - const slug = button.dataset.slug; - const title = button.dataset.title || slug; - const proceed = await showConfirm({ - title: 'Delete "' + title + '"?', - body: "This can't be undone.", - confirmLabel: 'Delete', - danger: true, - }); - if (!proceed) return; - try { - await deleteDoc(slug); - } catch { - toast("Couldn't delete", 'error'); - return; - } - button.closest('.doc-row').remove(); - applySearch(); - toast('Deleted'); - }); - }); - - // Search + batch select — client-side only over the already-rendered rows. - // No access data, no extra KV/R2; keep the catalog fast (#115). - const listEl = document.querySelector('#pane-mine .doc-list'); - if (!listEl) return; - const search = document.getElementById('doc-search'); - const selectAll = document.getElementById('select-all'); - const selectAllLabel = document.getElementById('select-all-label'); - const batchDelete = document.getElementById('batch-delete'); - const batchMove = document.getElementById('batch-move'); - const noMatch = document.getElementById('no-match'); - const FOLDERS = ${foldersJson}; - let activeFolder = ''; - - function visibleRows() { - return Array.from(listEl.querySelectorAll('.doc-row')).filter((row) => !row.hidden); - } - function selectedRows() { - return Array.from(listEl.querySelectorAll('.doc-row')).filter((row) => { - const box = row.querySelector('.doc-check'); - return box && box.checked; - }); - } - function syncBatchUi() { - const visible = visibleRows(); - const selected = selectedRows(); - const n = selected.length; - listEl.classList.toggle('is-selecting', n > 0); - selected.forEach((row) => row.classList.add('is-selected')); - listEl.querySelectorAll('.doc-row').forEach((row) => { - const box = row.querySelector('.doc-check'); - if (!(box && box.checked)) row.classList.remove('is-selected'); - }); - batchDelete.classList.toggle('is-visible', n > 0); - batchDelete.textContent = n <= 1 ? 'Delete' : ('Delete ' + n); - batchMove.classList.toggle('is-visible', n > 0); - batchMove.textContent = n <= 1 ? 'Move' : ('Move ' + n); - const allVisibleChecked = visible.length > 0 && visible.every((row) => { - const box = row.querySelector('.doc-check'); - return box && box.checked; - }); - const someVisibleChecked = visible.some((row) => { - const box = row.querySelector('.doc-check'); - return box && box.checked; - }); - selectAll.checked = allVisibleChecked; - selectAll.indeterminate = someVisibleChecked && !allVisibleChecked; - selectAllLabel.textContent = allVisibleChecked ? 'Deselect all' : 'Select all'; - selectAll.disabled = visible.length === 0; - if (!listEl.querySelector('.doc-row')) { - search.closest('.toolbar').hidden = true; - selectAll.closest('.batch-bar').hidden = true; - listEl.insertAdjacentHTML('afterend', '

No published docs yet.

'); - listEl.remove(); - if (noMatch) noMatch.hidden = true; - } - } - function applySearch() { - const q = (search.value || '').trim().toLowerCase(); - const searching = !!q; - let shown = 0; - listEl.querySelectorAll('.doc-row').forEach((row) => { - const hay = ((row.dataset.title || '') + ' ' + (row.dataset.slug || '')).toLowerCase(); - // Browsing shows the current location only; searching goes global, - // with the "in " hint on so filed docs are never lost. - const inLoc = searching || (row.dataset.folder || '') === activeFolder; - const match = (!q || hay.includes(q)) && inLoc; - row.hidden = !match; - const hint = row.querySelector('.loc-hint'); - if (hint) hint.hidden = !searching; - if (match) shown += 1; - else { - const box = row.querySelector('.doc-check'); - if (box) box.checked = false; - } - }); - let foldersShown = 0; - if (folderRowsEl) { - folderRowsEl.querySelectorAll('.folder-row').forEach((fr) => { - const vis = !searching && (fr.dataset.parent || '') === activeFolder; - fr.hidden = !vis; - if (vis) foldersShown += 1; - }); - } - buildCrumbs(); - if (noMatch) { - noMatch.textContent = !searching && activeFolder ? 'This folder is empty.' : 'No matches.'; - noMatch.hidden = shown > 0 || (!searching && !activeFolder && foldersShown > 0); - } - listEl.hidden = shown === 0; - // Folder counts track the live rows, so drags, menu moves and deletes - // never leave a stale number behind. - if (folderRowsEl) { - folderRowsEl.querySelectorAll('.folder-row').forEach((fr) => { - let n = 0; - listEl.querySelectorAll('.doc-row').forEach((row) => { - if ((row.dataset.folder || '') === fr.dataset.folderId) n += 1; - }); - const sub = FOLDERS.filter((x) => (x.parent || '') === fr.dataset.folderId).length; - const meta = fr.querySelector('.doc-meta'); - if (meta) meta.textContent = (sub ? sub + (sub === 1 ? ' folder' : ' folders') + ' · ' : '') + n + (n === 1 ? ' doc' : ' docs'); - }); - } - syncBatchUi(); - } - search.addEventListener('input', applySearch); - listEl.addEventListener('change', (e) => { - if (e.target && e.target.classList && e.target.classList.contains('doc-check')) syncBatchUi(); - }); - selectAll.addEventListener('change', () => { - const on = selectAll.checked; - visibleRows().forEach((row) => { - const box = row.querySelector('.doc-check'); - if (box) box.checked = on; - }); - syncBatchUi(); - }); - batchDelete.addEventListener('click', async () => { - const rows = selectedRows(); - if (!rows.length) return; - const proceed = await showConfirm({ - title: rows.length === 1 - ? ('Delete "' + (rows[0].dataset.title || rows[0].dataset.slug) + '"?') - : ('Delete ' + rows.length + ' docs?'), - body: "This can't be undone.", - confirmLabel: rows.length === 1 ? 'Delete' : ('Delete ' + rows.length), - danger: true, - }); - if (!proceed) return; - batchDelete.disabled = true; - let ok = 0, failed = 0; - for (const row of rows) { - try { - await deleteDoc(row.dataset.slug); - row.remove(); - ok += 1; - } catch { - failed += 1; - } - } - batchDelete.disabled = false; - applySearch(); - if (failed && ok) toast("Deleted " + ok + " · couldn't delete " + failed, 'error'); - else if (failed) toast("Couldn't delete", 'error'); - else toast('Deleted'); - }); - - // Sort — re-orders the rendered rows off their data attributes; the server - // default is last-updated-first, matching the select's initial value. - const sortSel = document.getElementById('doc-sort'); - sortSel.addEventListener('change', () => { - const key = sortSel.value; - const all = Array.from(listEl.querySelectorAll('.doc-row')); - all.sort((a, b) => { - if (key === 'title') { - return (a.dataset.title || '').localeCompare(b.dataset.title || '', undefined, { sensitivity: 'base' }); - } - return (b.dataset[key] || '').localeCompare(a.dataset[key] || ''); - }); - all.forEach((row) => listEl.appendChild(row)); - }); + const hosted = hostedRegistrationEnabled(env, origin); + const viewer = sessionLogin(session); + const mine = catalog.filter((row) => { + if (hosted) return isDocOwnerSession(env, session, row.meta); + return !row.owner || row.owner === viewer; + }).sort((a, b) => String(b.updated).localeCompare(String(a.updated))); - // Folders are places (Drive-style, one level): folder rows sit above the - // doc list at the root, clicking one navigates into it (?folder= via - // pushState), the breadcrumb walks back. Create/rename/delete reload the - // page (rows and FOLDERS are server-rendered); move updates rows in place. - const folderRowsEl = document.getElementById('folder-rows'); - const crumbsEl = document.getElementById('crumbs'); - // Breadcrumb = the full ancestor path (My docs / A / B); every segment is - // clickable and doubles as a drag-drop target for moving docs up the tree. - function buildCrumbs() { - if (!crumbsEl) return; - crumbsEl.hidden = !activeFolder; - crumbsEl.textContent = ''; - if (!activeFolder) return; - const chain = []; - const seen = new Set(); - let cur = FOLDERS.find((f) => f.id === activeFolder); - while (cur && !seen.has(cur.id)) { - seen.add(cur.id); - chain.unshift(cur); - cur = FOLDERS.find((f) => f.id === cur.parent); - } - const addSeg = (label, id) => { - const b = document.createElement('button'); - b.type = 'button'; - b.className = 'crumb-root'; - b.textContent = label; - b.addEventListener('click', () => setFolder(id, true)); - wireDropTarget(b, () => id); - crumbsEl.appendChild(b); - const sep = document.createElement('span'); - sep.className = 'sep'; - sep.setAttribute('aria-hidden', 'true'); - sep.textContent = '/'; - crumbsEl.appendChild(sep); - }; - addSeg('My docs', ''); - chain.slice(0, -1).forEach((f) => addSeg(f.name, f.id)); - const curSpan = document.createElement('span'); - curSpan.className = 'cur'; - curSpan.textContent = chain[chain.length - 1].name; - crumbsEl.appendChild(curSpan); - } - function setFolder(id, push) { - activeFolder = FOLDERS.some((f) => f.id === id) ? id : ''; - if (push) { - const url = activeFolder ? '?folder=' + encodeURIComponent(activeFolder) : location.pathname; - history.pushState({ folder: activeFolder }, '', url); - } - applySearch(); - } - window.addEventListener('popstate', () => { - setFolder(new URLSearchParams(location.search).get('folder') || '', false); + const [starItems, recentItems, folderState] = viewer + ? await Promise.all([loadStars(env, viewer), loadRecents(env, viewer), loadFolderState(env, viewer)]) + : [[], [], { folders: [], docs: {} }]; + const starred = new Set(starItems.map((item) => item.slug)); + const bySlug = new Map(catalog.map((row) => [row.slug, row])); + const savedRows = (items) => items.map((item) => { + const row = bySlug.get(item.slug); + return row && docReadableBy(env, session, row.meta) ? { ...row, at: item.at } : null; + }).filter(Boolean); + const publicRow = (row) => ({ + slug: row.slug, + title: row.title, + latest: row.latest, + created: row.created, + updated: row.updated, + owner: row.owner, + starred: starred.has(row.slug), }); - if (folderRowsEl) { - folderRowsEl.addEventListener('click', (e) => { - if (e.target && e.target.closest && e.target.closest('.row-actions')) return; - const fr = e.target && e.target.closest ? e.target.closest('.folder-row') : null; - if (fr) setFolder(fr.dataset.folderId, true); - }); - folderRowsEl.addEventListener('keydown', (e) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - const fr = e.target && e.target.closest ? e.target.closest('.folder-row') : null; - if (fr) { e.preventDefault(); setFolder(fr.dataset.folderId, true); } - }); - } - document.getElementById('new-folder').addEventListener('click', async () => { - const here = FOLDERS.find((f) => f.id === activeFolder); - const name = await showPrompt({ - title: here ? 'New folder in "' + here.name + '"' : 'New folder', - confirmLabel: 'Create', - placeholder: 'Folder name', - }); - if (!name) return; - const res = await fetch('/api/folders', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(activeFolder ? { name, parent: activeFolder } : { name }), - }); - if (!res.ok) { - let body = {}; - try { body = await res.json(); } catch {} - toast(body.error === 'too_deep' ? 'Folders can only nest 4 levels deep' : "Couldn't create folder", 'error'); - return; - } - // Reload keeps the current location (?folder= is already in the URL). - location.reload(); - }); - document.querySelectorAll('.folder-rename').forEach((button) => { - button.addEventListener('click', async (e) => { - e.stopPropagation(); - closeMenus(null); - const cur = FOLDERS.find((f) => f.id === button.dataset.id); - const name = await showPrompt({ title: 'Rename folder', confirmLabel: 'Rename', value: cur ? cur.name : '' }); - if (!name) return; - const res = await fetch('/api/folders', { - method: 'PATCH', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: button.dataset.id, name }), - }); - if (!res.ok) { toast("Couldn't rename folder", 'error'); return; } - location.reload(); - }); - }); - document.querySelectorAll('.folder-delete').forEach((button) => { - button.addEventListener('click', async (e) => { - e.stopPropagation(); - closeMenus(null); - const proceed = await showConfirm({ - title: 'Delete folder "' + (button.dataset.name || '') + '"?', - body: 'Docs inside move back to My docs. No documents are deleted.', - confirmLabel: 'Delete folder', - danger: true, - }); - if (!proceed) return; - const res = await fetch('/api/folders?id=' + encodeURIComponent(button.dataset.id), { - method: 'DELETE', - credentials: 'same-origin', - }); - if (!res.ok) { toast("Couldn't delete folder", 'error'); return; } - location.reload(); - }); - }); - document.querySelectorAll('.row-move').forEach((button) => { - button.addEventListener('click', async () => { - closeMenus(null); - const pick = await pickFolder('Move "' + (button.dataset.title || button.dataset.slug) + '" to…'); - if (!pick) return; - try { - await moveDocs([button.dataset.slug], pick.folder); - } catch { - toast("Couldn't move", 'error'); - return; - } - const row = button.closest('.doc-row'); - row.dataset.folder = pick.folder || ''; - setRowHint(row, pick.folder || ''); - applySearch(); - toast('Moved'); - }); - }); - batchMove.addEventListener('click', async () => { - const rows = selectedRows(); - if (!rows.length) return; - const pick = await pickFolder(rows.length === 1 - ? ('Move "' + (rows[0].dataset.title || rows[0].dataset.slug) + '" to…') - : ('Move ' + rows.length + ' docs to…')); - if (!pick) return; - batchMove.disabled = true; - try { - await moveDocs(rows.map((row) => row.dataset.slug), pick.folder); - } catch { - batchMove.disabled = false; - toast("Couldn't move", 'error'); - return; - } - batchMove.disabled = false; - rows.forEach((row) => { - row.dataset.folder = pick.folder || ''; - setRowHint(row, pick.folder || ''); - const box = row.querySelector('.doc-check'); - if (box) box.checked = false; - }); - applySearch(); - toast('Moved'); - }); - // Drag to file (Drive-style): drag a doc row onto a folder row, or onto - // "My docs" in the crumbs to move it back to the root. Dragging a selected - // row drags the whole selection. HTML5 DnD — touch devices keep the - // Move menu, which stays available everywhere. - let dragSlugs = null; - listEl.addEventListener('dragstart', (e) => { - const row = e.target && e.target.closest ? e.target.closest('.doc-row') : null; - if (!row) return; - const selected = selectedRows(); - const dragRows = selected.includes(row) ? selected : [row]; - dragSlugs = dragRows.map((r) => r.dataset.slug); - dragRows.forEach((r) => r.classList.add('dragging')); - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - try { e.dataTransfer.setData('text/plain', dragSlugs.join(',')); } catch {} - } - }); - listEl.addEventListener('dragend', () => { - dragSlugs = null; - listEl.querySelectorAll('.dragging').forEach((r) => r.classList.remove('dragging')); - document.querySelectorAll('.drop-hover').forEach((el) => el.classList.remove('drop-hover')); - }); - async function dropInto(folderId) { - const slugs = dragSlugs; - dragSlugs = null; - if (!slugs || !slugs.length) return; - try { - await moveDocs(slugs, folderId); - } catch { - toast("Couldn't move", 'error'); - return; - } - listEl.querySelectorAll('.doc-row').forEach((row) => { - if (!slugs.includes(row.dataset.slug)) return; - row.dataset.folder = folderId || ''; - setRowHint(row, folderId || ''); - const box = row.querySelector('.doc-check'); - if (box) box.checked = false; - }); - applySearch(); - toast('Moved'); - } - function wireDropTarget(el, getFolderId) { - el.addEventListener('dragover', (e) => { - if (!dragSlugs) return; - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; - el.classList.add('drop-hover'); - }); - el.addEventListener('dragleave', () => el.classList.remove('drop-hover')); - el.addEventListener('drop', (e) => { - e.preventDefault(); - el.classList.remove('drop-hover'); - dropInto(getFolderId()); - }); - } - if (folderRowsEl) folderRowsEl.querySelectorAll('.folder-row').forEach((fr) => wireDropTarget(fr, () => fr.dataset.folderId)); - - // Keep the search-time location hint honest after an in-place move. - function setRowHint(row, folderId) { - let hint = row.querySelector('.loc-hint'); - const f = FOLDERS.find((x) => x.id === folderId); - if (!f) { if (hint) hint.remove(); return; } - if (!hint) { - hint = document.createElement('span'); - hint.className = 'loc-hint'; - hint.hidden = true; - const meta = row.querySelector('.doc-meta'); - if (meta) meta.appendChild(hint); - } - hint.textContent = ' · in ' + f.name; - } - // Boot into the location the URL names (?folder=; unknown ids fall - // back to the root) — this first applySearch also hides filed docs. - setFolder(new URLSearchParams(location.search).get('folder') || '', false); - syncBatchUi(); -})(); - -`; + return { + docs: mine.map((row) => ({ ...publicRow(row), folder: folderState.docs[row.slug] || '' })), + recent: savedRows(recentItems).map(publicRow), + starred: savedRows(starItems).map(publicRow), + folders: folderState.folders.map((folder) => ({ + id: folder.id, + name: folder.name, + parent: folder.parent || '', + })), + }; } -// ───────────────────────────────────────────────────────────────────────── -// EVENT-LOG COMMENT MODEL (v0.2) -// -// Each comment is stored as { id, author, created_in, created, events: [...] }. -// Events: created, text_edited, anchor_changed, marked_applied, deleted, -// reaction_added, reaction_removed, reply_added, reply_text_edited, -// reply_deleted, reply_reaction_added, reply_reaction_removed. -// Every event carries `at_version` and `at` (ISO timestamp). -// -// THE FUNDAMENTAL RULE: every version is a snapshot. Reading a comment "as -// of version N" folds events with at_version <= N. Mutations NEVER overwrite -// past state — they append a new event. Going back to an older version -// shows the comment exactly as it existed then; going forward shows the -// latest state. -// -// Agent emoji (✅🟡❓) is rendered at fold time from marked_applied events, -// not stored as a reaction record. That way the agent verdict is per-version -// just like any other status. - -const AGENT_STATUS_EMOJI = { applied: '✅', partial: '🟡', question: '❓' }; - function isFiniteVersion(v) { return Number.isFinite(v) && v >= 0; } @@ -4348,6 +3225,24 @@ export default { if (p === '/api/ping') return json({ ok: true, service: 'tdoc' }); if (p === '/api/runtime') return json({ ok: true, runtime: runtimeInfo() }); + if (p === SHELL_RUNTIME_JS_PATH && (method === 'GET' || method === 'HEAD')) { + return new Response(method === 'HEAD' ? null : SHELL_RUNTIME_JS, { + headers: { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + }, + }); + } + if (p === SHELL_RUNTIME_CSS_PATH && (method === 'GET' || method === 'HEAD')) { + return new Response(method === 'HEAD' ? null : SHELL_RUNTIME_CSS, { + headers: { + 'Content-Type': 'text/css; charset=utf-8', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + }, + }); + } if (p === '/favicon.svg' && method === 'GET') { return new Response(TDOC_FAVICON_SVG, { headers: { @@ -4384,7 +3279,7 @@ export default { // the marketing page for that one request. if (p === '/' && (method === 'GET' || method === 'HEAD')) { const notice = (url.searchParams.get('notice') || '').trim(); - if (notice) return html(landingHtml(env, notice)); + if (notice) return neutralLandingResponse(env, notice); return landingResponse(env, req); } @@ -4409,16 +3304,16 @@ export default { // soft landing GitHub may bounce to after Approve; keep the friendly page. if (p === '/auth/github/callback' && method === 'GET') { const code = url.searchParams.get('code'); - if (!code) return html(authDoneHtml()); + if (!code) return authStatusResponse('You can close this tab and return to tdoc.'); const state = url.searchParams.get('state'); // Anchor to a cookie-pair boundary so a cookie merely ending in // "tdoc_oauth" (e.g. "xtdoc_oauth=") can't supply the nonce. const cookieNonce = (/(?:^|;\s*)tdoc_oauth=([a-f0-9]+)/.exec(req.headers.get('cookie') || '') || [])[1]; if (!state || !cookieNonce || state !== cookieNonce) { - return html(authErrorHtml('Sign-in could not be verified (state mismatch). Please try again.'), { status: 400 }); + return authStatusResponse('Sign-in could not be verified (state mismatch). Please try again.', { error: true, status: 400 }); } if (!env.GITHUB_CLIENT_SECRET) { - return html(authErrorHtml('Web sign-in is not configured on this host.'), { status: 500 }); + return authStatusResponse('Web sign-in is not configured on this host.', { error: true, status: 500 }); } const ret = sanitizeReturn(await env.META.get(`oauthstate:${state}`)); await env.META.delete(`oauthstate:${state}`); @@ -4430,10 +3325,10 @@ export default { redirect_uri: `${url.origin}/auth/github/callback`, }); if (r.error || !r.access_token) { - return html(authErrorHtml('GitHub sign-in failed: ' + (r.error_description || r.error || 'no token returned')), { status: 400 }); + return authStatusResponse('GitHub sign-in failed: ' + (r.error_description || r.error || 'no token returned'), { error: true, status: 400 }); } const user = await ghUser(r.access_token); - if (!user.login) return html(authErrorHtml('GitHub returned no account.'), { status: 500 }); + if (!user.login) return authStatusResponse('GitHub returned no account.', { error: true, status: 500 }); const sid = rand(24); const session = { login: user.login, @@ -4447,12 +3342,12 @@ export default { 'tdoc_oauth=; Path=/; Max-Age=0', ]); } catch (e) { - return html(authErrorHtml('Sign-in error: ' + e.message), { status: 500 }); + return authStatusResponse('Sign-in error: ' + e.message, { error: true, status: 500 }); } } // Static soft landing (device flow, or the OAuth App's callback URL). if (p === '/auth/done' && method === 'GET') { - return html(authDoneHtml()); + return authStatusResponse('You can close this tab and return to tdoc.'); } // ---- owner catalog ---- @@ -4471,14 +3366,20 @@ export default { }); } const nonce = rand(16); - const page = await indexHtml(env, s, url.origin, nonce); const identity = { login: s.login, avatar_url: s.avatar_url, name: s.name }; - // /me is a PLAIN site page (tdoc-generated content, no author HTML): the - // shared bar + identity wiring go in via injectSiteChrome, no iframe. - return html(injectSiteChrome(page, { - slug: '', version: 0, identity, isOwner: false, canSeeMyDocs: true, - isCatalog: true, authConfigured: true, webAuth: !!env.GITHUB_CLIENT_SECRET, mode: 'published', versions: [], - }, nonce), { + const data = await indexData(env, s, url.origin); + return html(SHELL.appHtml({ + title: 'My docs', + nonceAttr: ` nonce="${nonce}"`, + runtimeJsPath: SHELL_RUNTIME_JS_PATH, + runtimeCssPath: SHELL_RUNTIME_CSS_PATH, + bootJson: safeJsonForScript({ + page: 'docs-hub', + identity, + runtime: runtimeInfo(), + ...data, + }), + }), { headers: { 'Content-Security-Policy': cspHeader(nonce) }, }); }