diff --git a/CLAUDE.md b/CLAUDE.md index 38151d3..4ef1fb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -388,17 +388,23 @@ When a search doesn't turn anything up: the wiring is usually in The full deployment runbook is [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md); the Railway readiness audit is [docs/RAILWAY_READINESS.md](docs/RAILWAY_READINESS.md). -Production = **Railway**. Two services: +Production = **Railway**. Three deployed services (plus an optional +fourth): -- `web` — Vite `build` → `vite preview` on `$PORT`, behind a Railway - domain. Static SPA + nothing else. +- `web` — Vite `build` → static `dist/` served by the `serve` package + on `$PORT` (`apps/web/Dockerfile`), behind a Railway domain. - `server` — `apps/server/Dockerfile` (Node 20 slim), Fastify on `$PORT`. Mounted Postgres add-on for the DB. S3-compatible object storage add-on for recordings/uploads. - -`railway.json` at the repo root encodes the build + start commands per -service. The `apps/server/Dockerfile` is the only Dockerfile in the -tree — `apps/web` ships as a Vite preview server, not a container. +- `sample-app` — same static-`serve` shape as `web` + (`apps/sample-app/Dockerfile`). +- `shotter` — optional headless-Chromium screenshot service + (`apps/shotter/Dockerfile`); not deployed today. + +Railway config-as-code is service-scoped (one file per service), so +each app carries its own `apps//railway.json`; point each Railway +service's "Config file path" setting at its file. See +docs/DEPLOYMENT.md §9. The MCP server (`apps/mcp`) **does not deploy**. It runs on the user's laptop (spawned by Claude Code, or via `npm run dev:mcp`). It opens a diff --git a/apps/sample-app/railway.json b/apps/sample-app/railway.json new file mode 100644 index 0000000..30e2368 --- /dev/null +++ b/apps/sample-app/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "apps/sample-app/Dockerfile" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/apps/sample-app/serve.json b/apps/sample-app/serve.json index 2510774..d103a04 100644 --- a/apps/sample-app/serve.json +++ b/apps/sample-app/serve.json @@ -1,22 +1,31 @@ { "headers": [ { - "source": "index.html", + "source": "**/*.@(js|css|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|map)", "headers": [ { "key": "Cache-Control", - "value": "no-cache, no-store, must-revalidate" + "value": "public, max-age=3600" } ] }, { - "source": "**/*.@(js|css|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|map)", + "source": "assets/**", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] + }, + { + "source": "index.html", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, no-store, must-revalidate" + } + ] } ], "cleanUrls": false, diff --git a/apps/server/railway.json b/apps/server/railway.json new file mode 100644 index 0000000..3e5e899 --- /dev/null +++ b/apps/server/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "apps/server/Dockerfile" + }, + "deploy": { + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f7c66cd..6393682 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -105,10 +105,28 @@ async function main(): Promise { // Extra origins from env (comma-separated). Lets production deploys add // their canvas/web hostnames without a code change. Localhost is always // allowed so `npm run dev` keeps working. + // A browser Origin header is always scheme://host[:port] — no path, no + // trailing slash. Normalise configured http(s) entries so an operator + // writing `https://foldo.dev/` (easy mistake) doesn't silently CORS-block + // the entire canvas. Non-http(s) schemes (chrome-extension://, + // moz-extension://, capacitor://, …) are passed through verbatim: + // `new URL(...).origin` is the literal string 'null' for them, which would + // both break the entry AND allowlist the dangerous `Origin: null` that + // sandboxed iframes / file:// pages send. const extraOrigins = (process.env.FOLDO_WEB_ORIGIN ?? '') .split(',') .map((s) => s.trim()) - .filter(Boolean); + .filter((s) => Boolean(s) && s !== 'null') + .map((s) => { + if (/^https?:\/\//i.test(s)) { + try { + return new URL(s).origin; + } catch { + /* fall through to the generic trim */ + } + } + return s.replace(/\/+$/, ''); + }); // Locking down the chrome-extension allowlist: before, ANY chrome-extension // origin could call the API (lets a malicious extension on a tester's box diff --git a/apps/server/src/repo/branches.ts b/apps/server/src/repo/branches.ts index d4bf95e..4d09d0b 100644 --- a/apps/server/src/repo/branches.ts +++ b/apps/server/src/repo/branches.ts @@ -113,6 +113,14 @@ function rowToCommit(r: CommitRow): Commit { } export async function upsertCommit(c: Commit): Promise { + // sha is the global PK; the same commit can arrive on multiple branch refs + // (merges, pushing an existing commit to a new branch). Updating + // message/author/parent on conflict is safe — in Git those are properties + // of the sha itself, so a re-delivery carries identical values — and it + // matters: POST /api/boards/:id/branches seeds a STUB row (`branch: ` + // message, the clicking user as author) that the real webhook delivery + // must be able to heal. branch_id is deliberately NOT updated: the first + // branch attribution wins. await exec( `INSERT INTO commits (sha, branch_id, message, author_user_id, parent_sha, created_at) VALUES ($1, $2, $3, $4, $5, $6) diff --git a/apps/server/src/routes/comments.ts b/apps/server/src/routes/comments.ts index 79bf775..abcb734 100644 --- a/apps/server/src/routes/comments.ts +++ b/apps/server/src/routes/comments.ts @@ -39,7 +39,10 @@ export async function registerCommentRoutes(app: FastifyInstance): Promise async (req, reply) => { const user = requireUser(req); const body = req.body; - if (!body?.boardId || !body?.frameId || !body?.text) { + // text may legitimately be '' — the pin-drop flow POSTs an empty body + // so the pin appears instantly and the user types into the popover + // afterwards. Reject only a missing/non-string text. + if (!body?.boardId || !body?.frameId || typeof body.text !== 'string') { return reply.code(400).send({ error: 'Invalid comment body', code: 'BAD_REQUEST' }); } // Comments require membership; even viewers can leave them in this MVP. diff --git a/apps/server/src/routes/recordings.ts b/apps/server/src/routes/recordings.ts index af005bb..8479c78 100644 --- a/apps/server/src/routes/recordings.ts +++ b/apps/server/src/routes/recordings.ts @@ -48,7 +48,11 @@ export async function registerRecordingRoutes( '/api/recordings/*', async (req: FastifyRequest<{ Params: { '*': string } }>, reply: FastifyReply) => { const key = decodeURIComponent(req.params['*'] ?? ''); - if (!key || key.includes('..')) { + // All recording keys live under the `recordings/` namespace. Enforcing + // the prefix matters on S3 deploys: `signedUrl()` will happily presign + // ANY bucket key, so without this check the route doubles as an open + // "presign anything" oracle (e.g. /api/recordings/uploads/.png). + if (!key.startsWith('recordings/') || key.includes('..')) { return reply .code(400) .send({ error: 'Bad recording key', code: 'BAD_REQUEST' }); diff --git a/apps/server/src/routes/tests.ts b/apps/server/src/routes/tests.ts index 5256724..c355156 100644 --- a/apps/server/src/routes/tests.ts +++ b/apps/server/src/routes/tests.ts @@ -346,6 +346,21 @@ export async function registerTestRoutes(app: FastifyInstance): Promise { url && (mode === 'auto' || mode === 'iframe') ? await probeFrameable(url) : null; + } else if ( + patch.targetMode !== undefined && + patch.targetMode !== test.targetMode + ) { + // Mode actually flipped without a new URL (e.g. dom_snapshot → + // iframe). The cached probe result may be stale or never computed, + // so re-probe against the existing target — otherwise + // resolveDeliveryMode keeps serving the wrong delivery mode off a + // stale `frameable`. (No-op PATCHes that re-send the current mode + // skip the probe; it's a blocking external GET.) + const mode = patch.targetMode; + patch.frameable = + test.targetUrl && (mode === 'auto' || mode === 'iframe') + ? await probeFrameable(test.targetUrl) + : null; } if (body.recordingModes !== undefined) { const modes = sanitizeRecordingModes(body.recordingModes); diff --git a/apps/server/src/routes/webhooks.ts b/apps/server/src/routes/webhooks.ts index ee44218..dce557a 100644 --- a/apps/server/src/routes/webhooks.ts +++ b/apps/server/src/routes/webhooks.ts @@ -1,5 +1,5 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; -import type { FastifyInstance, FastifyRequest, RawServerDefault } from 'fastify'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { Frame, GithubPushPayload, MarkdownFrameContent } from '@foldo/protocol'; import { getBoardByRepoSlug } from '../repo/boards.ts'; import { @@ -36,24 +36,36 @@ function verifyGithubSignature( } export async function registerWebhookRoutes(app: FastifyInstance): Promise { - app.post<{ Body: GithubPushPayload }>( - '/api/webhooks/github', - { - config: { rawBody: true }, - }, - async (req, reply) => { - const secret = process.env.FOLDO_GITHUB_WEBHOOK_SECRET; - // We need raw body for HMAC; Fastify parses JSON before our handler runs, - // so reconstitute by re-stringifying. This isn't byte-identical with - // exotic encodings but matches GitHub's payload for all practical cases. - // For strict verification, register a raw-body content-type-parser. - const raw = - (req as unknown as { rawBody?: string }).rawBody ?? JSON.stringify(req.body ?? {}); - if (!verifyGithubSignature(req, raw, secret)) { - return reply - .code(401) - .send({ error: 'Invalid webhook signature', code: 'UNAUTHORIZED' }); - } + // The webhook lives in its own encapsulated scope so we can override the + // JSON content-type parser for this route only: GitHub's HMAC is computed + // over the exact bytes it sent, so we must keep the verbatim body around — + // re-stringifying the parsed object is not byte-identical (key order, + // unicode escaping) and makes signature verification fail for real + // payloads whenever a secret is configured. + await app.register(async (scope) => { + // Delegate the actual parse to Fastify's default JSON parser so this + // route keeps the secure-json-parse prototype-poisoning protection and + // the standard malformed-JSON error shape every other route gets. + const defaultJsonParser = scope.getDefaultJsonParser('error', 'error'); + scope.addContentTypeParser( + 'application/json', + { parseAs: 'string' }, + (req, body, done) => { + (req as unknown as { rawBody?: string }).rawBody = body as string; + defaultJsonParser(req, body as string, done); + }, + ); + + scope.post<{ Body: GithubPushPayload }>( + '/api/webhooks/github', + async (req, reply) => { + const secret = process.env.FOLDO_GITHUB_WEBHOOK_SECRET; + const raw = (req as unknown as { rawBody?: string }).rawBody; + if (!verifyGithubSignature(req, raw ?? '', secret)) { + return reply + .code(401) + .send({ error: 'Invalid webhook signature', code: 'UNAUTHORIZED' }); + } const body = req.body; if (!body?.ref || !body?.after || !body?.repository?.full_name) { @@ -142,11 +154,7 @@ export async function registerWebhookRoutes(app: FastifyInstance): Promise hub.broadcast(board.id, { type: 'frame.added', frame }); return reply.send({ ok: true }); - }, - ); - - // Suppress unused-type warning for the helper signature on older Fastify type - // exports, we don't actually need RawServerDefault but the import keeps the - // type imports honest. Side-effect free. - void (null as unknown as RawServerDefault); + }, + ); + }); } diff --git a/apps/server/src/ws/browser.ts b/apps/server/src/ws/browser.ts index 9d41d16..bd17904 100644 --- a/apps/server/src/ws/browser.ts +++ b/apps/server/src/ws/browser.ts @@ -156,36 +156,57 @@ export async function registerBrowserWs(app: FastifyInstance): Promise { .connectionsOnBoard(board.id) .map((c) => c.presence); - void Promise.resolve(hub.latestSeq(board.id)).then((latestSeq) => { - sendSafe(socket, { - type: 'welcome', - boardId: board.id, - youUserId: user.id, - board, - users: others, - latestSeq, + // The protocol guarantees replayed messages arrive immediately AFTER + // the welcome, so the SENDS must be ordered — on RedisHub the two hub + // reads are independent round-trips that would otherwise race. The + // reads themselves are safe to issue in parallel; only the send + // order matters. + const sinceSeq = + typeof msg.sinceSeq === 'number' && msg.sinceSeq > 0 + ? msg.sinceSeq + : null; + void Promise.all([ + Promise.resolve(hub.latestSeq(board.id)), + sinceSeq === null + ? Promise.resolve(undefined) + : Promise.resolve(hub.getMissedSince(board.id, sinceSeq)), + ]) + .then(([latestSeq, missed]) => { + sendSafe(socket, { + type: 'welcome', + boardId: board.id, + youUserId: user.id, + board, + users: others, + latestSeq, + }); + // Replay any broadcasts the client missed while it was + // disconnected. If sinceSeq is older than our oldest buffered + // message getMissedSince returns null and the client falls back + // to a fresh REST refetch. + if (sinceSeq === null) return; + if (missed === null) { + wsReplayGaps.inc({ boardId: board.id }); + sendSafe(socket, { + type: 'error', + code: 'REPLAY_GAP', + message: + 'replay buffer no longer contains requested seq; please refetch', + }); + } else if (missed) { + for (const m of missed) sendSafe(socket, m); + } + }) + .catch((err) => { + wsLog.warn( + { + connId, + boardId: board.id, + err: err instanceof Error ? err.message : String(err), + }, + 'welcome/replay send failed', + ); }); - }); - - // Replay any broadcasts the client missed while it was disconnected. - // If sinceSeq is older than our oldest buffered message we return - // null and the client falls back to a fresh REST refetch. - if (typeof msg.sinceSeq === 'number' && msg.sinceSeq > 0) { - void Promise.resolve(hub.getMissedSince(board.id, msg.sinceSeq)).then( - (missed) => { - if (missed === null) { - wsReplayGaps.inc({ boardId: board.id }); - sendSafe(socket, { - type: 'error', - code: 'REPLAY_GAP', - message: 'replay buffer no longer contains requested seq; please refetch', - }); - } else { - for (const m of missed) sendSafe(socket, m); - } - }, - ); - } // Tell others we joined hub.broadcast(board.id, { type: 'presence.join', user: presence }, user.id); diff --git a/apps/shotter/Dockerfile b/apps/shotter/Dockerfile index 274a23f..e105037 100644 --- a/apps/shotter/Dockerfile +++ b/apps/shotter/Dockerfile @@ -1,6 +1,13 @@ # syntax=docker/dockerfile:1.6 # Foldo screenshot service. Headless Chromium via playwright-core's prebuilt # image so we don't have to babysit the install dance. +# +# IMPORTANT: this base-image tag must match the exact `playwright-core` +# version pinned in apps/shotter/package.json. The image pre-installs the +# browser revisions for ITS Playwright version only — a newer playwright-core +# from the lockfile looks for a Chromium revision that isn't there, and every +# POST /shot fails at runtime (the container still builds and passes /health). +# Bump both together. FROM mcr.microsoft.com/playwright:v1.58.0-jammy @@ -21,8 +28,9 @@ COPY apps/shotter/tsconfig.json ./apps/shotter/ EXPOSE 5180 +# node is guaranteed in this image; wget/curl are not. HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD wget -q -O - "http://localhost:${PORT}/health" >/dev/null || exit 1 + CMD node -e "fetch('http://localhost:'+(process.env.PORT||5180)+'/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" WORKDIR /app/apps/shotter CMD ["npm", "start"] diff --git a/apps/shotter/package.json b/apps/shotter/package.json index 0025947..1499d3e 100644 --- a/apps/shotter/package.json +++ b/apps/shotter/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "fastify": "^5.1.0", - "playwright-core": "^1.58.0", + "playwright-core": "1.58.0", "tsx": "^4.19.2" }, "devDependencies": { diff --git a/apps/shotter/railway.json b/apps/shotter/railway.json new file mode 100644 index 0000000..37df291 --- /dev/null +++ b/apps/shotter/railway.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "apps/shotter/Dockerfile" + }, + "deploy": { + "healthcheckPath": "/health", + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index bb13fb6..abbff72 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,9 +1,11 @@ # syntax=docker/dockerfile:1.6 # Multi-stage build for @foldo/web. Build context = repo root. # Build-time env (must be passed via --build-arg or Railway service vars): -# VITE_API_URL — e.g. https://api.foldo.example -# VITE_WS_URL — e.g. wss://api.foldo.example (optional; defaults from API_URL) -# VITE_SAMPLE_URL — e.g. https://sample.foldo.example +# VITE_API_URL — e.g. https://api.foldo.example +# VITE_WS_URL — e.g. wss://api.foldo.example (optional; defaults from API_URL) +# VITE_SAMPLE_URL — e.g. https://sample.foldo.example +# VITE_SHOTTER_URL — e.g. https://shotter.foldo.example (optional; only when +# the shotter service is deployed, docs/DEPLOYMENT.md §3.5) FROM node:20-slim AS builder @@ -12,9 +14,11 @@ WORKDIR /app ARG VITE_API_URL ARG VITE_WS_URL ARG VITE_SAMPLE_URL +ARG VITE_SHOTTER_URL ENV VITE_API_URL=$VITE_API_URL ENV VITE_WS_URL=$VITE_WS_URL ENV VITE_SAMPLE_URL=$VITE_SAMPLE_URL +ENV VITE_SHOTTER_URL=$VITE_SHOTTER_URL COPY package.json package-lock.json tsconfig.base.json ./ COPY apps/server/package.json ./apps/server/ @@ -25,8 +29,8 @@ COPY apps/extension/package.json ./apps/extension/ COPY packages/protocol/package.json ./packages/protocol/ COPY packages/plugin/package.json ./packages/plugin/ -# Web build doesn't need native modules — skip better-sqlite3 to keep the -# build fast and small. ignore-scripts prevents the postinstall. +# Web build doesn't need native modules or postinstall scripts — +# --ignore-scripts keeps the install fast and hermetic. RUN npm ci --workspaces --include-workspace-root --ignore-scripts COPY packages/protocol ./packages/protocol diff --git a/apps/web/railway.json b/apps/web/railway.json new file mode 100644 index 0000000..8dba654 --- /dev/null +++ b/apps/web/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "apps/web/Dockerfile" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/apps/web/serve.json b/apps/web/serve.json index 2510774..d103a04 100644 --- a/apps/web/serve.json +++ b/apps/web/serve.json @@ -1,22 +1,31 @@ { "headers": [ { - "source": "index.html", + "source": "**/*.@(js|css|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|map)", "headers": [ { "key": "Cache-Control", - "value": "no-cache, no-store, must-revalidate" + "value": "public, max-age=3600" } ] }, { - "source": "**/*.@(js|css|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|map)", + "source": "assets/**", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] + }, + { + "source": "index.html", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, no-store, must-revalidate" + } + ] } ], "cleanUrls": false, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index daca530..ed3c432 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -761,6 +761,7 @@ export default function App() { screenPosition={popoverScreenPos} composing={commentPopover.composing} onUpdateText={async (text) => { + const previous = popoverComment; const optimistic = { ...popoverComment, text, updatedAt: new Date().toISOString() }; boardStore.upsertComment(optimistic); if (boot.kind === 'offline') return; @@ -778,10 +779,42 @@ export default function App() { boardStore.upsertComment(updated); } catch (err) { console.warn('[foldo] update comment failed', err); + // Roll back the optimistic text — otherwise the store keeps + // unsaved text that silently vanishes on the next reload. + // Restore onto the CURRENT store entry (not the captured + // object) so concurrent WS changes (new replies, a newer + // successful save) aren't clobbered, and only if our + // optimistic text is still what's showing. + const current = boardStore.getSnapshot().comments.get(previous.id); + if (current && current.text === text) { + boardStore.upsertComment({ + ...current, + text: previous.text, + updatedAt: previous.updatedAt, + }); + } showToast(setToast, 'Failed to save comment'); } }} onClose={() => { + // Pin-drop comments are created empty (the server accepts '' + // so the pin shows instantly). If the popover closes and the + // comment is still empty with no replies, it was abandoned — + // delete it so boards don't accumulate ghost pins. Re-read the + // store: flushBody may have just written text the captured + // popoverComment doesn't have. Local-id comments are skipped; + // the in-flight create in useCommentHandlers cleans those up. + const current = boardStore + .getSnapshot() + .comments.get(popoverComment.id); + if ( + current && + !current.id.startsWith('c-local-') && + !current.text.trim() && + current.replies.length === 0 + ) { + void onDeleteComment(current.id); + } setCommentPopover(null); if (snap.board && route.frameId) { navigate({ boardId: snap.board.id, frameId: route.frameId }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1d48b95..8ba2abb 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -9,6 +9,17 @@ export const API_BASE = (import.meta.env.VITE_API_URL as string | undefined) || 'http://localhost:4000'; +/** + * Resolve a server-issued URL against the API origin. The server returns + * paths relative to ITSELF (`/api/uploads/…`, `/api/recordings/…`); the web + * app is served from a different origin, so a bare relative src would 404 + * against the web host. Absolute http(s)/data/blob URLs pass through. + */ +export function resolveApiUrl(url: string): string { + if (/^(https?:)?\/\//i.test(url) || /^(data|blob):/i.test(url)) return url; + return `${API_BASE}${url.startsWith('/') ? '' : '/'}${url}`; +} + let authToken: string | null = null; let authUserId: string | null = null; diff --git a/apps/web/src/components/Canvas.tsx b/apps/web/src/components/Canvas.tsx index 6126bc8..03272eb 100644 --- a/apps/web/src/components/Canvas.tsx +++ b/apps/web/src/components/Canvas.tsx @@ -322,6 +322,14 @@ export const Canvas = forwardRef(function Canvas( const w = screenToWorld(c.x, c.y); onCursorMove(w.x, w.y); }, [onCursorMove, screenToWorld]); + // Cancel a pending cursor flush on unmount — once containerRef is gone, + // screenToWorld degrades to {0,0} and we'd broadcast a bogus origin cursor. + useEffect( + () => () => { + if (cursorRafRef.current != null) cancelAnimationFrame(cursorRafRef.current); + }, + [], + ); const onPointerMove = (e: React.PointerEvent) => { /* A+W1 touch: keep the per-pointer position in sync so pinch math sees diff --git a/apps/web/src/components/ImageFrame.tsx b/apps/web/src/components/ImageFrame.tsx index a939bc4..ee25ebc 100644 --- a/apps/web/src/components/ImageFrame.tsx +++ b/apps/web/src/components/ImageFrame.tsx @@ -1,4 +1,5 @@ import type { Branch, Comment, Frame, ImageFrameContent } from '@foldo/protocol'; +import { resolveApiUrl } from '../api/client'; import { FrameMeta } from './FrameMeta'; import { CommentPin } from './CommentPin'; import { useFrameDrag } from './useFrameDrag'; @@ -24,7 +25,9 @@ export function ImageFrame({ onCommentClick, }: Props) { const c = frame.content as ImageFrameContent; - const src = c.url ?? c.dataUrl ?? ''; + // Upload URLs are relative to the API origin (`/api/uploads/…`), not the + // web host — resolve them before handing to . + const src = c.url ? resolveApiUrl(c.url) : (c.dataUrl ?? ''); const { handlers: dragHandlers } = useFrameDrag({ frame, zoom }); return (
s.testsRevision); + useEffect(() => { + if (!open) return; void refresh(); - }, [open, refresh]); + }, [open, testsRevision, refresh]); if (!open) return null; @@ -933,7 +942,7 @@ function SessionCard({ const taskTitle = (taskId: string, idx: number) => tasks.find((t) => t.id === taskId)?.title ?? `Task ${idx + 1}`; const recordingSrc = session.recordingUrl - ? `${API_BASE}${session.recordingUrl}` + ? resolveApiUrl(session.recordingUrl) : null; const started = new Date(session.startedAt); diff --git a/apps/web/src/hooks/useCanvasBoot.ts b/apps/web/src/hooks/useCanvasBoot.ts index b7f46d3..a05e089 100644 --- a/apps/web/src/hooks/useCanvasBoot.ts +++ b/apps/web/src/hooks/useCanvasBoot.ts @@ -16,14 +16,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { - Branch, - Comment, - Frame, + Dispatch, + GetBoardResponse, ServerMessage, } from '@foldo/protocol'; import { boardStore } from '../state/useBoardStore'; import { applyServerMessage } from '../state/reducers'; import { listBoards, getBoard } from '../api/boards'; +import { listDispatches } from '../api/dispatches'; import { FoldoWsClient, type WsStatus } from '../api/ws'; import { mockBoardSnapshot, @@ -101,8 +101,13 @@ export function useCanvasBoot({ // any frames/comments created while we were offline land in the store. if (s === 'open') { if (wasOpenOnce) { - void getBoard(boardId!) - .then((fresh) => hydrateStoreFromRest(fresh, demoUserId)) + void Promise.all([ + getBoard(boardId!), + listDispatches(boardId!), + ]) + .then(([fresh, d]) => + rehydrateStoreFromRest(fresh, d.dispatches), + ) .catch(() => { /* ignore, WS will keep us live */ }); @@ -167,23 +172,22 @@ export function useCanvasBoot({ // Identical bodies to the inlines App.tsx used to carry; live here so the // boot hook is self-contained. -function hydrateStoreFromRest( - snapshot: { - board: import('@foldo/protocol').Board; - branches: Branch[]; - frames: Frame[]; - comments: Comment[]; - users: import('@foldo/protocol').User[]; - mcpConnected: boolean; - }, - meUserId: string, -): void { - const frameMap = new Map(snapshot.frames.map((f) => [f.id, f])); - const commentMap = new Map(snapshot.comments.map((c) => [c.id, c])); - const branchMap = new Map(snapshot.branches.map((b) => [b.id, b])); - const userMap = new Map(snapshot.users.map((u) => [u.id, u])); +/** Build the Map-shaped slices shared by boot- and reconnect-hydration. */ +function snapshotSlices(snapshot: GetBoardResponse) { + return { + board: snapshot.board, + frames: new Map(snapshot.frames.map((f) => [f.id, f])), + comments: new Map(snapshot.comments.map((c) => [c.id, c])), + branches: new Map(snapshot.branches.map((b) => [b.id, b])), + users: new Map(snapshot.users.map((u) => [u.id, u])), + mcpConnected: snapshot.mcpConnected, + }; +} + +function hydrateStoreFromRest(snapshot: GetBoardResponse, meUserId: string): void { + const slices = snapshotSlices(snapshot); // Presence will be supplied by the WS welcome; seed a basic record for ourselves. - const me = userMap.get(meUserId); + const me = slices.users.get(meUserId); const presence = new Map(); if (me) { presence.set(meUserId, { @@ -200,16 +204,40 @@ function hydrateStoreFromRest( offline: false, wsStatus: 'connecting', meUserId, - board: snapshot.board, - frames: frameMap, - comments: commentMap, - branches: branchMap, - users: userMap, + ...slices, presence, dispatches: new Map(), - mcpConnected: snapshot.mcpConnected, + activeTestSessions: new Set(), + testsRevision: 0, + }); +} + +/** + * Reconnect-time rehydrate. Unlike the boot-time {@link hydrateStoreFromRest} + * this must NOT reset the slices the WS connection owns: the `welcome` that + * just arrived seeded the full presence list (a wholesale `set` would wipe + * remote peers, and the presence reducers drop updates for unknown users), + * and `wsStatus` was just set to 'open' by the status callback. + * + * Slices the REST board payload doesn't carry get refreshed explicitly: + * `dispatches` from the freshly-fetched list (so in-flight progress UI stays + * accurate instead of stale or blank), `activeTestSessions` cleared (it's a + * transient signal — a `test.session.completed` missed past the replay + * buffer would otherwise stick the "testing now" badge forever), and + * `testsRevision` bumped so an open TestsPanel refetches anything it missed. + */ +function rehydrateStoreFromRest( + snapshot: GetBoardResponse, + dispatches: Dispatch[], +): void { + boardStore.patch({ + hydrated: true, + offline: false, + ...snapshotSlices(snapshot), + dispatches: new Map(dispatches.map((d) => [d.id, d])), activeTestSessions: new Set(), }); + boardStore.markTestsChanged(); } function hydrateStoreFromMock(): void { @@ -233,5 +261,6 @@ function hydrateStoreFromMock(): void { dispatches: new Map(), mcpConnected: false, activeTestSessions: new Set(), + testsRevision: 0, }); } diff --git a/apps/web/src/hooks/useCommentHandlers.ts b/apps/web/src/hooks/useCommentHandlers.ts index 7346cd3..e72d643 100644 --- a/apps/web/src/hooks/useCommentHandlers.ts +++ b/apps/web/src/hooks/useCommentHandlers.ts @@ -166,6 +166,17 @@ export function useCommentHandlers({ commentId: c.id, composing: stillCompose ? true : undefined, }); + } else if (!typedText) { + // The popover was closed (or moved elsewhere) while the create was + // in flight AND no text was ever typed — the pin was abandoned. + // Delete the just-created empty comment instead of leaving a ghost + // pin (App.tsx's close handler can't do it: at close time the + // comment still had its local id). + boardStore.removeComment(c.id); + void apiDeleteComment(c.id).catch(() => { + /* already gone or unreachable — nothing to roll back to */ + }); + return; } // Fire-and-forget the PATCH if we rescued typed text. We don't // await because the optimistic swap above already shows the right @@ -363,9 +374,7 @@ export function useCommentHandlers({ const onReplyToComment = useCallback( async (commentId: string, text: string): Promise => { if (offline) { - const c = comments.get(commentId); - if (!c) return; - const reply = { + boardStore.addReply(commentId, { id: `r-local-${Date.now()}`, authorUserId: demoUserId, authorName: 'You', @@ -373,23 +382,19 @@ export function useCommentHandlers({ authorColor: '#7fd49a', text, createdAt: new Date().toISOString(), - }; - boardStore.upsertComment({ - ...c, - replies: [...c.replies, reply], }); return; } try { const r = await apiReplyToComment(commentId, { text }); - const c = boardStore.getSnapshot().comments.get(commentId); - if (c) boardStore.upsertComment({ ...c, replies: [...c.replies, r] }); + // Idempotent — the WS broadcast may have already appended this reply. + boardStore.addReply(commentId, r); } catch (e) { // eslint-disable-next-line no-console console.warn('[foldo] reply failed', e); } }, - [offline, comments, demoUserId], + [offline, demoUserId], ); const onResolveComment = useCallback( diff --git a/apps/web/src/marketing/Docs.tsx b/apps/web/src/marketing/Docs.tsx index 81fd3d0..4f35ff3 100644 --- a/apps/web/src/marketing/Docs.tsx +++ b/apps/web/src/marketing/Docs.tsx @@ -421,9 +421,11 @@ VITE_PARENT_ORIGIN=https://foldo.dev`}

Deploy to Railway

- A railway.json is already in the repo wiring three - services to per-app Dockerfiles. Provision a Postgres plugin and - wire ${'${{Postgres.DATABASE_URL}}'} into the server + Each app ships its own railway.json (e.g.{' '} + apps/server/railway.json) wiring the service to its + Dockerfile — point each Railway service's config-file path at + the matching file. Provision a Postgres plugin and wire{' '} + ${'${{Postgres.DATABASE_URL}}'} into the server service. Custom domains (Cloudflare → CNAME → Railway) take a few minutes once added in the Railway dashboard.

diff --git a/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.test.tsx b/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.test.tsx index ca32912..f78ecd4 100644 --- a/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.test.tsx +++ b/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.test.tsx @@ -92,6 +92,7 @@ function seed(patch: Partial): void { dispatches: new Map(), mcpConnected: false, activeTestSessions: new Set(), + testsRevision: 0, ...patch, }); } diff --git a/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.unit.test.tsx b/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.unit.test.tsx index 12a6914..eee873c 100644 --- a/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.unit.test.tsx +++ b/apps/web/src/plugins/core-layers/__tests__/LayerNavigator.unit.test.tsx @@ -93,6 +93,7 @@ function seed(patch: Partial): void { dispatches: new Map(), mcpConnected: false, activeTestSessions: new Set(), + testsRevision: 0, ...patch, }); } diff --git a/apps/web/src/state/BoardStore.ts b/apps/web/src/state/BoardStore.ts index b3317ad..acdf360 100644 --- a/apps/web/src/state/BoardStore.ts +++ b/apps/web/src/state/BoardStore.ts @@ -6,6 +6,7 @@ import type { Board, Branch, Comment, + CommentReply, Dispatch, Frame, PresenceUser, @@ -40,6 +41,13 @@ export interface BoardSnapshot { * normal `frame.added` path. */ activeTestSessions: Set; + /** + * Bumped whenever a `test.created` / `test.updated` / `test.deleted` + * broadcast arrives. Tests themselves live in TestsPanel-local state + * (fetched via REST); this counter just tells an open panel to refetch + * so collaborator edits show up live. + */ + testsRevision: number; } type Listener = () => void; @@ -58,6 +66,7 @@ const empty = (): BoardSnapshot => ({ dispatches: new Map(), mcpConnected: false, activeTestSessions: new Set(), + testsRevision: 0, }); class BoardStoreImpl { @@ -117,6 +126,20 @@ class BoardStoreImpl { this.patch({ comments }); } + /** + * Append a reply to a comment, idempotently by reply id. A reply can reach + * the store twice — once from the REST response and once from the WS + * broadcast (the server doesn't except the sender) — and array appends + * aren't idempotent like Map.set, so the dedupe lives here rather than as + * a convention every caller must remember. + */ + addReply(commentId: string, reply: CommentReply) { + const c = this.snap.comments.get(commentId); + if (!c) return; + if (c.replies.some((r) => r.id === reply.id)) return; + this.upsertComment({ ...c, replies: [...c.replies, reply] }); + } + removeComment(commentId: string) { if (!this.snap.comments.has(commentId)) return; const comments = new Map(this.snap.comments); @@ -165,6 +188,11 @@ class BoardStoreImpl { this.patch({ activeTestSessions }); } + /** Signal that the board's User Tests changed (created/updated/deleted). */ + markTestsChanged() { + this.patch({ testsRevision: this.snap.testsRevision + 1 }); + } + /** Clear the in-progress indicator for a test. */ markTestSessionInactive(testId: TestId) { if (!this.snap.activeTestSessions.has(testId)) return; diff --git a/apps/web/src/state/reducers.ts b/apps/web/src/state/reducers.ts index d9b0b2e..49e2039 100644 --- a/apps/web/src/state/reducers.ts +++ b/apps/web/src/state/reducers.ts @@ -70,16 +70,11 @@ export function applyServerMessage(msg: ServerMessage) { case 'comment.updated': boardStore.upsertComment(msg.comment); return; - case 'comment.reply.added': { - const snap = boardStore.getSnapshot(); - const c = snap.comments.get(msg.commentId); - if (!c) return; - boardStore.upsertComment({ - ...c, - replies: [...c.replies, msg.reply], - }); + case 'comment.reply.added': + // Idempotent by reply id — the author also receives this broadcast + // after already appending the REST response. + boardStore.addReply(msg.commentId, msg.reply); return; - } case 'comment.deleted': boardStore.removeComment(msg.commentId); return; @@ -136,10 +131,24 @@ export function applyServerMessage(msg: ServerMessage) { case 'test.session.completed': boardStore.markTestSessionInactive(msg.testId); return; + case 'test.created': + case 'test.updated': + case 'test.deleted': + // Tests live in TestsPanel-local state; bump the revision so an open + // panel knows to refetch. + boardStore.markTestsChanged(); + return; case 'error': console.warn('[foldo-ws] server error', msg); return; case 'pong': return; + default: { + // Exhaustiveness: a new ServerMessage type without a branch here is a + // typecheck error, not a silently-dropped broadcast. + const _exhaustive: never = msg; + void _exhaustive; + return; + } } } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 7af9696..fef8120 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string; readonly VITE_WS_URL?: string; readonly VITE_SAMPLE_URL?: string; + readonly VITE_SHOTTER_URL?: string; } interface ImportMeta { diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 22b9300..74a41f4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -17,8 +17,8 @@ The CLI examples assume Railway CLI v4.57+. Install with Today's production deploy is one Railway project (`foldo`) with three HTTP services (`server`, `web`, `sample-app`), a Postgres plugin, and a -fourth service (`shotter`) defined in `railway.json` but **not deployed -right now** — see §3.5. +fourth service (`shotter`) with config in the repo +(`apps/shotter/railway.json`) but **not deployed right now** — see §3.5. ``` ┌──────────────────────────────────┐ @@ -52,7 +52,7 @@ right now** — see §3.5. └──────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────┐ - │ shotter (defined in railway.json, NOT deployed) │ + │ shotter (config in repo, NOT deployed) │ │ Playwright/Chromium screenshot service │ │ Optional fallback for /c/ when iframing is │ │ blocked by X-Frame-Options. See §3.5. │ @@ -157,7 +157,7 @@ before `npm run build`).
| Var | When | Req | Prod value pattern | Notes | |----------------------|------|-----|----------------------------------|-------| -| `PORT` | RT | yes | injected by Railway | `serve` binds it via `--listen tcp://0.0.0.0:$PORT` (see `railway.json:32`). | +| `PORT` | RT | yes | injected by Railway | `serve` binds it via `--listen tcp://0.0.0.0:$PORT` (Dockerfile CMD). | | `VITE_API_URL` | BT | yes | `https://api.foldo.dev` | Inlined; baked into the bundle. | | `VITE_WS_URL` | BT | no | `wss://api.foldo.dev` | Defaults from `VITE_API_URL` if unset. | | `VITE_SAMPLE_URL` | BT | yes | `https://sample.foldo.dev` | Iframed previews. | @@ -222,16 +222,16 @@ service in §3.4. ### 3.3 Create the three application services Each service is a separate Railway service that builds from this same -repo, pointed at a different Dockerfile. The Dockerfile paths live in -`railway.json`. +repo, pointed at a different Dockerfile via its own config file +(Railway config-as-code is service-scoped — see §9). ```bash # Repeat for: server, web, sample-app # Dashboard → +New → GitHub Repo → lukataylo/foldo → "Add a service" -# For "server": set Service Name = `server`. Railway picks up -# apps/server/Dockerfile via railway.json. -# For "web": Service Name = `web`. Dockerfile = apps/web/Dockerfile. -# For "sample-app":Service Name = `sample-app`. Dockerfile = apps/sample-app/Dockerfile. +# For each service, set Settings → Config file path: +# "server": apps/server/railway.json +# "web": apps/web/railway.json +# "sample-app": apps/sample-app/railway.json ``` Confirm via: @@ -272,7 +272,8 @@ Apply the full §2 matrix per service. Run `railway variables --service ### 3.5 (Optional) Deploy the shotter service -The shotter is defined in `railway.json` but **not deployed today**. +The shotter has config in the repo (`apps/shotter/railway.json`) but is +**not deployed today**. Turn it on only if iframing breaks for a target user (X-Frame-Options or CSP `frame-ancestors`). Symptoms: the canvas shows the `/c/` fallback UI permanently empty. @@ -750,34 +751,49 @@ Key dashboards to build: ## 9. `railway.json` — what's in / what's out -The file at the repo root is the source of truth for service shape. -A summary of what each block does: - -- `build.builder = NIXPACKS` — project-level default; each service - overrides to `DOCKERFILE` because we want the workspace install - layer cached precisely. +Railway's config-as-code is **service-scoped**: one config file applies +to one service, and the schema +(https://railway.app/railway.schema.json) allows only `build`, +`deploy`, and `environments` at the root — there is no multi-service +`services` map. (The repo used to carry a single root `railway.json` +with a `services` block; Railway silently ignored it, so every service +actually built with root-level Nixpacks instead of its Dockerfile.) + +The config now lives next to each app, and each Railway service must +point its **Settings → Config file path** at its own file: + +| Railway service | Config file path | +| --------------- | -------------------------- | +| `server` | `apps/server/railway.json` | +| `web` | `apps/web/railway.json` | +| `sample-app` | `apps/sample-app/railway.json` | +| `shotter` | `apps/shotter/railway.json` (optional, see §3.5) | + +What each file encodes: + +- `build.builder = DOCKERFILE` + `build.dockerfilePath` — every + service builds from its Dockerfile (build context = repo root) so + the workspace install layer is cached precisely. - `deploy.restartPolicyType = ON_FAILURE`, `restartPolicyMaxRetries = 10` - — Railway restarts a crashed container up to 10× before backing - off. Matches the per-service overrides. -- `services.server.deploy.healthcheckPath = /health`, - `healthcheckTimeout = 30` — Railway probes this and only routes - traffic to a deployment once `/health` returns 200. -- `services.web.deploy.startCommand` — explicit because the - Dockerfile CMD uses a shell form that needs `$PORT` expansion at - container start. As of the A+ W1 ops slice (2026-05), the web and - sample-app services serve their `dist/` via the `serve` package - (https://www.npmjs.com/package/serve) instead of `vite preview`. - Reasons: gzip/brotli on text assets, range requests, and a sane - cache-header policy driven by `apps//serve.json` (long-lived - `immutable` for content-hashed JS/CSS, `no-cache` for `index.html` - so a new deploy is picked up on the next page load). `--single` - enables SPA history-mode fallback. The container's PATH includes - `/app/node_modules/.bin` so `serve` resolves to the hoisted CLI shim - with no extra global install. -- `services.shotter` — **defined but not deployed today**. See §3.5 - for when to turn it on. The block is kept in `railway.json` so the - config travels with the repo and the second-engineer onboarding - doesn't have to invent it. + — Railway restarts a crashed container up to 10× before backing off. +- `server`/`shotter`: `deploy.healthcheckPath = /health` — Railway + probes this and only routes traffic to a deployment once `/health` + returns 200. +- **No `startCommand` anywhere** — the Dockerfile `CMD` is the single + source of truth (an earlier `startCommand` override fought the CMD + and broke `serve` resolution; see PR #22). As of the A+ W1 ops slice + (2026-05), the web and sample-app services serve their `dist/` via + the `serve` package (https://www.npmjs.com/package/serve) instead of + `vite preview`. Reasons: gzip/brotli on text assets, range requests, + and a sane cache-header policy driven by `apps//serve.json` + (long-lived `immutable` for content-hashed `assets/`, short-lived + for unhashed static files, `no-cache` for `index.html` so a new + deploy is picked up on the next page load). `--single` enables SPA + history-mode fallback. `serve` is installed globally in the runtime + image. +- `shotter` — **defined but not deployed today**. See §3.5 for when to + turn it on. The config travels with the repo so the second-engineer + onboarding doesn't have to invent it. --- diff --git a/docs/RAILWAY_READINESS.md b/docs/RAILWAY_READINESS.md index 985b9af..f7de5db 100644 --- a/docs/RAILWAY_READINESS.md +++ b/docs/RAILWAY_READINESS.md @@ -181,7 +181,8 @@ Already documented in `docs/DEPLOYMENT.md` §11. traffic. - Health endpoint exists at `/health` (`apps/server/src/index.ts:64`) and the server Dockerfile uses it for `HEALTHCHECK`. Railway's - `healthcheckPath: /health` is wired in `railway.json`. + `healthcheckPath: /health` is wired in `apps/server/railway.json` + (config-as-code is service-scoped; each service has its own file). - The web and sample-app services have no health endpoint per se — their Dockerfile healthchecks do a `fetch('/')` against the running preview server, which is sufficient. diff --git a/docs/USER-ACTIONS-REQUIRED.md b/docs/USER-ACTIONS-REQUIRED.md index fb24c85..a675506 100644 --- a/docs/USER-ACTIONS-REQUIRED.md +++ b/docs/USER-ACTIONS-REQUIRED.md @@ -9,6 +9,37 @@ code waiting on these actions. --- +## P0 — deploy correctness + +### 0. Point each Railway service at its per-app railway.json + +**Why:** Railway config-as-code is service-scoped — the old root +`railway.json` used a `services` map the schema doesn't support, so +Railway was ignoring it entirely (services built with root Nixpacks, +no `/health` deploy gate). The config now lives at +`apps//railway.json`, but Railway only reads those files once each +service's **Settings → Config-as-code → Config file path** points at +its own file. Until that's done, services deploy with dashboard +defaults only. + +**How:** in the Railway dashboard, for each service set the config +file path: + +| Service | Config file path | +| ------------ | ------------------------------ | +| `server` | `apps/server/railway.json` | +| `web` | `apps/web/railway.json` | +| `sample-app` | `apps/sample-app/railway.json` | +| `shotter` | `apps/shotter/railway.json` (when/if deployed) | + +Then redeploy each service once. + +**Done when:** each service's build log shows it using its Dockerfile +(`apps//Dockerfile`), and the server's deployment waits on the +`/health` healthcheck before traffic switches over. + +--- + ## P0 — security ### 1. Rotate the Resend API key diff --git a/e2e/deploy/prod-smoke.spec.ts b/e2e/deploy/prod-smoke.spec.ts index 85b078e..41ae2d3 100644 --- a/e2e/deploy/prod-smoke.spec.ts +++ b/e2e/deploy/prod-smoke.spec.ts @@ -18,7 +18,10 @@ import { expect, test } from '@playwright/test'; const SHOULD_RUN = process.env.RUN_PROD_SMOKE === '1'; -const BASE = (process.env.FOLDO_PROD_BASE ?? 'https://api.foldo.dev').replace(/\/+$/, ''); +// `||` not `??`: the post-deploy workflow exports FOLDO_PROD_BASE='' when the +// dispatch payload carries no base_url, and an empty string must still fall +// back to the default (an empty BASE makes every request an invalid URL). +const BASE = (process.env.FOLDO_PROD_BASE || 'https://api.foldo.dev').replace(/\/+$/, ''); const TOKEN = process.env.FOLDO_PROD_SMOKE_TOKEN ?? ''; // `test.describe.skip` when the gate is off — the spec still appears in diff --git a/package-lock.json b/package-lock.json index 9a0fd92..447a507 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,7 +111,7 @@ "version": "0.0.1", "dependencies": { "fastify": "^5.1.0", - "playwright-core": "^1.58.0", + "playwright-core": "1.58.0", "tsx": "^4.19.2" }, "devDependencies": { @@ -119,6 +119,18 @@ "typescript": "^5.6.3" } }, + "apps/shotter/node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "apps/web": { "name": "@foldo/web", "version": "0.0.1", @@ -6236,6 +6248,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, "bin": { "playwright-core": "cli.js" }, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a56f3d8..0f99323 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -164,6 +164,18 @@ export class PluginRegistry { private readonly plugins: Plugin[] = []; install(plugin: Plugin): void { + // Idempotent by manifest id: a second bootPlugins(...) (dev HMR + // re-evaluating apps/web while this module instance survives) must not + // double every surface contribution. REPLACE rather than skip so the + // re-evaluated module's fresh render/activate closures win — keeping the + // old instance would pin surfaces to disposed module state under HMR. + const existing = this.plugins.findIndex( + (p) => p.manifest.id === plugin.manifest.id, + ); + if (existing >= 0) { + this.plugins[existing] = plugin; + return; + } this.plugins.push(plugin); } diff --git a/railway.json b/railway.json deleted file mode 100644 index 4dc93c3..0000000 --- a/railway.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "https://railway.app/railway.schema.json", - "//": "Multi-service Foldo deploy. Each Railway service should be created from this repo and pointed at one of the entries in `services`. Persistence is Postgres (provisioned as a Railway plugin) — wire its DATABASE_URL into the `server` service. End-to-end runbook (env-var matrix, first-deploy steps, rollback, backups, observability) lives in docs/DEPLOYMENT.md.", - "build": { - "builder": "NIXPACKS" - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 10 - }, - "services": { - "server": { - "//": "Foldo API + WebSocket gateway. Required env vars: DATABASE_URL (from the linked Postgres plugin), PORT (provided by Railway), FOLDO_WEB_ORIGIN=https://foldo.dev,https://sample.foldo.dev, FOLDO_SAMPLE_APP_URL=https://sample.foldo.dev, LOG_LEVEL=info.", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/server/Dockerfile" - }, - "deploy": { - "//": "Horizontal scaling: to run with replicas>1, provision a Railway Redis plugin and set REDIS_URL=${{Redis.REDIS_URL}} on this service. Without REDIS_URL the server boots the in-memory WS hub and MUST stay at replicas=1 (broadcasts won't fan out across instances; clients on different replicas will desync). See docs/DEPLOYMENT.md §1 + §1.1. The boot log line `[ws] hub=…` tells you which backend won at startup. No startCommand — the server runs straight from TS via tsx, so it uses the Dockerfile CMD (`npm start` → tsx src/index.ts). There is no `dist/` build step.", - "healthcheckPath": "/health", - "healthcheckTimeout": 30, - "restartPolicyType": "ON_FAILURE" - } - }, - "web": { - "//": "Canvas + marketing surface (Vite/React) served via the production-grade `serve` static server (gzip + immutable hashed-asset caching, no-cache index.html). BUILD-time env vars: VITE_API_URL=https://api.foldo.dev, VITE_WS_URL=wss://api.foldo.dev, VITE_SAMPLE_URL=https://sample.foldo.dev. PORT is provided by Railway at runtime. Cache headers come from apps/web/serve.json (copied into dist/ by the Dockerfile).", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/web/Dockerfile" - }, - "deploy": { - "//": "No startCommand override — the Dockerfile CMD (sh -c 'serve apps/web/dist …') is the single source of truth. An earlier `npx serve …` override here failed in Railway because `npx`'s resolution couldn't find the hoisted node_modules/.bin/serve reliably. The Dockerfile CMD puts /app/node_modules/.bin on PATH and uses sh -c so the shell resolves serve correctly.", - "restartPolicyType": "ON_FAILURE" - } - }, - "sample-app": { - "//": "Pricing demo app — iframed by the canvas. Served via `serve` (same prod-grade static server as `web`). BUILD-time env: VITE_PARENT_ORIGIN=https://foldo.dev. Cache headers come from apps/sample-app/serve.json.", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/sample-app/Dockerfile" - }, - "deploy": { - "//": "No startCommand override — see the `web` service note above. Dockerfile CMD is the single source of truth.", - "restartPolicyType": "ON_FAILURE" - } - }, - "shotter": { - "//": "Headless Chromium screenshot service. Powers the /c/ fallback when iframing is blocked by X-Frame-Options. OPTIONAL and NOT DEPLOYED TODAY — only turn this on if the canvas's `Capture from URL` modal is silently failing for a target host (X-Frame-Options or CSP frame-ancestors blocking the iframe). docs/DEPLOYMENT.md §3.5 covers the activation steps end-to-end. Env vars: PORT (set automatically), FOLDO_SHOT_SECRET (optional bearer gate; if set, mirror the same value on `server` and set VITE_SHOTTER_URL on `web`).", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/shotter/Dockerfile" - }, - "deploy": { - "startCommand": "npm --workspace @foldo/shotter run start", - "healthcheckPath": "/health", - "restartPolicyType": "ON_FAILURE" - } - } - } -}