diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b924d1dd..3cdfa59b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,35 @@ All notable changes to Patternflow will be documented in this file, newest first ### Web +- **The community's workshop is gone.** The map of directions, its threads and the territory editor are removed with their API routes; the menu and the phone tabs keep Patterns, Decks and Atlas, and the old workshop addresses redirect to `/community`. Comments, alerts and reports keep their pattern and deck paths, and the workshop's tables and rows stay in the database. +- **The community atlas is a read-only archive.** It is off the community menu and reached from the footer's Archive link, with a line at the top saying so. The pin picker and the pin editor are gone, and `/api/community/atlas` answers a write with a 405; the map, its pins and the prompts stay readable. +- **A community member's display name is their own username.** Sign-up and `/api/auth/update-user` took any string as `displayUsername`, so a member could show as `@ada` on their cards and in the licence header of the next pattern they published. Only the capitals of the member's own username are accepted now, and a new username takes the display name with it. +- **A pattern title stays on its licence header's comment line.** A title with a line break closed the `// Title:` comment, and the rest of it ran as code in the pattern and, through the `// Based on:` line, in every fork. The community now refuses a title holding a line break or another control character, and the header folds titles stored before that onto one line. +- **The community's faint grey text reads on its dark grounds.** `--pfc-ink-faint` goes from #6B6558 (3.18 to 3.44:1) to #847E70 (4.56 to 4.93:1), so dates, counts and footer links pass WCAG AA. axe's color-contrast failures on eleven community pages went from 326 to 0. +- **The community wall lays out its columns in CSS, so a phone's first paint already has three.** The server's markup carried six columns and a phone redrew them as three after hydration. At 390px CLS went from 1.19 on /community and 1.49 on /community/patterns to 0.14, all of it the firmware notice. +- **A long deck name no longer widens the community's deck pages on a phone.** The phone deck grid's bare `1fr` column could not shrink below a deck name drawn on one line, so a 62-character name made /community/decks and the profile 579px wide at 390px and pushed the tab bar's You off screen. The name now ends in an ellipsis. +- **Community windows stay open when a drag that began inside them ends on the backdrop.** Selecting text in Edit details, the report window, sign-in or the deck panel and letting go outside closed the window and dropped what was typed. The twelve community windows now close the way Pattern Lab's do, on a press that begins and ends on the backdrop. +- **Buttons that belong on the right stay there on a phone in the community.** The phone rule that hid the page header's spacer hid the same span in eight other rows, so at 390px the comment Post button sat at the left edge, the deck panel's order buttons moved with each title's length, and Make & publish on your own profile wrapped to the left. +- **A deleted or private community link says so inside the community.** Pattern, deck and profile pages that 404 fell through to the site's root page, a bare "404" linking to the landing page. They now keep the community header and tabs, say the page was deleted or made private, link to the patterns wall, and title the tab "Not found" when the row is gone. +- **The community's Alerts badge clears once you have read your alerts, and counts only the ones you can open.** The header counted once per full page load, so "Alerts 3" stayed on the alerts page and on every page after it until a reload. It also counted alerts on patterns that had since gone private and on the retired workshop, which the alerts page never lists, so a member could see "Alerts 2" over an empty page. +- **Community pages no longer prefetch Pattern Lab.** Every community page fetched 218.7 KB of script (gzip) after the load event: Pattern Lab's chunks through the header's Pattern Lab link, and the editions chunk through the firmware notice. Links that leave the community now load their page when they are followed. +- **The community's build routes answer bad input with 400 and 404.** `POST /api/community/builds` threw on a `null` in `patterns` and came back a 500 without CORS headers, and `GET …/modules?file=__proto__` (or `constructor`, `toString`, `hasOwnProperty`) answered 200 with an empty body. The three build routes have their first tests. +- **The community header draws a signed-in reader's handle from the first paint.** It drew "Sign in" until the browser had fetched its own session, 0.2–0.9 s on every page. On a phone that put the header on two lines and then pulled the page up 40px (CLS 0.049). It now draws the user the layout already read on the server. +- **The community's firmware notice is in the server's HTML for anyone who has not dismissed it.** It arrived at hydration and pushed every community page down, 119px on a phone (CLS 0.141 at 390px) and 55px at 1440px. Dismissing it now writes a cookie as well as localStorage, and the layout reads the cookie. +- **Community text fields no longer zoom an iPhone's page.** On a phone every field is 16px. The wall's search, comments, sign in, reports, the edit and share dialogs, C++ headers and a deck's visibility picker were 12 to 14px, and iOS Safari zooms into a field under 16px when it takes focus. +- **The community header and the wall's controls take a thumb on a phone.** At phone widths every link and button in the header, the firmware notice's ✕ and link, the wall's sort tabs and Flashable now are 44px tall, where Alerts was 18.8px and the ✕ 18.9×21. The wordmark's two links get a 24px line each and no longer overlap under WCAG 2.5.8, and the header stays 65px tall. +- **Community forms name the status when the server answers with an error page.** A comment, Edit details, a report, a firmware header, a performance or a shared deck that met a proxy's 502 page or a bare 413 said "Network error."; it now says "Comment failed (HTTP 502)." and still shows the route's own message when it sent one. +- **The empty Liked tab says nothing is liked yet.** It borrowed the wall's empty panel, "Nobody has hung anything here yet." and a link to Pattern Lab, over a wall full of patterns. It now says a heart keeps a pattern there and links back to the wall, drops only the filter when none of the likes is flashable, and asks a signed-out visitor to sign in. +- **A like the server turns down says why.** The pattern page's like button and the wall card's heart rolled back without a word when the server refused, so a 429 read as a button that ignored the press; both now show the route's message ("Too many likes — slow down a moment."), or the status when a proxy page answered. +- **Posting a comment from Preview brings the field back.** The comment went up and the field stayed an empty preview box with the Preview tab still picked and greyed out; it now clears to Write, ready for the next comment. +- **Comment footnotes stay on the page.** A footnote link in a comment opened the page again in a new tab, and with two footnoted comments on one page it landed on the first one's note; footnotes and links to other pages of the site now open in the same tab, each comment's footnotes have their own ids, and `safeHref` reads a backslash as a slash and has its first tests. +- **The deck's windows and Send to my Patternflow close on Esc.** Edit deck details, Deck performance, Share deck, Share this deck, the deck panel and Send to my Patternflow now take the focus when they open and give it back to the button that opened them when Esc closes them. +- **The pattern page's knobs turn one detent per arrow key.** The community pattern page's knob sliders moved 0.001 a press, 1,000 presses across a 0..1 knob and 9,900 across Speed. They now turn one detent, 48 across any range, as the Pattern Lab's Knobs panel does. +- **The board address field takes a click on its own words and fits a thumb on a phone.** On the Basics card, the deck panel and the Send window, a click on "Device address:" now lands in the field, and below 768px the field takes its own line at 44px with 16px text, so iOS no longer zooms the page into it. +- **Every community button is 44px tall on a phone.** Below 768px and on touch screens, the deck page, a pattern's owner bar, Ports, Publish a performance and the Basics card drew buttons 24.7 to 36.8px tall, and a deck of five put fifteen 24.7px buttons in the deck panel. They are now at least 44px, and the panel's ↑ and ↓ are 44px squares. +- **Community downloads say how long a compile has taken.** Download .zip on a pattern or deck page counts the seconds against what the compile was promised: 8 s for one pattern, and 1.5 s more for each pattern a deck still has to compile. Past that it says the build server may be busy or stopped. It used to say "usually a few seconds" for all three minutes. The deck panel and the Send window promise the deck bar's number instead of "a second or two". +- **The decks page no longer loads every preset to draw the Basics card.** The card imported the preset list for its four thumbnails, which put the source of all 48 presets (175 KB of script, 38 KB gzipped) in the first load of /community/decks. The page now picks the four on the server and passes them in. +- **The deck panel opens from the dock, and its buttons say which pattern they move.** Above 900px the header chip that opens the panel is hidden, so from the keyboard a deck could only be emptied whole. The dock's count is now a button that opens the panel. Its up, down and remove buttons are named for their pattern, and after a press the focus stays in the panel instead of falling to the page. - **The global stylesheet drops 87 rules for classes no page renders.** The old version tag, tooltip, spec and stack tables, roadmap track, placeholders, marquee comments, build steps and tweaks panel had no component left, and `slideUpFadeIn`, `pf-slide` and four variables went with them. The CSS chunk every page loads is 8,942 bytes smaller (1,693 gzipped), and computed styles on seven pages at 390 and 1440 px are unchanged. - **Pattern Lab's Preview, Pixel, Color Ramp and Knobs rules live with their panels.** The 20 rules only one panel file read moved from the dock's `LabPanels.module.css` to that panel's stylesheet, Pixel's in a new `PixelPanel.module.css`, and a test fails when the dock file holds a class only one panel file reads. Computed styles in 13 Lab states at 390px and 1440px match `dev`. - **A moving text layer whose words are deleted goes blank, and a long scroll opens again still moving.** Emptying the text of a scrolling or ramp-running layer left the old letters moving in the preview and both exports. A line too long for a 64 × 128 source after the panel was turned, or set right of its X past 4,096 columns, saved but reopened standing still; every step that makes or reads a source now places it the same way. The Text panel no longer says a font file is kept for next time when this browser would not keep it. diff --git a/PATTERN_GUIDE.md b/PATTERN_GUIDE.md index 3ba06a3da..d9f50fb37 100644 --- a/PATTERN_GUIDE.md +++ b/PATTERN_GUIDE.md @@ -122,10 +122,6 @@ chapters use all of this. ![A pattern's detail page](docs/images/pattern-guide/10-pattern-detail.png) -One more thing in the top nav — **Workshop** is where directions get discussed -and decided. It's under heavy construction, so this guide leaves it alone for -now. - --- ## 3. Putting someone else's pattern on your device diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 21ae5fe7c..270f4d6f0 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -14,7 +14,7 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow | `/guide` · `/guide/build` · `/guide/play` · `/guide/make` · `/guide/audio` (each + `/ko`) | The interactive guide: a hub and four guides on one continuous 3D stage (`components/guide/`), with the firmware's input logic and screens ported to `lib/guide/` and the real console and audio editor in windows (`public/guide/console-demo/`, `audio-editor/`, generated by `firmware/toolchain/console_demo.py` and `editor_demo.py`). Words in `components/guide/copy.ts` and `copy/`; old `/guide#flash`-style anchors land on `/guide/play` (`legacy.ts`) | | `/inside/[build]` | One community build's page | | `/pattern-lab` | The pattern workspace — see below | -| `/community/**` | The community: feed, pattern pages (`/p/[id]`), profiles (`/u/[username]`), decks (`/d/[id]`, `/decks`), the Workshop (`/workshop/[code]`), territories and the atlas, notifications, featured, reports. Renders a pointer to the community host unless `COMMUNITY_ENABLED=1` | +| `/community/**` | The community: feed, pattern pages (`/p/[id]`), profiles (`/u/[username]`), decks (`/d/[id]`, `/decks`), the atlas, notifications, featured, reports. The workshop came down on 26 October 2026, and its old addresses redirect to `/community` (`next.config.ts`). The atlas became a read-only archive the same day. The footer's Archive link is the way in, and `/api/community/atlas` answers a write with a 405. Renders a pointer to the community host unless `COMMUNITY_ENABLED=1` | | `/editions` | The edition shelf: every firmware you can put on a panel, official and community, with one-click install. `/variants` (the URL until 2026-09, baked into shipped console pages) redirects here permanently | | `/features` | The catalogue: every feature on its own — what it needs, which firmware carries it, and a reel of it running (Instagram embeds by permalink, lazy). The other axis of the shelf; hand-curated in `features-data.ts`. A feature that is not on the shelf can carry a frozen try-out image (`tryOut`), installed the way the shelf installs | | `/update` | The device's firmware-update handoff: the browser downloads an image and POSTs it to the panel over the LAN, because the panel cannot fetch over TLS | @@ -23,7 +23,7 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow | `/roadmap` | Roadmap rendered from `roadmap-data.ts` plus GitHub issue progress via `/api/roadmap` | | `/compliance` | Printed on the box — never move, redirect or noindex it | | `/terms` · `/business` · `/contact` | Static pages | -| `/api/community/**` | The community's JSON API (30-odd routes: patterns, decks, posts, comments, builds, workshop, moderation…). Every route guards on `communityEnabled()`, and `src/proxy.ts` closes the whole prefix with a 404 on a deployment without the community, so a route that forgets is still closed | +| `/api/community/**` | The community's JSON API (20-odd routes: patterns, decks, comments, builds, the atlas, moderation…). Every route guards on `communityEnabled()`, and `src/proxy.ts` closes the whole prefix with a 404 on a deployment without the community, so a route that forgets is still closed | | `/api/auth/[...all]` | Better Auth | | `/api/variant-bin/[...path]` | Serves hosted edition images from `VARIANT_BIN_DIR` with the CORS headers `/update` needs | | `/api/roadmap` | Open issues + sub-issue progress (10 min revalidate; optional `GITHUB_TOKEN`) | @@ -81,7 +81,7 @@ Exports flatten the visible stack to one standalone pattern (`flatten.ts`); `hEx ## Community (`src/lib/community/` + `src/components/community/`) -SQLite via Drizzle (`server/schema.ts`; migrations in `web/drizzle/`, applied on first open by `server/db.ts`), Better Auth (`server/auth.ts`), reads in `server/queries/` — one file per domain (patterns, decks, workshop, moderation, users, notifications, plus the shared sub-selects), re-exported by `server/queries.ts` — attachments and thumbnails on disk under `communityDataDir()` (default `web/data/`, gitignored — set `COMMUNITY_DB_PATH` on a real deployment). **`lib/community/server/` is the server side** — everything that touches SQLite, the filesystem or a session — and ESLint refuses a value import of it from `components/` or a `*Client.tsx` (type imports are fine; a value crosses through an API route). The rest of `lib/community/` is either `"use client"` (`auth-client`, `deviceHost`, `download`, `handoff`, `knobs`, `thumbs`) or pure and usable anywhere (`validate`, `license`, `deck`, `workshop`, `cors`, …). +SQLite via Drizzle (`server/schema.ts`; migrations in `web/drizzle/`, applied on first open by `server/db.ts`), Better Auth (`server/auth.ts`), reads in `server/queries/` — one file per domain (patterns, decks, atlas, moderation, users, notifications, plus the shared sub-selects), re-exported by `server/queries.ts` — attachments and thumbnails on disk under `communityDataDir()` (default `web/data/`, gitignored — set `COMMUNITY_DB_PATH` on a real deployment). **`lib/community/server/` is the server side** — everything that touches SQLite, the filesystem or a session — and ESLint refuses a value import of it from `components/` or a `*Client.tsx` (type imports are fine; a value crosses through an API route). The rest of `lib/community/` is either `"use client"` (`auth-client`, `deviceHost`, `download`, `handoff`, `knobs`, `thumbs`) or pure and usable anywhere (`validate`, `license`, `deck`, `cors`, …). Patterns render in a sandboxed iframe (`public/pattern-sandbox.html`, driven by `components/community/SandboxPreview.tsx`). Pattern Lab hands off to the community through `handoff.ts` (fork vs. edit-in-place), and publishes through `components/community/PublishModal.tsx`. diff --git a/web/next.config.ts b/web/next.config.ts index 0fd9de6f4..109aacd0b 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -90,6 +90,16 @@ const nextConfig: NextConfig = { // to the guide's Flash chapter, where the button and the steps are. Only // the exact path: /flash/manifest.json and /flash/bin/* are untouched. { source: "/flash", destination: "/guide/play#flash", permanent: false }, + // The community's workshop came down on 26 October 2026: the map of + // directions, its threads, and the moderators' territory editor. Its + // rows stay in the database. A link to any of it, from an old alert, a + // post elsewhere or a bookmark, lands on the community's front page. One + // line per page that was there. + { source: "/community/workshop", destination: "/community", permanent: false }, + { source: "/community/workshop/:code", destination: "/community", permanent: false }, + { source: "/community/workshop/:code/t/:id", destination: "/community", permanent: false }, + { source: "/community/t/:id", destination: "/community", permanent: false }, + { source: "/community/territories", destination: "/community", permanent: false }, // From the journal's first post on 29 April 2026 until 11 May, its own // links asked for English with ?lang=en. English moved to /en paths that // day (c44b0d7a), and these two send the old links there. The query goes diff --git a/web/package.json b/web/package.json index 69a27ea65..a5fdb36b9 100644 --- a/web/package.json +++ b/web/package.json @@ -31,7 +31,6 @@ "check:notify": "tsx scripts/notify-smoke.ts", "check:ports": "tsx scripts/ports-smoke.ts", "seed:map": "tsx scripts/seed-territories.ts", - "check:workshop": "tsx scripts/workshop-smoke.ts", "check:sandbox": "tsx scripts/sandbox-smoke.ts", "check:capture": "tsx scripts/lab-capture-smoke.ts", "check:thumbs": "tsx scripts/thumbs-smoke.ts", @@ -43,7 +42,7 @@ "check:pfstvectors": "tsx scripts/pfst-v2-vectors-smoke.ts", "check:ramp": "tsx scripts/ramp-oklab-smoke.ts", "check:panels": "vitest run", - "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache && npm run check:drift", + "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache && npm run check:drift", "build:sandbox": "tsx scripts/build-sandbox.ts", "check:sandbox-sync": "tsx scripts/build-sandbox.ts --check", "gen:shared": "tsx scripts/gen-shared.ts", diff --git a/web/scripts/moderation-smoke.ts b/web/scripts/moderation-smoke.ts index dda6baf1e..3e489ef54 100644 --- a/web/scripts/moderation-smoke.ts +++ b/web/scripts/moderation-smoke.ts @@ -192,7 +192,9 @@ check("a non-string is rejected", cleanModerationReason(42), undefined); console.log("\n── report input ──"); check("pattern is a valid target", isReportTargetType("pattern"), true); -check("post is a valid target", isReportTargetType("post"), true); +// A workshop thread was a target until the workshop came down on 26 October 2026. +check("post is no longer a target", isReportTargetType("post"), false); +check("deck is a valid target", isReportTargetType("deck"), true); check("comment is a valid target", isReportTargetType("comment"), true); check("user is not a target", isReportTargetType("user"), false); check("non-string is not a target", isReportTargetType(42), false); diff --git a/web/scripts/notify-smoke.ts b/web/scripts/notify-smoke.ts index 304b9fdf0..2680d64bd 100644 --- a/web/scripts/notify-smoke.ts +++ b/web/scripts/notify-smoke.ts @@ -75,7 +75,8 @@ async function main() { updatedAt: ago(10), }, ]); - // Threads live on the map now, so the fixture needs somewhere to put one. + // The workshop came down on 26 October 2026 and its tables stayed, so the + // fixture keeps a thread for the alert below that still points at one. await db.insert(schema.territories).values({ id: "z1", code: "A1", @@ -102,7 +103,6 @@ async function main() { createdAt: NOW, }); await notify.notifyCommentAdded({ - on: "pattern", targetId: "p1", targetTitle: "Alice One", ownerId: "alice", @@ -132,28 +132,26 @@ async function main() { check("thread rows say so", bobRows.map((row) => row.type), ["thread", "thread"]); check("and carry the snippet", bobRows[0]?.snippet, "thanks both"); - console.log("\n── post comments use the same path ──"); - await db.insert(schema.postComments).values({ - id: "pc1", - postId: "t1", - userId: "bob", - body: "post reply", - createdAt: NOW, - }); - await notify.notifyCommentAdded({ - on: "post", + console.log("\n── a workshop alert stays off the list ──"); + await db.insert(schema.notifications).values({ + id: "n-post", + userId: "cara", + type: "thread", + actorId: "bob", + targetType: "post", targetId: "t1", targetTitle: "Alice Thread", - ownerId: "alice", - actorId: "bob", - commentId: "pc1", - body: "post reply", + createdAt: NOW, }); - check("the post's author hears it", await queries.countUnreadNotifications("alice"), 3); check( - "the row points at the post", - (await queries.listNotifications("alice"))[0]?.targetType, - "post", + "a row about a workshop thread is not listed", + (await queries.listNotifications("cara")).map((row) => row.targetType), + ["pattern"], + ); + check( + "not for a moderator either", + (await queries.listNotifications("cara", { moderator: true })).map((row) => row.targetType), + ["pattern"], ); console.log("\n── forks ──"); @@ -178,7 +176,7 @@ async function main() { forkVisibility: "public", actorId: "bob", }); - check("a public fork tells the parent's author", await queries.countUnreadNotifications("alice"), 4); + check("a public fork tells the parent's author", await queries.countUnreadNotifications("alice"), 3); await notify.notifyForkPublished({ parentOwnerId: "alice", parentTitle: "Alice One", @@ -186,7 +184,7 @@ async function main() { forkVisibility: "private", actorId: "bob", }); - check("a private fork tells nobody — its page would 404", await queries.countUnreadNotifications("alice"), 4); + check("a private fork tells nobody — its page would 404", await queries.countUnreadNotifications("alice"), 3); await notify.notifyForkPublished({ parentOwnerId: "alice", parentTitle: "Alice One", @@ -194,7 +192,7 @@ async function main() { forkVisibility: "public", actorId: "alice", }); - check("forking your own work tells nobody", await queries.countUnreadNotifications("alice"), 4); + check("forking your own work tells nobody", await queries.countUnreadNotifications("alice"), 3); console.log("\n── deck inclusion ──"); await db.insert(schema.decks).values({ @@ -215,7 +213,7 @@ async function main() { actorId: "bob", patterns: deckPatterns, }); - check("the pattern's author hears it", await queries.countUnreadNotifications("alice"), 5); + check("the pattern's author hears it", await queries.countUnreadNotifications("alice"), 4); check("the deck owner's own pattern tells nobody", await queries.countUnreadNotifications("bob"), 2); await notify.notifyDeckInclusion({ deckId: "d1", @@ -223,12 +221,12 @@ async function main() { actorId: "bob", patterns: deckPatterns, }); - check("repeating the event while unread is absorbed", await queries.countUnreadNotifications("alice"), 5); + check("repeating the event while unread is absorbed", await queries.countUnreadNotifications("alice"), 4); console.log("\n── reading ──"); await queries.markNotificationsRead("alice"); check("opening the page reads everything", await queries.countUnreadNotifications("alice"), 0); - check("the rows themselves stay", (await queries.listNotifications("alice")).length, 5); + check("the rows themselves stay", (await queries.listNotifications("alice")).length, 4); console.log("\n── the read path respects visibility ──"); await db.update(schema.patterns).set({ visibility: "private" }).where(eq(schema.patterns.id, "p1")); diff --git a/web/scripts/workshop-smoke.ts b/web/scripts/workshop-smoke.ts deleted file mode 100644 index 73b5a21df..000000000 --- a/web/scripts/workshop-smoke.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Workshop smoke test — `npm run check:workshop`. - * - * Against a throwaway SQLite file. What this guards: a pin is a subscription - * (new thread notifies exactly the pinned, never the author), re-pinning edits - * the note without restarting "since", the filename sanitiser stops traversal - * and header injection, the byte sniffer only ever says "image" about real - * raster images, and a deleted thread's attachment bytes actually leave the - * disk once the sweep runs. - */ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -// The database is a lazy singleton keyed off this variable, so it has to be set -// before anything imports it — hence the dynamic imports below. -const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-workshop-")); -process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); -process.env.COMMUNITY_ENABLED = "1"; - -const DAY = 24 * 60 * 60 * 1000; -const NOW = new Date("2026-08-01T12:00:00Z"); -const ago = (days: number) => new Date(NOW.getTime() - days * DAY); - -let failures = 0; - -function check(label: string, actual: unknown, expected: unknown) { - const ok = JSON.stringify(actual) === JSON.stringify(expected); - if (!ok) failures += 1; - console.log( - `${ok ? " ok " : "FAIL "}${label}${ok ? "" : `\n got ${JSON.stringify(actual)}\n want ${JSON.stringify(expected)}`}`, - ); -} - -async function main() { - const { eq } = await import("drizzle-orm"); - const { getDb } = await import("../src/lib/community/server/db"); - const schema = await import("../src/lib/community/server/schema"); - const queries = await import("../src/lib/community/server/queries"); - const notify = await import("../src/lib/community/server/notify"); - const workshop = await import("../src/lib/community/workshop"); - const { attachmentDir, attachmentPath, sniffImage } = await import( - "../src/lib/community/server/attachments" - ); - const { sweepOrphanAttachments } = await import("../src/lib/community/server/retention"); - - const db = getDb(); - - // ── Seed ─────────────────────────────────────────────────────────────────── - const person = (id: string) => ({ - id, - name: id, - email: `${id}@patternflow.local`, - emailVerified: false, - createdAt: ago(30), - updatedAt: ago(30), - username: id, - displayUsername: id, - }); - await db.insert(schema.user).values([person("alice"), person("bob"), person("cara")]); - await db.insert(schema.territories).values({ - id: "z1", - code: "A1", - title: "Wired control", - createdAt: ago(20), - }); - await db.insert(schema.territoryPins).values([ - { id: "pin-a", territoryId: "z1", userId: "alice", note: "OSC bridge", createdAt: ago(5) }, - { id: "pin-b", territoryId: "z1", userId: "bob", note: null, createdAt: ago(3) }, - ]); - - // ── Pin = subscription ──────────────────────────────────────────────────── - await db.insert(schema.posts).values({ - id: "t1", - territoryId: "z1", - userId: "alice", - title: "First bytes over the wire", - body: "hello", - createdAt: NOW, - updatedAt: NOW, - }); - await notify.notifyNewThread({ - territoryId: "z1", - territoryLabel: "A1 · Wired control", - postId: "t1", - postTitle: "First bytes over the wire", - actorId: "alice", - }); - - const rows = await db.select().from(schema.notifications); - check("new thread notifies exactly the other pinned person", rows.length, 1); - check("…and it is bob", rows[0]?.userId, "bob"); - check("…as a territory row", rows[0]?.type, "territory"); - check("…carrying where", rows[0]?.snippet, "A1 · Wired control"); - check("…pointing at the thread", [rows[0]?.targetType, rows[0]?.targetId], ["post", "t1"]); - - // Unpinned bystanders hear nothing; the author never hears about themselves. - check( - "cara (not pinned) got nothing", - rows.some((row) => row.userId === "cara"), - false, - ); - - // ── Correlated counts ───────────────────────────────────────────────────── - // These shipped as zero once: drizzle renders `${table.column}` unqualified, - // and SQLite bound the bare `id` to the INNER table's own id column. The - // node counts are the workshop's entire face, so they get asserted against - // known contents. - const zones = await queries.listTerritories(); - check("territory counts see the pins", zones[0]?.pinCount, 2); - check("…and the thread", zones[0]?.threadCount, 1); - const drawerThreads = await queries.listPosts({ territoryId: "z1", limit: 6 }); - check("reply count starts at zero, not undefined", drawerThreads[0]?.commentCount, 0); - await db.insert(schema.postComments).values({ - id: "c1", - postId: "t1", - userId: "bob", - body: "tried it — works at 115200", - createdAt: NOW, - }); - const withReply = await queries.listPosts({ territoryId: "z1", limit: 6 }); - check("…and counts the reply", withReply[0]?.commentCount, 1); - const recent = await queries.listRecentThreads(); - check("the latest strip carries where", recent[0]?.territoryCode, "A1"); - - // ── Re-pin edits the note, keeps "since" ────────────────────────────────── - await db - .insert(schema.territoryPins) - .values({ id: "pin-a2", territoryId: "z1", userId: "alice", note: "MIDI too", createdAt: NOW }) - .onConflictDoUpdate({ - target: [schema.territoryPins.territoryId, schema.territoryPins.userId], - set: { note: "MIDI too" }, - }); - const alicePin = ( - await db - .select() - .from(schema.territoryPins) - .where(eq(schema.territoryPins.userId, "alice")) - )[0]; - check("re-pin updates the note", alicePin?.note, "MIDI too"); - check("…without restarting since", alicePin?.createdAt.getTime(), ago(5).getTime()); - - // ── Filename sanitiser ──────────────────────────────────────────────────── - check("traversal keeps only the basename", workshop.cleanFilename("..\\..\\front.dxf"), "front.dxf"); - check("unix traversal too", workshop.cleanFilename("../../etc/passwd"), "passwd"); - check("leading dots come off", workshop.cleanFilename("...hidden.png"), "hidden.png"); - check( - "quotes and controls come out", - workshop.cleanFilename('to\u0000ler"an\u001fces.md'), - "tolerances.md", - ); - check("nothing left means null", workshop.cleanFilename('"\u0001.'), null); - check("allowlist takes dxf", workshop.attachmentAllowed("front.dxf"), true); - check("allowlist takes uppercase PNG", workshop.attachmentAllowed("shot.PNG"), true); - check("allowlist refuses exe", workshop.attachmentAllowed("run.exe"), false); - check("svg is a file, not an image", workshop.isImageFilename("logo.svg"), false); - check("jpeg is an image", workshop.isImageFilename("bench.JPG"), true); - - // ── Territory validators ────────────────────────────────────────────────── - // The admin editor is the only way to draw the map now, so its cleaners are - // the only thing between a typo and a broken front page. - check("code accepts A1", workshop.cleanTerritoryCode("a1"), "A1"); - check("code accepts two digits", workshop.cleanTerritoryCode("B12"), "B12"); - check("code refuses a word", workshop.cleanTerritoryCode("hardware"), null); - check("code refuses a bare letter", workshop.cleanTerritoryCode("A"), null); - check("title collapses whitespace", workshop.cleanTerritoryTitle(" Wired control "), "Wired control"); - check("empty title is refused", workshop.cleanTerritoryTitle(" "), undefined); - check("blank description is null", workshop.cleanTerritoryDescription(" "), null); - check("span 2 is fine", workshop.cleanSpan("2"), 2); - check("span 7 is not", workshop.cleanSpan(7), undefined); - check("span 1 is not", workshop.cleanSpan(1), undefined); - check("coords clamp rather than refuse", workshop.cleanStageCoord(99999, "x"), 1440); - check("…on both ends", workshop.cleanStageCoord(-40, "y"), 0); - check("coords refuse nonsense", workshop.cleanStageCoord("over there", "x"), undefined); - check( - "questions keep at most four lines", - workshop.cleanQuestions("a\nb\n\nc\nd\ne"), - "a\nb\nc\nd", - ); - check("no questions is null", workshop.cleanQuestions(""), null); - - // A question three characters over the cap used to 400 the whole save — the - // description edit in the same body went with it, and the reason landed in a - // banner at the top of a list you had scrolled past. The cap is still a cap; - // what changed is that the editor knows it, and can say which line. - const long = "x".repeat(workshop.QUESTION_MAX + 1); - check("a question at the cap is fine", workshop.cleanQuestions("x".repeat(workshop.QUESTION_MAX)) !== undefined, true); - check("one over is refused", workshop.cleanQuestions(long), undefined); - check("…and names the line", workshop.overlongQuestion(`ok\n${long}`), long); - check("nothing over means nothing to name", workshop.overlongQuestion("ok\nfine"), null); - check( - "a long fifth line is dropped, not fatal", - workshop.cleanQuestions(`a\nb\nc\nd\n${long}`), - "a\nb\nc\nd", - ); - check("the editor's cap is the route's cap", workshop.QUESTION_MAX, 60); - - // ── Presence ────────────────────────────────────────────────────────────── - // The map's people layer. Presence is not a pin — walking somewhere must - // never touch subscriptions — so it gets its own table and its own checks. - await db.insert(schema.presence).values({ - userId: "alice", - x: 900, - y: 200, - status: "soldering", - updatedAt: NOW, - }); - const standing = await queries.listPresence(); - check("presence carries the handle", standing[0]?.username, "alice"); - check("…and the spot", [standing[0]?.x, standing[0]?.y], [900, 200]); - check("…and the words", standing[0]?.status, "soldering"); - check("the unmoved are everyone else", await queries.countUnmoved(), 2); - - // Moving again is an update, not a second body. - await db - .insert(schema.presence) - .values({ userId: "alice", x: 300, y: 500, status: null, updatedAt: NOW }) - .onConflictDoUpdate({ - target: schema.presence.userId, - set: { x: 300, y: 500, updatedAt: NOW }, - }); - const walked = await queries.listPresence(); - check("walking moves the one square", walked.length, 1); - check("…to the new spot", [walked[0]?.x, walked[0]?.y], [300, 500]); - check("…without dropping the words", walked[0]?.status, "soldering"); - - check("status collapses whitespace", workshop.cleanStatus(" taking a walk "), "taking a walk"); - check("empty status clears to null", workshop.cleanStatus(" "), null); - check("a long status is refused", workshop.cleanStatus("x".repeat(61)), undefined); - check("at the cap it fits", workshop.cleanStatus("x".repeat(60)), "x".repeat(60)); - - // ── Markdown links ──────────────────────────────────────────────────────── - // Posts render as markdown now, so every href in one was typed by a member. - // react-markdown has its own urlTransform; this is ours, and it is the one - // PostBody actually calls. - const { safeHref } = await import("../src/lib/community/markdown"); - check("https passes through", safeHref("https://example.com/a"), "https://example.com/a"); - check("http too", safeHref("http://example.com"), "http://example.com"); - check("javascript: is refused", safeHref("javascript:alert(1)"), null); - check("…with padding", safeHref(" JaVaScRiPt:alert(1)"), null); - check("…and with a tab inside the scheme", safeHref("java\tscript:alert(1)"), null); - check("…and with a NUL inside it", safeHref("java\u0000script:alert(1)"), null); - check("data: is refused", safeHref("data:text/html;base64,PHNjcmlwdD4="), null); - check("vbscript: is refused", safeHref("vbscript:msgbox"), null); - check("file: is refused", safeHref("file:///etc/passwd"), null); - check("protocol-relative is refused, not upgraded", safeHref("//evil.example/x"), null); - check("a relative path is fine", safeHref("/community/patterns"), "/community/patterns"); - check("an anchor is fine", safeHref("#results"), "#results"); - check("a colon after a slash is a path, not a scheme", safeHref("notes/a:b"), "notes/a:b"); - check("empty is nothing", safeHref(""), null); - check("undefined is nothing", safeHref(undefined), null); - - // ── Storage quotas ──────────────────────────────────────────────────────── - // The per-parent cap bounds one thread and nothing else — an account just - // makes more threads. Without a per-user and a whole-community ceiling the - // only limit is the disk the server boots from, and when that fills SQLite - // stops writing: the community does not degrade, it stops. - check("a fresh account is storing nothing", await queries.attachmentBytesByUser("cara"), 0); - await db.insert(schema.posts).values({ - id: "quota-thread", - territoryId: "z1", - userId: "cara", - title: "Quota", - body: "x", - createdAt: NOW, - updatedAt: NOW, - }); - await db.insert(schema.postAttachments).values([ - { id: "cccccccccccccccc", postId: "quota-thread", commentId: null, userId: "cara", - filename: "a.png", bytes: 900_000, createdAt: NOW }, - { id: "dddddddddddddddd", postId: "quota-thread", commentId: null, userId: "cara", - filename: "b.png", bytes: 100_000, createdAt: NOW }, - ]); - check("a person's usage is the sum of their files", await queries.attachmentBytesByUser("cara"), 1_000_000); - check("…and is not charged to anybody else", await queries.attachmentBytesByUser("bob"), 0); - check("the community total counts everyone", await queries.attachmentBytesTotal(), 1_000_000); - - check("per-user ceiling is 100 MB", workshop.ATTACHMENT_MAX_PER_USER_BYTES, 100 * 1024 * 1024); - check("community ceiling is 2 GB", workshop.ATTACHMENT_MAX_TOTAL_BYTES, 2 * 1024 * 1024 * 1024); - // The body cap has to leave room for the largest legitimate upload, or a - // valid five-file post gets a 413 instead of an attachment. - check( - "the request cap admits a full five-file upload", - workshop.ATTACHMENT_MAX_REQUEST_BYTES > - workshop.ATTACHMENT_MAX_PER_PARENT * workshop.ATTACHMENT_MAX_BYTES, - true, - ); - - // ── Byte sniffing ───────────────────────────────────────────────────────── - const png = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.alloc(16), - ]); - check("png magic sniffs as png", sniffImage(png), "image/png"); - check("text never sniffs as an image", sniffImage(Buffer.from("")), null); - check("short buffers are nothing", sniffImage(Buffer.from([0x89, 0x50])), null); - - // ── Cascade + orphan sweep ──────────────────────────────────────────────── - const fileId = "aaaaaaaaaaaaaaaa"; - const orphanId = "bbbbbbbbbbbbbbbb"; - fs.mkdirSync(attachmentDir(), { recursive: true }); - fs.writeFileSync(attachmentPath(fileId), png); - fs.writeFileSync(attachmentPath(orphanId), Buffer.from("leftover")); - await db.insert(schema.postAttachments).values({ - id: fileId, - postId: "t1", - commentId: null, - userId: "alice", - filename: "shot.png", - bytes: png.length, - createdAt: NOW, - }); - - // Deleting the thread takes its attachment ROW with it (foreign keys)… - await db.delete(schema.posts).where(eq(schema.posts.id, "t1")); - // Scoped to the thread that was deleted: this is asking whether ITS rows went - // with it, not whether the table is empty — other threads' files are supposed - // to survive, and asserting on the whole table quietly made this test depend - // on nothing else in the fixture ever having an attachment. - const remainingRows = await db - .select() - .from(schema.postAttachments) - .where(eq(schema.postAttachments.postId, "t1")); - check("deleting the thread cascades the attachment row", remainingRows.length, 0); - check( - "…and leaves another thread's files alone", - (await db.select().from(schema.postAttachments).where(eq(schema.postAttachments.postId, "quota-thread"))).length, - 2, - ); - - // …and the sweep takes the BYTES, once they are past the grace window. - // The files' mtimes are pinned to the fixture clock — the real clock is - // days ahead of NOW, and the grace check compares against mtime. - fs.utimesSync(attachmentPath(fileId), NOW, NOW); - fs.utimesSync(attachmentPath(orphanId), NOW, NOW); - const early = await sweepOrphanAttachments(NOW); - check("inside the grace window nothing is touched", early.deleted, 0); - fs.utimesSync(attachmentPath(fileId), ago(2), ago(2)); - fs.utimesSync(attachmentPath(orphanId), ago(2), ago(2)); - const late = await sweepOrphanAttachments(NOW); - check("past it, both orphans go", late.deleted, 2); - check("…and the directory is actually empty", fs.readdirSync(attachmentDir()).length, 0); - check("…with no errors", late.errors, []); - - console.log(failures === 0 ? "\nAll workshop checks passed." : `\n${failures} FAILED`); - process.exit(failures === 0 ? 0 : 1); -} - -void main(); diff --git a/web/src/app/api/community/atlas/route.test.ts b/web/src/app/api/community/atlas/route.test.ts new file mode 100644 index 000000000..38ea6d678 --- /dev/null +++ b/web/src/app/api/community/atlas/route.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// The atlas has been an archive since 26 October 2026. Its pins stay where +// their authors left them, so the route still reads them and refuses the +// writes that used to place, move and remove one, even from a moderator who +// could do all three until then. Driven through the real route with a real +// session and the Lab's Origin, on a database of its own. + +const LAB_ORIGIN = "https://lab.patternflow.test"; +const ENV_KEYS = [ + "COMMUNITY_DB_PATH", + "COMMUNITY_ENABLED", + "BETTER_AUTH_SECRET", + "COMMUNITY_ADMIN_USERNAMES", + "COMMUNITY_ALLOWED_ORIGINS", +] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; +let cookie = ""; +let route: typeof import("./route"); +let pinRows: () => Promise<{ patternId: string; x: number; y: number; entryId: string | null }[]>; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-atlas-archive-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "atlas-archive-test-secret-not-a-real-one"; + process.env.COMMUNITY_ADMIN_USERNAMES = "ada"; + process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + route = await import("./route"); + + const response = await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "atlas-archive-test-password", name: "ada", username: "ada" }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("no session cookie for ada"); + cookie = setCookie.split(";")[0]; + + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const now = new Date(); + for (const id of ["p1", "p2"]) { + await db.insert(schema.patterns).values({ + id, userId: ada!.id, title: `ada's ${id}`, code: "// js", visibility: "public", createdAt: now, updatedAt: now, + }); + } + await db.insert(schema.atlasPins).values({ patternId: "p1", x: 40, y: 60, entryId: null, kind: "pin", updatedAt: now }); + + pinRows = () => + db + .select({ + patternId: schema.atlasPins.patternId, + x: schema.atlasPins.x, + y: schema.atlasPins.y, + entryId: schema.atlasPins.entryId, + }) + .from(schema.atlasPins); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +const call = (method: "GET" | "POST" | "DELETE", body?: Record) => + new Request("http://localhost:3000/api/community/atlas", { + method, + headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +it.each([ + ["moving a pin", "POST", { patternId: "p1", x: 10, y: 10, entryId: null, kind: "pin" }], + ["placing a new one", "POST", { patternId: "p2", x: 70, y: 20, entryId: null, kind: "pin" }], + ["removing one", "DELETE", { patternId: "p1" }], +] as const)("refuses %s with a 405 the Lab can read", async (_, method, body) => { + const response = await route[method](call(method, body)); + expect(response.status).toBe(405); + expect(response.headers.get("Allow")).toBe("GET, OPTIONS"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN); + expect(await response.json()).toEqual({ error: "The atlas is an archive now, and its pins stay where they are." }); + expect(await pinRows()).toEqual([{ patternId: "p1", x: 40, y: 60, entryId: null }]); +}); + +it("still reads the pins", async () => { + const response = await route.GET(call("GET")); + expect(response.status).toBe(200); + const { pins } = (await response.json()) as { pins: { patternId: string; x: number; y: number }[] }; + expect(pins.map(({ patternId, x, y }) => ({ patternId, x, y }))).toEqual([{ patternId: "p1", x: 40, y: 60 }]); +}); diff --git a/web/src/app/api/community/atlas/route.ts b/web/src/app/api/community/atlas/route.ts index f13c690a3..afaf20ae1 100644 --- a/web/src/app/api/community/atlas/route.ts +++ b/web/src/app/api/community/atlas/route.ts @@ -1,25 +1,14 @@ -import { eq } from "drizzle-orm"; -import { ENTRY_BY_ID } from "@/lib/atlas/data"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; +import { communityEnabled } from "@/lib/community/server/db"; import { listAtlasPins } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { atlasPins, patterns } from "@/lib/community/server/schema"; // GET /api/community/atlas — every pattern pinned on the atlas. -// POST — place or move a pin: { patternId, x, y, entryId?, kind? } in the -// atlas's 0..100 data space. Only the pattern's author (or a -// moderator) may. kind "pin" (default) is an exemplar tile on the -// shared map; kind "research" files the pattern against an entry as a -// field note — failures stay on record where they happened, without -// occupying the map. -// DELETE — take a pin off the map: { patternId }. Same permission. -// -// A pin says "this work lives at this spot of pattern space". One per -// pattern; placing again just moves it (and can change its kind). +// POST, DELETE — refused with a 405. The atlas is an archive since 26 October +// 2026: the pins stay where their authors left them. Until then POST +// placed or moved a pin ({ patternId, x, y, entryId?, kind? }) and +// DELETE took one off the map ({ patternId }). export async function GET(request: Request) { const blocked = originBlocked(request); @@ -30,13 +19,13 @@ export async function GET(request: Request) { export async function POST(request: Request) { const blocked = originBlocked(request); if (blocked) return blocked; - return withCors(request, await handleWrite(request, "place")); + return withCors(request, refuseWrite()); } export async function DELETE(request: Request) { const blocked = originBlocked(request); if (blocked) return blocked; - return withCors(request, await handleWrite(request, "remove")); + return withCors(request, refuseWrite()); } export const OPTIONS = preflight; @@ -64,103 +53,12 @@ async function handleGet(request: Request) { }); } -function cleanCoord(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) return undefined; - const rounded = Math.round(value); - if (rounded < 0 || rounded > 100) return undefined; - return rounded; -} - -async function handleWrite(request: Request, mode: "place" | "remove") { +function refuseWrite() { if (!communityEnabled()) { return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to place work on the atlas." }, { status: 401 }); - } - - if (!rateLimit(`atlas:${session.user.id}`, 60, 60_000)) { - return Response.json({ error: "Too many moves — let the pin settle a moment." }, { status: 429 }); - } - - const payload = await readJsonObject(request); - if (payload instanceof Response) return payload; - - const patternId = typeof payload.patternId === "string" ? payload.patternId : ""; - if (!patternId) { - return Response.json({ error: "Which pattern?" }, { status: 400 }); - } - - const [pattern] = await getDb() - .select({ id: patterns.id, userId: patterns.userId, visibility: patterns.visibility }) - .from(patterns) - .where(eq(patterns.id, patternId)) - .limit(1); - if (!pattern) { - return Response.json({ error: "No such pattern." }, { status: 404 }); - } - - // The author decides where their work sits; moderators can tidy the map. - if (pattern.userId !== session.user.id && !isAdminSession(session)) { - return Response.json({ error: "Only the pattern's author can place it." }, { status: 403 }); - } - - if (mode === "remove") { - await getDb().delete(atlasPins).where(eq(atlasPins.patternId, patternId)); - return Response.json({ ok: true }); - } - - const kind = payload.kind === undefined ? "pin" : payload.kind; - if (kind !== "pin" && kind !== "research") { - return Response.json({ error: "Unknown pin kind." }, { status: 400 }); - } - - // A map pin is a tile everyone sees, so it must be public. A research row is - // a field note — private failures are allowed, because the read path already - // shows those only to their author and moderators. - if (kind === "pin" && pattern.visibility !== "public") { - return Response.json( - { error: "Only public patterns can sit on the shared map." }, - { status: 400 }, - ); - } - - const x = cleanCoord(payload.x); - const y = cleanCoord(payload.y); - if (x === undefined || y === undefined) { - return Response.json({ error: "Bad position." }, { status: 400 }); - } - - // Lineage is a deliberate, human act: the author links a pattern to the - // prompt point it came from (or to none). Nothing is inferred from code. - let entryId: string | null; - if (payload.entryId === undefined || payload.entryId === null || payload.entryId === "") { - entryId = null; - } else if (typeof payload.entryId === "string" && ENTRY_BY_ID.has(payload.entryId)) { - entryId = payload.entryId; - } else { - return Response.json({ error: "No such point on the map." }, { status: 400 }); - } - - // A research row exists to remember where an attempt happened — unmoored - // from any entry it would be invisible everywhere, so refuse the no-op. - if (kind === "research" && entryId === null) { - return Response.json( - { error: "Research notes attach to a point — drop it closer to one." }, - { status: 400 }, - ); - } - - const now = new Date(); - await getDb() - .insert(atlasPins) - .values({ patternId, x, y, entryId, kind, updatedAt: now }) - .onConflictDoUpdate({ - target: atlasPins.patternId, - set: { x, y, entryId, kind, updatedAt: now }, - }); - - return Response.json({ ok: true }); + return Response.json( + { error: "The atlas is an archive now, and its pins stay where they are." }, + { status: 405, headers: { Allow: "GET, OPTIONS" } }, + ); } diff --git a/web/src/app/api/community/attachments/[id]/route.ts b/web/src/app/api/community/attachments/[id]/route.ts deleted file mode 100644 index 10060f177..000000000 --- a/web/src/app/api/community/attachments/[id]/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -import fs from "node:fs/promises"; -import { eq } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { attachmentPath, sniffImage } from "@/lib/community/server/attachments"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { getAttachment } from "@/lib/community/server/queries"; -import { postAttachments } from "@/lib/community/server/schema"; - -// GET /api/community/attachments/[id] — hand back a file from a thread. -// DELETE the same — take one back off. -// -// Two paths out, decided by the BYTES rather than the uploaded name: -// -// - Verified raster images (png/jpeg/gif/webp by magic number) are served -// inline with their real content type, so a build photo shows up IN the -// thread instead of landing in Downloads. `CSP: sandbox` rides along as -// the belt to that brace: even if something were mis-sniffed, the response -// renders with no scripts, no origin, no cookies. -// -// - Everything else is application/octet-stream + attachment, whatever it -// claims to be. An .svg or .html hung on a thread is somebody else's -// markup on our origin — served inline it would run with our cookies, -// which is stored XSS. SVG stays on this path forever: it is a document, -// and there is no magic number that makes a document safe. -// -// No auth check: threads are public, so their files are too. The id is the -// capability, and it is not guessable. - -export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const { id } = await context.params; - const row = await getAttachment(id); - if (!row) { - return Response.json({ error: "File not found." }, { status: 404 }); - } - - let bytes: Buffer; - try { - bytes = await fs.readFile(attachmentPath(row.id)); - } catch { - // The row outlived its file — a restored database against a fresh disk, - // say. A 404 is the honest answer. - return Response.json({ error: "File not found." }, { status: 404 }); - } - - // The stored name is already stripped of quotes and control characters - // (cleanFilename), so it is safe to put in the header; filename* carries the - // non-ASCII form for browsers that want it. - const encoded = encodeURIComponent(row.filename); - const image = sniffImage(bytes); - - return new Response(new Uint8Array(bytes), { - headers: { - "Content-Type": image ?? "application/octet-stream", - "Content-Length": String(bytes.length), - "Content-Disposition": `${image ? "inline" : "attachment"}; filename="${row.filename}"; filename*=UTF-8''${encoded}`, - // A thread's file never changes — the row is immutable once written. - "Cache-Control": "public, max-age=31536000, immutable", - "X-Content-Type-Options": "nosniff", - // For the inline path: no scripts, no origin, no cookies, even if the - // sniff were somehow wrong. Harmless on downloads. - "Content-Security-Policy": "sandbox", - }, - }); -} - -export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleDelete(request, context)); -} - -export const OPTIONS = preflight; - -/** - * Unattach a file. - * - * Attaching used to be one-way: pick the wrong file and it was on the thread - * for good, under your name, with a permanent URL. That is a bad thing to do to - * somebody over a misclick, and worse when the wrong file is one they did not - * mean to publish at all. - * - * Only the person who uploaded it, or a moderator. The uploader is always the - * author of the thread or reply it hangs on (the POST route enforces that), so - * this is the same permission as editing the thing it is attached to. - * - * Row first, then bytes. A row pointing at a missing file already 404s - * gracefully above; bytes with no row are invisible and permanent, so if the - * unlink fails the sweep is what cleans up — never the other way round. - */ -async function handleDelete(request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in first." }, { status: 401 }); - } - - const { id } = await context.params; - const row = await getAttachment(id); - if (!row) { - return Response.json({ error: "File not found." }, { status: 404 }); - } - if (row.userId !== session.user.id && !isAdminSession(session)) { - return Response.json({ error: "That is not your file." }, { status: 403 }); - } - - await getDb().delete(postAttachments).where(eq(postAttachments.id, row.id)); - await fs.rm(attachmentPath(row.id), { force: true }).catch(() => undefined); - - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/attachments/route.ts b/web/src/app/api/community/attachments/route.ts deleted file mode 100644 index effb4bda5..000000000 --- a/web/src/app/api/community/attachments/route.ts +++ /dev/null @@ -1,215 +0,0 @@ -import fs from "node:fs/promises"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { attachmentDir, attachmentPath } from "@/lib/community/server/attachments"; -import { - ATTACHMENT_MAX_BYTES, - ATTACHMENT_MAX_PER_PARENT, - ATTACHMENT_MAX_PER_USER_BYTES, - ATTACHMENT_MAX_REQUEST_BYTES, - ATTACHMENT_MAX_TOTAL_BYTES, - attachmentAllowed, - cleanFilename, - formatBytes, -} from "@/lib/community/workshop"; -import { - attachmentBytesByUser, - attachmentBytesTotal, - countAttachments, - getPostCommentStub, - getPostStub, - newId, -} from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { postAttachments } from "@/lib/community/server/schema"; - -// POST /api/community/attachments — hang files on a thread or one of its -// replies. multipart/form-data: postId, optional commentId, and `files`. -// -// Called AFTER the thread or reply exists, not with it. That ordering means an -// attachment always has a parent — there is no upload session to expire, no -// orphaned bytes to sweep — and a failed upload leaves the writing intact for -// the author to retry rather than losing the post with it. -// -// This is not a file host and it runs on a Raspberry Pi: five files per parent, -// two megabytes each, and an extension allowlist. See lib/community/workshop.ts. - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to attach a file." }, { status: 401 }); - } - - if (!rateLimit(`attach:${session.user.id}`, 20, 60_000)) { - return Response.json({ error: "Too many uploads — wait a minute." }, { status: 429 }); - } - - // Before parsing anything. formData() pulls the entire multipart body into - // memory, and every size check below runs on what it produces — so without - // this the per-file cap is enforced only after a gigabyte has already been - // read. A reverse proxy usually also caps this, but the app cannot see - // whether one is in front of it today, let alone after the next move. - const declared = Number(request.headers.get("content-length") ?? "0"); - if (Number.isFinite(declared) && declared > ATTACHMENT_MAX_REQUEST_BYTES) { - return Response.json( - { error: `That request is ${formatBytes(declared)}; the limit is ${formatBytes(ATTACHMENT_MAX_REQUEST_BYTES)}.` }, - { status: 413 }, - ); - } - - let form: FormData; - try { - form = await request.formData(); - } catch { - return Response.json({ error: "Expected a multipart form." }, { status: 400 }); - } - - const postId = String(form.get("postId") ?? ""); - const rawCommentId = form.get("commentId"); - const commentId = typeof rawCommentId === "string" && rawCommentId.length > 0 ? rawCommentId : null; - - // Only the person who wrote the thing may hang files on it — the file reads - // as theirs, so it has to be. - if (commentId) { - const comment = await getPostCommentStub(commentId); - if (!comment || comment.postId !== postId) { - return Response.json({ error: "Reply not found." }, { status: 404 }); - } - if (comment.userId !== session.user.id) { - return Response.json({ error: "That is not your reply." }, { status: 403 }); - } - } else { - const post = await getPostStub(postId); - if (!post) { - return Response.json({ error: "Thread not found." }, { status: 404 }); - } - if (post.userId !== session.user.id) { - return Response.json({ error: "That is not your thread." }, { status: 403 }); - } - } - - const files = form.getAll("files").filter((entry): entry is File => entry instanceof File); - if (files.length === 0) { - return Response.json({ error: "No files in the request." }, { status: 400 }); - } - - const already = await countAttachments(postId, commentId); - if (already + files.length > ATTACHMENT_MAX_PER_PARENT) { - return Response.json( - { error: `Up to ${ATTACHMENT_MAX_PER_PARENT} files each — this one has ${already}.` }, - { status: 400 }, - ); - } - - // Validate everything before writing anything: a half-attached post is worse - // than a rejected one. - const accepted: { id: string; filename: string; bytes: Buffer }[] = []; - for (const file of files) { - const filename = cleanFilename(file.name); - if (!filename) { - return Response.json({ error: "A file had no usable name." }, { status: 400 }); - } - if (!attachmentAllowed(filename)) { - return Response.json( - { error: `“${filename}” is not a file type we take. Link it from a repo instead.` }, - { status: 400 }, - ); - } - if (file.size > ATTACHMENT_MAX_BYTES) { - return Response.json( - { - error: `“${filename}” is ${formatBytes(file.size)} — the limit is ${formatBytes( - ATTACHMENT_MAX_BYTES, - )}.`, - }, - { status: 400 }, - ); - } - accepted.push({ - id: newId(), - filename, - bytes: Buffer.from(await file.arrayBuffer()), - }); - } - - // Quotas, checked once the real sizes are known. The per-parent count above - // bounds a thread; these bound an account and the disk. - const incoming = accepted.reduce((sum, item) => sum + item.bytes.length, 0); - const [mine, everyone] = await Promise.all([ - attachmentBytesByUser(session.user.id), - attachmentBytesTotal(), - ]); - if (mine + incoming > ATTACHMENT_MAX_PER_USER_BYTES) { - return Response.json( - { - error: - `You are storing ${formatBytes(mine)} of ${formatBytes(ATTACHMENT_MAX_PER_USER_BYTES)}. ` + - "Remove a file from an older thread to make room — the × on any attachment.", - }, - { status: 413 }, - ); - } - if (everyone + incoming > ATTACHMENT_MAX_TOTAL_BYTES) { - // Nothing the uploader can do about this one, so it says so plainly - // rather than blaming them. - return Response.json( - { error: "The community's file storage is full. This is being looked at — try again later." }, - { status: 507 }, - ); - } - - await fs.mkdir(attachmentDir(), { recursive: true }); - const now = new Date(); - const written: string[] = []; - try { - for (const item of accepted) { - await fs.writeFile(attachmentPath(item.id), item.bytes); - written.push(item.id); - } - await getDb() - .insert(postAttachments) - .values( - accepted.map((item) => ({ - id: item.id, - postId, - commentId, - userId: session.user.id, - filename: item.filename, - bytes: item.bytes.length, - createdAt: now, - })), - ); - } catch { - // Bytes on disk with no row pointing at them are invisible and permanent, - // so a failed write takes its own files back out. - await Promise.all( - written.map((id) => fs.rm(attachmentPath(id), { force: true }).catch(() => undefined)), - ); - return Response.json({ error: "Could not store the files." }, { status: 500 }); - } - - return Response.json( - { - ok: true, - files: accepted.map((item) => ({ - id: item.id, - filename: item.filename, - bytes: item.bytes.length, - })), - }, - { status: 201 }, - ); -} diff --git a/web/src/app/api/community/builds/[id]/modules/route.ts b/web/src/app/api/community/builds/[id]/modules/route.ts index e5e3bc05a..68231f879 100644 --- a/web/src/app/api/community/builds/[id]/modules/route.ts +++ b/web/src/app/api/community/builds/[id]/modules/route.ts @@ -94,8 +94,9 @@ async function handleGet(request: Request, context: { params: Promise<{ id: stri } // Exact-match lookup against the zip's own entry names — nothing here - // touches the filesystem, so there is no path to traverse. - const entry = wantedFile ? entries[wantedFile] : undefined; + // touches the filesystem, so there is no path to traverse. Own names + // only: `?file=__proto__` used to find Object.prototype and answer 200. + const entry = wantedFile && Object.hasOwn(entries, wantedFile) ? entries[wantedFile] : undefined; if (!entry) return new Response("No such file in this build.", { status: 404 }); return new Response(new Uint8Array(entry), { diff --git a/web/src/app/api/community/builds/route.test.ts b/web/src/app/api/community/builds/route.test.ts new file mode 100644 index 000000000..878c1de53 --- /dev/null +++ b/web/src/app/api/community/builds/route.test.ts @@ -0,0 +1,244 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { zipSync } from "fflate"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// POST /builds read each entry of `patterns` as an object without asking, and +// on 26 October `{"patterns":[null]}` threw a TypeError on `.code`: Next +// answered a 500 with no JSON and no CORS headers. GET +// /builds//modules?file=__proto__ looked the name up on the unzipped +// entries and found Object.prototype, so it answered 200 with an empty body +// and `Content-Length: undefined`; `constructor`, `toString` and +// `hasOwnProperty` were the same. No test called either GET route. All three +// handlers are driven here with a signed-in session, on a database and an +// artifact directory of their own. + +const LAB_ORIGIN = "https://lab.patternflow.test"; +const ENV_KEYS = [ + "COMMUNITY_DB_PATH", + "COMMUNITY_ENABLED", + "BETTER_AUTH_SECRET", + "COMMUNITY_ALLOWED_ORIGINS", + "BUILD_ENABLED", + "BUILD_ARTIFACT_DIR", +] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; +let cookie = ""; + +type Params = { params: Promise<{ id: string }> }; +let post: (body: unknown) => Promise; +let poll: (id: string) => Promise; +let modules: (id: string, query?: string) => Promise; + +// Passes validateCustomPattern, which is all POST /builds asks of a header +// before queueing it. Nothing here compiles it. +const HEADER = [ + "#pragma once", + "namespace RouteTest {", + 'const char* NAME = "Route test";', + 'const char* KNOB_LABELS[4] = {"A", "B", "C", "D"};', + "void setup() {}", + "void update(float t) {}", + "void draw() {}", + "}", +].join("\n"); + +const PFM = new Uint8Array([0x50, 0x46, 0x4d, 0x31, 1, 2, 3, 4]); +// Built with this realm's Uint8Array: under jsdom, fflate's strToU8 returns +// one zipSync does not take for a file, and it writes a folder per byte. +const JSON_ENTRY = Uint8Array.from('{"name":"Route test"}', (char) => char.charCodeAt(0)); +const ZIP = zipSync({ "route_test.pfm": PFM, "route_test.json": JSON_ENTRY }); + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-builds-route-")); + const artifacts = path.join(tmp, "builds"); + fs.mkdirSync(artifacts); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "builds-route-test-secret-not-a-real-one"; + process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN; + process.env.BUILD_ENABLED = "1"; + process.env.BUILD_ARTIFACT_DIR = artifacts; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const queueRoute = await import("./route"); + const pollRoute = await import("./[id]/route"); + const modulesRoute = await import("./[id]/modules/route"); + + const response = await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "builds-route-test-password", name: "ada", username: "ada" }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("no session cookie for ada"); + cookie = setCookie.split(";")[0]; + + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const userId = ada!.id; + const now = new Date(); + const done = { + userId, + status: "done", + format: "pfm", + kind: "send", + patterns: JSON.stringify([{ label: "Route test", code: HEADER }]), + namespaces: JSON.stringify(["RouteTest"]), + createdAt: now, + startedAt: now, + finishedAt: now, + }; + fs.writeFileSync(path.join(artifacts, "b-done.zip"), ZIP); + // A file the artifact directory does not hold, for a row that names it. + fs.writeFileSync(path.join(tmp, "outside.zip"), ZIP); + await db.insert(schema.builds).values([ + { ...done, id: "b-done", artifact: "b-done.zip", artifactBytes: ZIP.byteLength }, + { ...done, id: "b-escape", artifact: "../outside.zip", artifactBytes: ZIP.byteLength }, + { ...done, id: "b-gone", artifact: "b-gone.zip", artifactBytes: ZIP.byteLength }, + { ...done, id: "b-queued", status: "queued", artifact: null, startedAt: null, finishedAt: null }, + ]); + + post = (body) => + (queueRoute.POST as (request: Request) => Promise)( + new Request("http://localhost:3000/api/community/builds", { + method: "POST", + headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie }, + body: JSON.stringify(body), + }), + ); + poll = (id) => + (pollRoute.GET as (request: Request, context: Params) => Promise)( + new Request(`http://localhost:3000/api/community/builds/${id}`), + { params: Promise.resolve({ id }) }, + ); + modules = (id, query = "") => + (modulesRoute.GET as (request: Request, context: Params) => Promise)( + new Request(`http://localhost:3000/api/community/builds/${id}/modules${query}`), + { params: Promise.resolve({ id }) }, + ); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe("POST /api/community/builds", () => { + it.each([ + ["null", [null]], + ["a number", [0]], + ["true", [true]], + ["a string", [HEADER]], + ["an array", [[HEADER]]], + ["null after a good one", [{ label: "Route test", code: HEADER }, null]], + ])("answers a pattern that is %s with a 400 the Lab can read", async (_, patterns) => { + const response = await post({ format: "pfm", patterns }); + expect(response.status).toBe(400); + const { error } = (await response.json()) as { error: string }; + expect(error).toMatch(/^Pattern \d+: /); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN); + }); + + it("queues a header it can read, and the poll route finds the build", async () => { + const response = await post({ format: "pfm", patterns: [{ label: "Route test", code: HEADER }] }); + expect(response.status).toBe(202); + const queued = (await response.json()) as { id: string; format: string; namespaces: string[] }; + expect(queued.format).toBe("pfm"); + expect(queued.namespaces).toEqual(["RouteTest"]); + + const polled = await poll(queued.id); + expect(polled.status).toBe(200); + expect(polled.headers.get("Cache-Control")).toBe("no-store"); + expect(await polled.json()).toMatchObject({ + id: queued.id, + status: "queued", + patterns: ["Route test"], + queuePosition: 1, + modulesUrl: null, + }); + }); +}); + +describe("GET /api/community/builds/[id]", () => { + it("names the modules URL once a build is done", async () => { + const response = await poll("b-done"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + status: "done", + namespaces: ["RouteTest"], + queuePosition: null, + bytes: ZIP.byteLength, + modulesUrl: "/api/community/builds/b-done/modules", + }); + }); + + it("answers an id nobody queued with a 404", async () => { + expect((await poll("0000000000000000")).status).toBe(404); + }); +}); + +describe("GET /api/community/builds/[id]/modules", () => { + it("serves the whole zip with no query", async () => { + const response = await modules("b-done"); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/zip"); + expect(response.headers.get("Content-Length")).toBe(String(ZIP.byteLength)); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(ZIP); + }); + + it("lists the zip's entries", async () => { + const response = await modules("b-done", "?list=1"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ files: ["route_test.json", "route_test.pfm"] }); + }); + + it.each([ + ["route_test.pfm", PFM, "application/octet-stream"], + ["route_test.json", JSON_ENTRY, "application/json"], + ])("serves the entry %s", async (name, bytes, type) => { + const response = await modules("b-done", `?file=${name}`); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe(type); + expect(response.headers.get("Content-Length")).toBe(String(bytes.byteLength)); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes); + }); + + it.each(["missing.pfm", "__proto__", "constructor", "toString", "hasOwnProperty"])( + "answers ?file=%s, which the zip does not hold, with a 404", + async (name) => { + const response = await modules("b-done", `?file=${name}`); + expect(response.status).toBe(404); + expect(await response.text()).toBe("No such file in this build."); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }, + ); + + it("will not read an artifact outside the artifact directory", async () => { + const response = await modules("b-escape"); + expect(response.status).toBe(400); + expect(await response.text()).toBe("Invalid artifact path."); + }); + + it("says when a finished build's zip is gone from disk", async () => { + expect((await modules("b-gone")).status).toBe(410); + }); + + it.each(["b-queued", "0000000000000000"])("has no modules for %s", async (id) => { + expect((await modules(id, "?list=1")).status).toBe(404); + }); +}); diff --git a/web/src/app/api/community/builds/route.ts b/web/src/app/api/community/builds/route.ts index e8aa6f459..ad1f2bc8a 100644 --- a/web/src/app/api/community/builds/route.ts +++ b/web/src/app/api/community/builds/route.ts @@ -110,6 +110,13 @@ async function handlePost(request: Request) { const patterns: BuildPatternInput[] = []; for (const [index, entry] of raw.entries()) { + // `[null]` threw on `.code` below and came back a 500 without CORS. + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + return Response.json( + { error: `Pattern ${index + 1}: expected an object with the header in "code".` }, + { status: 400 }, + ); + } const item = entry as Record; const code = typeof item.code === "string" ? item.code : ""; if (code.length === 0 || code.length > CPP_MAX) { diff --git a/web/src/app/api/community/comments/[id]/route.ts b/web/src/app/api/community/comments/[id]/route.ts index cf3b030bb..06d395899 100644 --- a/web/src/app/api/community/comments/[id]/route.ts +++ b/web/src/app/api/community/comments/[id]/route.ts @@ -7,18 +7,18 @@ import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getCommentStub } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; -import { comments, postComments } from "@/lib/community/server/schema"; +import { comments } from "@/lib/community/server/schema"; import { COMMENT_MAX, cleanComment } from "@/lib/community/validate"; -// DELETE /api/community/comments/[id]?on=pattern|post +// DELETE /api/community/comments/[id]?on=pattern // // Comments previously had no removal path at all — not for their author, not // for anyone. That left "report this comment" with no remedy behind it, so the // report button and this route ship together. // -// `on` says which thread the comment belongs to. Pattern comments and post -// comments live in separate tables (see schema.ts), and guessing by trying both -// would make the route's behaviour depend on id collisions. +// `on` says which thread the comment belongs to. Until 26 October 2026 it could +// also be "post", a reply in the workshop. The workshop came down that day and +// its replies stay in their own table, untouched by this route. export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { const blocked = originBlocked(request); @@ -52,12 +52,12 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st } const on = new URL(request.url).searchParams.get("on"); - if (on !== "pattern" && on !== "post") { + if (on !== "pattern") { return Response.json({ error: "Unknown comment thread." }, { status: 400 }); } const { id } = await context.params; - const comment = await getCommentStub(on, id); + const comment = await getCommentStub(id); if (!comment) { return Response.json({ error: "Comment not found." }, { status: 404 }); } @@ -76,14 +76,13 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st ); } - const table = on === "pattern" ? comments : postComments; // `editedAt` is stamped so the thread shows the comment changed. Replies sit // under it, and a comment that quietly becomes something else makes them read // as answers to something never said. await getDb() - .update(table) + .update(comments) .set({ body: next, editedAt: new Date() }) - .where(eq(table.id, id)); + .where(eq(comments.id, id)); return Response.json({ ok: true }); } @@ -99,12 +98,12 @@ async function handleDelete(request: Request, context: { params: Promise<{ id: s } const on = new URL(request.url).searchParams.get("on"); - if (on !== "pattern" && on !== "post") { + if (on !== "pattern") { return Response.json({ error: "Unknown comment thread." }, { status: 400 }); } const { id } = await context.params; - const comment = await getCommentStub(on, id); + const comment = await getCommentStub(id); if (!comment) { return Response.json({ error: "Comment not found." }, { status: 404 }); } @@ -113,8 +112,7 @@ async function handleDelete(request: Request, context: { params: Promise<{ id: s return Response.json({ error: "You can only delete your own comments." }, { status: 403 }); } - const table = on === "pattern" ? comments : postComments; - await getDb().delete(table).where(eq(table.id, id)); + await getDb().delete(comments).where(eq(comments.id, id)); // The notifications this comment caused go with it. await clearNotificationsFor({ sourceId: id }); return Response.json({ ok: true }); diff --git a/web/src/app/api/community/decks/[id]/route.ts b/web/src/app/api/community/decks/[id]/route.ts index 992ef7ddc..afec8a71d 100644 --- a/web/src/app/api/community/decks/[id]/route.ts +++ b/web/src/app/api/community/decks/[id]/route.ts @@ -22,6 +22,7 @@ import { serializePerformance, validatePerformance } from "@/lib/pattern/pfst"; import { deckPatterns, decks } from "@/lib/community/server/schema"; import { COMMENT_MAX, + TITLE_ERROR, cleanDescription, cleanModerationReason, cleanTitle, @@ -115,7 +116,7 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st let title = deck.title; if (raw.title !== undefined) { const next = cleanTitle(raw.title); - if (!next) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); + if (!next) return Response.json({ error: TITLE_ERROR }, { status: 400 }); title = next; } diff --git a/web/src/app/api/community/decks/route.ts b/web/src/app/api/community/decks/route.ts index 99b6f6c40..52e578c38 100644 --- a/web/src/app/api/community/decks/route.ts +++ b/web/src/app/api/community/decks/route.ts @@ -9,7 +9,7 @@ import { notifyDeckInclusion } from "@/lib/community/server/notify"; import { countPublicDecksByUser, getPatternsForDeck, newId } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; import { deckPatterns, decks } from "@/lib/community/server/schema"; -import { cleanDescription, cleanTitle } from "@/lib/community/validate"; +import { TITLE_ERROR, cleanDescription, cleanTitle } from "@/lib/community/validate"; import { cleanVisibility } from "@/lib/community/visibility"; // POST /api/community/decks — share the working deck as a deck other people @@ -47,7 +47,7 @@ async function handlePost(request: Request) { if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); - if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); + if (!title) return Response.json({ error: TITLE_ERROR }, { status: 400 }); const description = cleanDescription(raw.description); if (description === undefined) { diff --git a/web/src/app/api/community/nullBody.test.ts b/web/src/app/api/community/nullBody.test.ts index 0c4a4c807..b6dc1c657 100644 --- a/web/src/app/api/community/nullBody.test.ts +++ b/web/src/app/api/community/nullBody.test.ts @@ -9,9 +9,6 @@ import { afterAll, beforeAll, expect, it } from "vitest"; // Pattern Lab on another origin could not read even the status. Every route // below is driven with a signed-in moderator and the Lab's Origin, on a // database of its own, with something for each id in the path to find. -// -// territories/[code]/pin is not here: its body is optional, and a `null` one -// already pins with no note. const LAB_ORIGIN = "https://lab.patternflow.test"; const ENV_KEYS = [ @@ -31,8 +28,6 @@ type RouteModule = Record; // [method, path, module loader, path params] const ROUTES: [string, string, () => Promise, Record][] = [ - ["POST", "atlas", () => import("./atlas/route"), {}], - ["DELETE", "atlas", () => import("./atlas/route"), {}], ["POST", "builds", () => import("./builds/route"), {}], ["PATCH", "comments/c1?on=pattern", () => import("./comments/[id]/route"), { id: "c1" }], ["POST", "decks", () => import("./decks/route"), {}], @@ -44,15 +39,8 @@ const ROUTES: [string, string, () => Promise, Record import("./patterns/[id]/header/route"), { id: "p1" }], ["POST", "patterns/p1/performance", () => import("./patterns/[id]/performance/route"), { id: "p1" }], ["PATCH", "ports/h1", () => import("./ports/[id]/route"), { id: "h1" }], - ["POST", "posts", () => import("./posts/route"), {}], - ["PATCH", "posts/s1", () => import("./posts/[id]/route"), { id: "s1" }], - ["POST", "posts/s1/comments", () => import("./posts/[id]/comments/route"), { id: "s1" }], - ["POST", "posts/s1/pin", () => import("./posts/[id]/pin/route"), { id: "s1" }], - ["POST", "presence", () => import("./presence/route"), {}], ["POST", "reports", () => import("./reports/route"), {}], ["PATCH", "reports/r1", () => import("./reports/[id]/route"), { id: "r1" }], - ["POST", "territories", () => import("./territories/route"), {}], - ["PATCH", "territories/A1", () => import("./territories/[code]/route"), { code: "A1" }], ]; beforeAll(async () => { @@ -95,10 +83,6 @@ beforeAll(async () => { await db.insert(schema.patternHeaders).values({ id: "h1", patternId: "p1", userId, codeCpp: "#pragma once\n", createdAt: now, }); - await db.insert(schema.territories).values({ id: "t1", code: "A1", title: "Workshop", createdAt: now }); - await db.insert(schema.posts).values({ - id: "s1", territoryId: "t1", userId, title: "ada's post", body: "hello", createdAt: now, updatedAt: now, - }); await db.insert(schema.reports).values({ id: "r1", targetType: "pattern", targetId: "p1", reporterId: userId, reason: "other", createdAt: now, }); diff --git a/web/src/app/api/community/patterns/[id]/comments/route.ts b/web/src/app/api/community/patterns/[id]/comments/route.ts index 16bf38756..c4235d8b9 100644 --- a/web/src/app/api/community/patterns/[id]/comments/route.ts +++ b/web/src/app/api/community/patterns/[id]/comments/route.ts @@ -67,7 +67,6 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str }); await notifyCommentAdded({ - on: "pattern", targetId: patternId, targetTitle: pattern.title, ownerId: pattern.userId, diff --git a/web/src/app/api/community/patterns/[id]/route.ts b/web/src/app/api/community/patterns/[id]/route.ts index afa177c08..87f20802b 100644 --- a/web/src/app/api/community/patterns/[id]/route.ts +++ b/web/src/app/api/community/patterns/[id]/route.ts @@ -31,6 +31,7 @@ import { buildStoredPatternCode, lineageFrom } from "@/lib/community/license"; import { CODE_MAX, COMMENT_MAX, + TITLE_ERROR, cleanCode, cleanCpp, cleanDescription, @@ -165,7 +166,7 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st let title = pattern.title; if (raw.title !== undefined) { const next = cleanTitle(raw.title); - if (!next) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); + if (!next) return Response.json({ error: TITLE_ERROR }, { status: 400 }); title = next; } diff --git a/web/src/app/api/community/patterns/route.test.ts b/web/src/app/api/community/patterns/route.test.ts new file mode 100644 index 000000000..a92576d5a --- /dev/null +++ b/web/src/app/api/community/patterns/route.test.ts @@ -0,0 +1,190 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// The title is written into the `// Title:` line of the licence header baked +// into the stored source, and a fork writes its parent's title into its own +// `// Based on:` line. On 26 October a title with a line feed and one line of +// JavaScript after it was published with 201, and the stored source ran that +// line. Forked, the fork ran it too, under the forker's name. Driven through +// the real routes with real sessions, on a database of its own. + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +/** Signs somebody up and returns their user id and Cookie header. */ +let enrol: (username: string) => Promise<{ id: string; cookie: string }>; +/** Puts a pattern straight into the table, as a past publish left it. */ +let seed: (row: { id: string; userId: string; title: string; code: string }) => Promise; +let publish: (cookie: string, body: Record) => Promise<{ status: number; id?: string; error?: string }>; +let edit: (cookie: string, patternId: string, body: Record) => Promise; +let storedCode: (patternId: string) => Promise; + +/** The licence block, from its opening rule to its closing one. */ +function header(code: string): string[] { + const lines = code.split(/\r\n|[\n\r\u2028\u2029]/); + const start = lines.indexOf("// ===== Patternflow pattern ====="); + const end = lines.findIndex((line, index) => index > start && /^\/\/ =+$/.test(line)); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return lines.slice(start, end + 1); +} + +/** Runs the source's top level and returns what it wrote to `ran`. */ +function run(code: string): string[] { + const ran: string[] = []; + new Function("ran", code)(ran); + return ran; +} + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-pattern-title-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "pattern-title-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const listRoute = await import("./route"); + const patternRoute = await import("./[id]/route"); + + const db = getDb(); + const auth = getAuth(); + + enrol = async (username) => { + const response = await auth.api.signUpEmail({ + body: { + email: `${username}@patternflow.local`, + password: "pattern-title-test-password", + name: username, + username, + }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error(`no session cookie for ${username}`); + const [author] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + return { id: author!.id, cookie: setCookie.split(";")[0] }; + }; + + seed = async ({ id, userId, title, code }) => { + const now = new Date(); + await db.insert(schema.patterns).values({ + id, + userId, + title, + code, + license: "CC-BY-SA-4.0", + visibility: "public", + createdAt: now, + updatedAt: now, + }); + }; + + // No Origin header below, which is the same-origin case originBlocked() + // lets through. + publish = async (cookie, body) => { + const response = await listRoute.POST( + new Request("http://localhost:3000/api/community/patterns", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + ); + const payload = (await response.json()) as { id?: string; error?: string }; + return { status: response.status, ...payload }; + }; + + edit = async (cookie, patternId, body) => { + const response = await patternRoute.PATCH( + new Request(`http://localhost:3000/api/community/patterns/${patternId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: patternId }) }, + ); + return response.status; + }; + + storedCode = async (patternId) => { + const [row] = await db + .select({ code: schema.patterns.code }) + .from(schema.patterns) + .where(eq(schema.patterns.id, patternId)) + .limit(1); + return row!.code; + }; +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it.each([ + ["a line feed", "\n"], + ["a carriage return", "\r"], + ["a line separator", "\u2028"], + ["a paragraph separator", "\u2029"], +])("refuses to publish a title broken by %s", async (_, lineBreak) => { + const gus = await enrol(`gus_${lineBreak.charCodeAt(0)}`); + const refused = await publish(gus.cookie, { + title: `Tide${lineBreak}ran.push("title");`, + code: "function draw() {}", + }); + expect(refused).toEqual({ + status: 400, + error: "Title is required (one line, max 80 chars, no control characters).", + }); +}, 30_000); + +it("keeps a title stored before the refusal out of its fork's code", async () => { + const ada = await enrol("ada"); + const eve = await enrol("eve"); + // What a publish with this title stored before 26 October: the title's + // second line sits between the comments as a line of code. + const title = 'Tide\nran.push("parent title");'; + const pastCode = [ + "// ===== Patternflow pattern =====", + "// Title: Tide", + 'ran.push("parent title");', + "// Author: @ada", + "// Date: 2026-10-20", + "// SPDX-License-Identifier: CC-BY-SA-4.0", + "// ===============================", + "", + "function draw() {}", + ].join("\n"); + await seed({ id: "p-ada", userId: ada.id, title, code: pastCode }); + expect(run(pastCode)).toEqual(["parent title"]); + + // Forking sends the parent's stored source as the new pattern's code. + const fork = await publish(eve.cookie, { title: "Tide again", code: pastCode, parentId: "p-ada" }); + expect(fork.status).toBe(201); + const forkCode = await storedCode(fork.id!); + expect(header(forkCode)).toHaveLength(7); + expect(header(forkCode).every((line) => line.startsWith("//"))).toBe(true); + expect(header(forkCode)[3]).toMatch(/^\/\/ Based on: "Tide ran\.push\("parent title"\);" by @ada — /); + expect(run(forkCode)).toEqual([]); + + // The parent's own source is rebuilt on its author's next edit. + expect(await edit(ada.cookie, "p-ada", { description: "Low tide." })).toBe(200); + const healed = await storedCode("p-ada"); + expect(header(healed)).toHaveLength(6); + expect(header(healed).every((line) => line.startsWith("//"))).toBe(true); + expect(run(healed)).toEqual([]); +}, 30_000); diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index f66f1e60c..314fe08f4 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -19,6 +19,7 @@ import { patterns } from "@/lib/community/server/schema"; import { toCardItem } from "@/lib/community/server/serialize"; import { CODE_MAX, + TITLE_ERROR, cleanCode, cleanCpp, cleanDescription, @@ -129,7 +130,7 @@ async function handlePost(request: Request) { if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); - if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); + if (!title) return Response.json({ error: TITLE_ERROR }, { status: 400 }); const description = cleanDescription(raw.description); if (description === undefined) { diff --git a/web/src/app/api/community/posts/[id]/comments/route.ts b/web/src/app/api/community/posts/[id]/comments/route.ts deleted file mode 100644 index e38d30af3..000000000 --- a/web/src/app/api/community/posts/[id]/comments/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { notifyCommentAdded } from "@/lib/community/server/notify"; -import { getPostStub, newId } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { postComments } from "@/lib/community/server/schema"; -import { cleanComment } from "@/lib/community/validate"; - -// POST /api/community/posts/[id]/comments — reply to a discussion thread (login -// required). Same rules as pattern comments: plain text in, React escapes out. - -export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request, context)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to comment." }, { status: 401 }); - } - - // Shares the pattern-comment budget on purpose: it is one person's comment - // rate, not a per-surface allowance. - if (!rateLimit(`comment:${session.user.id}`, 10, 60_000)) { - return Response.json({ error: "Too many comments — wait a minute and try again." }, { status: 429 }); - } - - const { id: postId } = await context.params; - const post = await getPostStub(postId); - if (!post) { - return Response.json({ error: "Post not found." }, { status: 404 }); - } - - const payload = await readJsonObject(request); - if (payload instanceof Response) return payload; - - const text = cleanComment(payload.body); - if (!text) { - return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); - } - - const commentId = newId(); - await getDb().insert(postComments).values({ - id: commentId, - postId, - userId: session.user.id, - body: text, - createdAt: new Date(), - }); - - await notifyCommentAdded({ - on: "post", - targetId: postId, - targetTitle: post.title, - ownerId: post.userId, - actorId: session.user.id, - commentId, - body: text, - }); - - // The id goes back because attachments are a second request: the reply - // composer uploads its files against this comment right after this returns. - return Response.json({ ok: true, id: commentId }, { status: 201 }); -} diff --git a/web/src/app/api/community/posts/[id]/pin/route.ts b/web/src/app/api/community/posts/[id]/pin/route.ts deleted file mode 100644 index 10c216b56..000000000 --- a/web/src/app/api/community/posts/[id]/pin/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { eq, isNotNull } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { getPostStub } from "@/lib/community/server/queries"; -import { posts } from "@/lib/community/server/schema"; - -// POST /api/community/posts/[id]/pin — moderators set (or clear) THE notice. -// -// Not part of the post PATCH on purpose: pinning is curation, not editing, and -// the edit route is author-only by a rule worth keeping intact. One slot: -// pinning a post un-pins whatever held the spot before. - -export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request, context)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session || !isAdminSession(session)) { - // 404, not 403 — the pin control is moderator furniture; everyone else - // has no reason to learn the endpoint exists. - return Response.json({ error: "Not found." }, { status: 404 }); - } - - const { id } = await context.params; - const post = await getPostStub(id); - if (!post) return Response.json({ error: "Post not found." }, { status: 404 }); - - const body = await readJsonObject(request); - if (body instanceof Response) return body; - const pinned = body.pinned; - if (typeof pinned !== "boolean") { - return Response.json({ error: "Expected { pinned: true | false }." }, { status: 400 }); - } - - const db = getDb(); - if (pinned) { - await db.update(posts).set({ pinnedAt: null }).where(isNotNull(posts.pinnedAt)); - await db.update(posts).set({ pinnedAt: new Date() }).where(eq(posts.id, id)); - } else { - await db.update(posts).set({ pinnedAt: null }).where(eq(posts.id, id)); - } - - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/posts/[id]/route.ts b/web/src/app/api/community/posts/[id]/route.ts deleted file mode 100644 index 36ee7b0eb..000000000 --- a/web/src/app/api/community/posts/[id]/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { eq } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { clearNotificationsFor } from "@/lib/community/server/notify"; -import { getPostStub } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { posts } from "@/lib/community/server/schema"; -import { cleanPostBody, cleanTitle } from "@/lib/community/validate"; - -// PATCH / DELETE /api/community/posts/[id] — the author edits or removes their -// own thread. Deleting takes its comments with it (they cascade). - -export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePatch(request, context)); -} - -export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleDelete(request, context)); -} - -export const OPTIONS = preflight; - -/** Shared gate: enabled, signed in, post exists, and it belongs to the caller. */ -async function authorize(request: Request, id: string, verb: string) { - if (!communityEnabled()) { - return { - error: Response.json( - { error: "Community is not enabled on this deployment." }, - { status: 503 }, - ), - }; - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return { error: Response.json({ error: `Sign in to ${verb} your post.` }, { status: 401 }) }; - } - - const post = await getPostStub(id); - if (!post) { - return { error: Response.json({ error: "Post not found." }, { status: 404 }) }; - } - // Moderators can take a post down, but not rewrite it: removing someone's - // post is a moderation act, editing it in their name is not. - const allowed = - post.userId === session.user.id || (verb === "delete" && isAdminSession(session)); - if (!allowed) { - // 403, not 404 — the post is public, it just isn't theirs to change. - return { - error: Response.json({ error: `You can only ${verb} your own posts.` }, { status: 403 }), - }; - } - - return { session }; -} - -async function handleDelete(request: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - const gate = await authorize(request, id, "delete"); - if (gate.error) return gate.error; - - await getDb().delete(posts).where(eq(posts.id, id)); - await clearNotificationsFor({ targetType: "post", targetId: id }); - return Response.json({ ok: true }); -} - -async function handlePatch(request: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - const gate = await authorize(request, id, "edit"); - if (gate.error) return gate.error; - - if (!rateLimit(`postpatch:${gate.session.user.id}`, 20, 60_000)) { - return Response.json({ error: "Too many edits — wait a minute and try again." }, { status: 429 }); - } - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - - // Partial update: only the fields present in the body change. - const patch: { title?: string; body?: string; updatedAt: Date } = { updatedAt: new Date() }; - - if (raw.title !== undefined) { - const title = cleanTitle(raw.title); - if (!title) return Response.json({ error: "Title is empty or too long." }, { status: 400 }); - patch.title = title; - } - if (raw.body !== undefined) { - const body = cleanPostBody(raw.body); - if (!body) return Response.json({ error: "Body is empty or too long." }, { status: 400 }); - patch.body = body; - } - if (patch.title === undefined && patch.body === undefined) { - return Response.json({ error: "Nothing to update." }, { status: 400 }); - } - - await getDb().update(posts).set(patch).where(eq(posts.id, id)); - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/posts/route.ts b/web/src/app/api/community/posts/route.ts deleted file mode 100644 index 45f3d970b..000000000 --- a/web/src/app/api/community/posts/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; -import { notifyNewThread } from "@/lib/community/server/notify"; -import { getTerritoryByCode, newId } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { posts, territoryPins } from "@/lib/community/server/schema"; -import { cleanPostBody, cleanTitle } from "@/lib/community/validate"; - -// POST /api/community/posts — start a thread inside a territory (login -// required). Title and body are plain text, escaped by React on output. -// -// A thread always belongs to a direction on the map: there is no general list -// any more, because a question about a direction belongs beside that -// direction. The caller names the territory by its map code ("A3"). -// -// `pin` piggybacks on the same request — the new-thread modal has "also pin -// me: I'm working here" checked by default, and starting a thread about -// something IS the strongest evidence you are working on it. Pinning is -// idempotent, so a second thread in the same territory changes nothing. - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to post." }, { status: 401 }); - } - - // Tighter than comments: a post is a thread, not a reply. - if (!rateLimit(`post:${session.user.id}`, 5, 60_000)) { - return Response.json({ error: "Too many posts — wait a minute and try again." }, { status: 429 }); - } - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - const title = cleanTitle(raw.title); - if (!title) { - return Response.json({ error: "Title is empty or too long." }, { status: 400 }); - } - const body = cleanPostBody(raw.body); - if (!body) { - return Response.json({ error: "Body is empty or too long." }, { status: 400 }); - } - - const code = cleanTerritoryCode(raw.territoryCode); - if (!code) { - return Response.json({ error: "Pick a place on the map for this." }, { status: 400 }); - } - const territory = await getTerritoryByCode(code); - if (!territory) { - return Response.json({ error: "That territory does not exist." }, { status: 404 }); - } - - const note = cleanPinNote(raw.pinNote); - if (note === undefined) { - return Response.json({ error: "That note is too long." }, { status: 400 }); - } - - const id = newId(); - const now = new Date(); - const db = getDb(); - await db.insert(posts).values({ - id, - territoryId: territory.id, - userId: session.user.id, - title, - body, - createdAt: now, - updatedAt: now, - }); - - if (raw.pin === true) { - // Already pinned there? Leave the original "since" alone — the date is the - // point of the pin, and a new thread does not restart it. - await db - .insert(territoryPins) - .values({ - id: newId(), - territoryId: territory.id, - userId: session.user.id, - note, - createdAt: now, - }) - .onConflictDoNothing(); - } - - // The pin-as-subscription payoff: everyone working in this territory hears - // that something started in it. After the author's own pin above, so a - // brand-new pin does not change who gets told about THIS thread (the actor - // is excluded either way) — but ordering it here keeps that true by - // construction rather than by coincidence. - await notifyNewThread({ - territoryId: territory.id, - territoryLabel: `${territory.code} · ${territory.title}`, - postId: id, - postTitle: title, - actorId: session.user.id, - }); - - return Response.json({ ok: true, id, territoryCode: territory.code }, { status: 201 }); -} diff --git a/web/src/app/api/community/presence/route.ts b/web/src/app/api/community/presence/route.ts deleted file mode 100644 index 0385ef7df..000000000 --- a/web/src/app/api/community/presence/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { - STAGE_HEIGHT, - STAGE_WIDTH, - STATUS_MAX, - cleanStageCoord, - cleanStatus, -} from "@/lib/community/workshop"; -import { countUnmoved, listPresence } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { presence } from "@/lib/community/server/schema"; - -// GET /api/community/presence — everyone standing on the constellation, -// plus how many accounts are still parked at the core. -// POST — move your own square, or say something. -// -// This is presence, not pins. A pin subscribes you to a territory's threads; -// your square just stands somewhere. The two never touch each other's tables, -// so walking around can never change what you get notified about. - -export async function GET(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleGet()); -} - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handleGet() { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - const [people, unmoved] = await Promise.all([listPresence(), countUnmoved()]); - return Response.json({ - people: people.map((person) => ({ - userId: person.userId, - username: person.username, - displayUsername: person.displayUsername, - x: person.x, - y: person.y, - status: person.status, - updatedAt: person.updatedAt.toISOString(), - })), - unmoved, - }); -} - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to stand on the map." }, { status: 401 }); - } - - // Movement saves are debounced client-side to one per pause, so a normal - // walk is a handful a minute; the limit only exists for a stuck key. - if (!rateLimit(`presence:${session.user.id}`, 60, 60_000)) { - return Response.json({ error: "Too many moves — stand still a moment." }, { status: 429 }); - } - - const payload = await readJsonObject(request); - if (payload instanceof Response) return payload; - - const hasPosition = payload.x !== undefined || payload.y !== undefined; - const hasStatus = payload.status !== undefined; - if (!hasPosition && !hasStatus) { - return Response.json({ error: "Nothing to change." }, { status: 400 }); - } - - let x: number | undefined; - let y: number | undefined; - if (hasPosition) { - // One coordinate without the other is a client bug, not a save. - x = cleanStageCoord(payload.x, "x"); - y = cleanStageCoord(payload.y, "y"); - if (x === undefined || y === undefined) { - return Response.json({ error: "Bad position." }, { status: 400 }); - } - } - - const status = hasStatus ? cleanStatus(payload.status) : undefined; - if (hasStatus && status === undefined) { - return Response.json( - { error: `A status fits in ${STATUS_MAX} characters — it stands beside your square.` }, - { status: 400 }, - ); - } - - // Upsert: first write births the square (a status-only save spawns it at the - // core, which is where the person visibly already was). - const now = new Date(); - await getDb() - .insert(presence) - .values({ - userId: session.user.id, - x: x ?? Math.round(STAGE_WIDTH / 2), - y: y ?? Math.round(STAGE_HEIGHT / 2), - status: status ?? null, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: presence.userId, - set: { - ...(hasPosition ? { x, y } : {}), - ...(hasStatus ? { status } : {}), - updatedAt: now, - }, - }); - - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/territories/[code]/pin/route.ts b/web/src/app/api/community/territories/[code]/pin/route.ts deleted file mode 100644 index 255096d53..000000000 --- a/web/src/app/api/community/territories/[code]/pin/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; -import { getTerritoryByCode, listTerritoryPins, newId } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { territoryPins } from "@/lib/community/server/schema"; - -// POST /api/community/territories/[code]/pin — "I'm working here." -// DELETE — take it back. -// -// The cheapest contribution the site accepts: one click, no artifact. That is -// the point — a direction with three names on it reads completely differently -// from the same direction with none, and asking for a write-up first would mean -// most directions stay at zero forever. -// -// Not a toggle on one verb. Pinning carries a note ("steel front") that the -// pinner can rewrite without unpinning, and unpinning throws away the "since" -// date, so the two need to be separately addressable. - -type Context = { params: Promise<{ code: string }> }; - -export async function POST(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePin(request, context)); -} - -export async function DELETE(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleUnpin(request, context)); -} - -export const OPTIONS = preflight; - -/** Everything both verbs need, or the response that says why not. */ -type Resolved = - | { error: Response } - | { error?: undefined; userId: string; territory: { id: string; code: string } }; - -async function resolve(request: Request, context: Context): Promise { - if (!communityEnabled()) { - return { - error: Response.json( - { error: "Community is not enabled on this deployment." }, - { status: 503 }, - ), - }; - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return { error: Response.json({ error: "Sign in to pin yourself." }, { status: 401 }) }; - } - - if (!rateLimit(`pin:${session.user.id}`, 30, 60_000)) { - return { - error: Response.json({ error: "Too many changes — slow down a moment." }, { status: 429 }), - }; - } - - const { code: rawCode } = await context.params; - const code = cleanTerritoryCode(rawCode); - const territory = code ? await getTerritoryByCode(code) : null; - if (!territory) { - return { error: Response.json({ error: "Territory not found." }, { status: 404 }) }; - } - - return { userId: session.user.id, territory }; -} - -async function handlePin(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { userId, territory } = resolved; - - let note: string | null | undefined = null; - // A note is optional, and so is the body carrying it. - try { - const payload = (await request.json()) as Record; - note = cleanPinNote(payload.note); - } catch { - note = null; - } - if (note === undefined) { - return Response.json({ error: "That note is too long." }, { status: 400 }); - } - - const db = getDb(); - // Re-pinning is how the note gets edited, so an existing row is updated in - // place — and keeps its original createdAt, because "since" is the part of a - // pin that means anything. - await db - .insert(territoryPins) - .values({ - id: newId(), - territoryId: territory.id, - userId, - note, - createdAt: new Date(), - }) - .onConflictDoUpdate({ - target: [territoryPins.territoryId, territoryPins.userId], - set: { note }, - }); - - return Response.json({ pinned: true, pins: await listTerritoryPins(territory.id) }); -} - -async function handleUnpin(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { userId, territory } = resolved; - - await getDb() - .delete(territoryPins) - .where( - and( - eq(territoryPins.territoryId, territory.id), - eq(territoryPins.userId, userId), - ), - ); - - return Response.json({ pinned: false, pins: await listTerritoryPins(territory.id) }); -} diff --git a/web/src/app/api/community/territories/[code]/route.ts b/web/src/app/api/community/territories/[code]/route.ts deleted file mode 100644 index 7e554b835..000000000 --- a/web/src/app/api/community/territories/[code]/route.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { eq } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { - QUESTION_MAX, - TERRITORY_DESC_MAX, - cleanQuestions, - cleanSpan, - cleanStageCoord, - cleanTerritoryCode, - cleanTerritoryDescription, - cleanTerritoryTitle, - overlongQuestion, -} from "@/lib/community/workshop"; -import { getTerritoryByCode } from "@/lib/community/server/queries"; -import { territories } from "@/lib/community/server/schema"; - -// PATCH /api/community/territories/[code] — edit one, or archive/restore it. -// DELETE /api/community/territories/[code] — only when nothing hangs off it. -// -// Archiving is the normal way to retire a direction: the threads written in it -// are still worth reading, and the codes are in their URLs. Deleting is for -// the case archiving does not cover — a typo'd territory nobody used — and is -// refused the moment it would take somebody's writing with it. -// -// The code itself is NOT editable. It is the identity: it appears in every -// thread URL under this territory, and renaming it would break every link -// anybody had shared. - -type Context = { params: Promise<{ code: string }> }; - -export async function PATCH(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePatch(request, context)); -} - -export async function DELETE(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleDelete(request, context)); -} - -export const OPTIONS = preflight; - -/** The territory, or the response saying why not. Spelled out rather than - * inferred so `resolved.error` narrows for the callers. */ -type Resolved = - | { error: Response; territory?: undefined } - | { error?: undefined; territory: NonNullable>> }; - -async function resolve(request: Request, context: Context): Promise { - if (!communityEnabled()) { - return { - error: Response.json( - { error: "Community is not enabled on this deployment." }, - { status: 503 }, - ), - }; - } - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!isAdminSession(session)) { - return { error: Response.json({ error: "Not found." }, { status: 404 }) }; - } - const { code: rawCode } = await context.params; - const code = cleanTerritoryCode(rawCode); - const territory = code ? await getTerritoryByCode(code) : null; - if (!territory) { - return { error: Response.json({ error: "Territory not found." }, { status: 404 }) }; - } - return { territory }; -} - -async function handlePatch(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { territory } = resolved; - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - - // Every field is optional — the editor sends what changed, and archiving - // arrives as its own one-key body. - const patch: Record = {}; - - if (raw.title !== undefined) { - const title = cleanTerritoryTitle(raw.title); - if (!title) return Response.json({ error: "A title is required." }, { status: 400 }); - patch.title = title; - } - if (raw.description !== undefined) { - const description = cleanTerritoryDescription(raw.description); - if (description === undefined) { - return Response.json( - { - error: - `A description is at most ${TERRITORY_DESC_MAX} characters — that one is ` + - `${typeof raw.description === "string" ? raw.description.trim().length : "?"}.`, - }, - { status: 400 }, - ); - } - patch.description = description; - } - if (raw.questions !== undefined) { - const questions = cleanQuestions(raw.questions); - if (questions === undefined) { - // Say WHICH line. The editor sends every field in one body, so a refusal - // here takes the description edit down with it, and "questions are one - // short line each" leaves you re-reading four lines to find the culprit. - const offender = overlongQuestion(raw.questions); - return Response.json( - { - error: offender - ? `“${offender.slice(0, 24)}…” is ${offender.length} characters. ` + - `A question hangs off the node as a chip, so it has to fit in ${QUESTION_MAX} — ` + - "put the long version in a thread." - : `Questions are one line each, ${QUESTION_MAX} characters at most.`, - }, - { status: 400 }, - ); - } - patch.questions = questions; - } - if (raw.span !== undefined) { - const span = cleanSpan(raw.span); - if (span === undefined) return Response.json({ error: "Width is 2 to 6 columns." }, { status: 400 }); - patch.span = span; - } - if (raw.x !== undefined) { - const x = cleanStageCoord(raw.x, "x"); - if (x === undefined) return Response.json({ error: "Bad map position." }, { status: 400 }); - patch.x = x; - } - if (raw.y !== undefined) { - const y = cleanStageCoord(raw.y, "y"); - if (y === undefined) return Response.json({ error: "Bad map position." }, { status: 400 }); - patch.y = y; - } - if (raw.position !== undefined) { - const position = Math.round(Number(raw.position)); - if (!Number.isFinite(position) || position < 0) { - return Response.json({ error: "Bad position." }, { status: 400 }); - } - patch.position = position; - } - if (raw.shippingNext !== undefined) { - patch.shippingNext = raw.shippingNext === true; - } - if (raw.archived !== undefined) { - patch.archivedAt = raw.archived === true ? new Date() : null; - } - - if (Object.keys(patch).length === 0) { - return Response.json({ error: "Nothing to change." }, { status: 400 }); - } - - await getDb().update(territories).set(patch).where(eq(territories.id, territory.id)); - return Response.json({ ok: true, code: territory.code }); -} - -async function handleDelete(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { territory } = resolved; - - // The counts ride on the row already (see territoryColumns), so this costs - // nothing to check — and it is the difference between removing a mistake and - // deleting other people's writing, which the foreign key would cascade away - // without asking. - if (territory.threadCount > 0 || territory.pinCount > 0) { - return Response.json( - { - error: - `${territory.code} has ${territory.threadCount} thread(s) and ${territory.pinCount} ` + - "pin(s). Archive it instead — the threads stay readable and their links keep working.", - }, - { status: 409 }, - ); - } - - await getDb().delete(territories).where(eq(territories.id, territory.id)); - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/territories/route.ts b/web/src/app/api/community/territories/route.ts deleted file mode 100644 index eb5e3b08a..000000000 --- a/web/src/app/api/community/territories/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { - cleanQuestions, - cleanSpan, - cleanStageCoord, - cleanTerritoryCode, - cleanTerritoryDescription, - cleanTerritoryTitle, -} from "@/lib/community/workshop"; -import { getTerritoryByCode, listTerritoriesForAdmin, newId } from "@/lib/community/server/queries"; -import { territories } from "@/lib/community/server/schema"; - -// POST /api/community/territories — draw a new direction on the map. -// -// Moderators only. Territories were seed-script-only at first, which meant -// adding one took an SSH session, a TypeScript edit and a redeploy — and the -// workshop's own empty state told the person running the project that someone -// ought to draw some, with no way to do it. This is that way. - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!isAdminSession(session)) { - // Same answer as a missing route, like the report queue. - return Response.json({ error: "Not found." }, { status: 404 }); - } - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - - const code = cleanTerritoryCode(raw.code); - if (!code) { - return Response.json( - { error: "A code is a letter and a number, like A1 or B3." }, - { status: 400 }, - ); - } - // The code is the identity: it is in every thread URL under this territory, - // so a duplicate would quietly attach new threads to the wrong direction. - if (await getTerritoryByCode(code)) { - return Response.json({ error: `${code} already exists.` }, { status: 409 }); - } - - const title = cleanTerritoryTitle(raw.title); - if (!title) { - return Response.json({ error: "A title is required." }, { status: 400 }); - } - const description = cleanTerritoryDescription(raw.description); - if (description === undefined) { - return Response.json({ error: "That description is too long." }, { status: 400 }); - } - const questions = cleanQuestions(raw.questions); - if (questions === undefined) { - return Response.json({ error: "Questions are one short line each." }, { status: 400 }); - } - const span = cleanSpan(raw.span ?? 2); - if (span === undefined) { - return Response.json({ error: "Width is 2 to 6 columns." }, { status: 400 }); - } - const x = cleanStageCoord(raw.x ?? 720, "x"); - const y = cleanStageCoord(raw.y ?? 320, "y"); - if (x === undefined || y === undefined) { - return Response.json({ error: "Bad map position." }, { status: 400 }); - } - - // New directions land at the end of the map rather than the front: the order - // is somebody's arrangement, and arriving should not disturb it. - const existing = await listTerritoriesForAdmin(); - const position = existing.reduce((max, row) => Math.max(max, row.position), -1) + 1; - - await getDb().insert(territories).values({ - id: newId(), - code, - title, - description, - span, - position, - x, - y, - shippingNext: raw.shippingNext === true, - questions, - createdAt: new Date(), - }); - - return Response.json({ ok: true, code }, { status: 201 }); -} diff --git a/web/src/app/community/atlas/page.tsx b/web/src/app/community/atlas/page.tsx index 803ddb2e7..c24ca2f3d 100644 --- a/web/src/app/community/atlas/page.tsx +++ b/web/src/app/community/atlas/page.tsx @@ -3,56 +3,38 @@ import type { Metadata } from "next"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { communityEnabled } from "@/lib/community/server/db"; -import { listAtlasPins, listPatternsByUser } from "@/lib/community/server/queries"; +import { listAtlasPins } from "@/lib/community/server/queries"; import AtlasClient from "@/components/community/AtlasClient"; -// The atlas: a map of pattern-technique space, sibling to the workshop map. -// The workshop charts where the HARDWARE could go (territories = project -// directions); this charts where PATTERNS could go — verified continents, -// open frontier, invented hypotheses, and, pinned between them, the actual -// published work: each pin is a live pattern placed where it belongs. +// The atlas: a map of pattern-technique space. It charts where PATTERNS could +// go — verified continents, open frontier, invented hypotheses, and, pinned +// between them, the actual published work: each pin is a live pattern placed +// where it belongs. // // Technique entries live in lib/atlas/data.ts and are edited like the seed // script. Pins live in the atlas_pins table. // -// Connecting work to the map happens HERE, by hand: publishing stays its own -// flow, and the atlas offers "add my pattern" — pick one of your published -// patterns, click where it belongs, and (optionally) tie it to the prompt -// point it came from. Nothing is inferred, nothing rides the publish flow. +// An archive since 26 October 2026. The community's menu and phone tabs no +// longer name it; the footer's Archive link does. Until then an author could +// add a published pattern here, move it and tie it to the prompt point it +// came from. The pins stay as they were left, and the route refuses writes. export const dynamic = "force-dynamic"; export const metadata: Metadata = { - title: "Atlas / Patternflow Community", + title: "Atlas archive / Patternflow Community", description: - "A map of pattern-technique space — verified continents, open frontier, published patterns pinned in place, and ready-made generation prompts for exploring each point.", + "A map of pattern-technique space, kept as an archive — verified continents, open frontier, published patterns pinned in place, and ready-made generation prompts for exploring each point.", }; export default async function AtlasPage() { if (!communityEnabled()) return null; // layout already redirected or rendered the notice + // The viewer still matters to a read: a private research note shows only to + // its author and to moderators. const session = await getAuth().api.getSession({ headers: await headers() }); const viewerId = session?.user.id ?? null; - const isAdmin = isAdminSession(session); - - const pins = await listAtlasPins(viewerId ? { id: viewerId, isAdmin } : null); - - // The picker: the viewer's own patterns that are not on the map yet, newest - // first (listPatternsByUser already orders that way) and carrying their code - // — you pick by looking at the pattern, not by reading a title. Private ones - // ride along flagged: they can only be filed as research notes, never as - // map tiles, and the client routes them there. - const pinned = new Set(pins.map((pin) => pin.patternId)); - const myPatterns = viewerId - ? (await listPatternsByUser(viewerId, viewerId)) - .filter((pattern) => !pinned.has(pattern.id)) - .map((pattern) => ({ - id: pattern.id, - title: pattern.title, - code: pattern.code, - isPublic: pattern.visibility === "public", - })) - : []; + const pins = await listAtlasPins(viewerId ? { id: viewerId, isAdmin: isAdminSession(session) } : null); return ( ); } diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index 84e7a8bb7..0e06b54df 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -24,6 +24,8 @@ import { } from "@/lib/community/visibility"; import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate"; import { useDeviceHost } from "@/lib/community/deviceHost"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; +import { useDialog } from "@/lib/hooks/useDialog"; import { useZipDownload, zipStatusUrl } from "@/lib/community/zipDownload"; import type { DeckPageItem } from "@/lib/community/server/serialize"; import { captureEvent } from "@/lib/posthogEvents"; @@ -411,6 +413,8 @@ export default function DeckDetailClient({ status={pack.status} error={pack.error} detail={pack.detail} + waited={pack.waited} + expectedS={pack.expectedS} /> )} @@ -607,6 +611,8 @@ function PerformanceModal({ const [json, setJson] = useState(initialJson ?? ""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const cardRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); const send = async (value: string | null) => { setBusy(true); @@ -643,8 +649,13 @@ function PerformanceModal({ }; return ( -
-
event.stopPropagation()}> +
+
event.stopPropagation()} + >
Deck performance