initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Deploy the computer stack to a target server of your choice.
#
# Run from anywhere in the repository on your workstation. The deployment
# target is explicit — there are no built-in host or domain defaults:
#
# HOST=app-server DOMAIN=computer.example.tld CERTBOT_EMAIL=ops@example.tld \
# ./scripts/deploy.sh (or: make deploy with the same variables)
#
# Flow: record the exact git revision, rsync the source tree to a UNIQUE
# staging directory on the server, then run scripts/server-deploy.sh there
# under sudo (one password prompt). The server script takes a deployment
# lock (flock) BEFORE publishing the staged tree into the shared release
# directory, provisions packages, backs up the database, builds the Compose
# stack (images are tagged with the revision), configures nginx + Let's
# Encrypt from deploy/nginx/computer.conf.template, and records the revision
# marker only after everything passed (review CR-11: overlapping deploys can
# no longer mix source and release state — synchronization targets are
# per-release, mutation of the shared directory is serialized, and the last
# successful marker survives failures).
#
# Review OPS-04: a release is a known revision — dirty working trees are
# refused unless ALLOW_DIRTY=1 acknowledges them, the revision is recorded on
# the server (.deployed-revision) and the built images are tagged with it.
# Rollback: re-deploy an older revision (git checkout <rev> && ./scripts/deploy.sh,
# which reuses/rebuilds that known source), or restore one of the last 15
# pre-deploy pg_dumps (+ its .env secret snapshot) from /var/backups/computer
# on the server. Database migrations are additive.
#
# Requirements: ssh access to HOST (an ssh config alias works well), rsync and
# git locally. DNS for DOMAIN must already point at the server (certbot
# HTTP-01). Optional overrides: REMOTE_DIR (default /var/www/computer),
# ALLOW_DIRTY=1.
set -euo pipefail
usage="usage: HOST=ssh-host DOMAIN=app.domain.tld CERTBOT_EMAIL=ops@domain.tld ./scripts/deploy.sh"
HOST="${HOST:?${usage}}"
DOMAIN="${DOMAIN:?${usage}}"
CERTBOT_EMAIL="${CERTBOT_EMAIL:?${usage}}"
REMOTE_DIR="${REMOTE_DIR:-/var/www/computer}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# --- 0. Record the revision being deployed (review OPS-04) ----------------------
REV="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null)" || die "git is required to deploy a known revision"
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
[ "${ALLOW_DIRTY:-0}" = "1" ] || die "working tree has uncommitted changes; commit or stash them so the release is a known revision (ALLOW_DIRTY=1 overrides)"
REV="$REV-dirty"
log "warning: deploying a DIRTY working tree as $REV"
fi
log "Deploying revision $REV"
REMOTE_USER="$(ssh -G "$HOST" | awk '/^user /{print $2; exit}')"
[ -n "$REMOTE_USER" ] || die "could not determine ssh user for $HOST"
command -v rsync >/dev/null 2>&1 || die "rsync is required locally (e.g. sudo apt install rsync / sudo pacman -S rsync)"
# --- 1. Prepare the remote directories (may prompt for your sudo password) ------
# The release directory is shared and mutable; this deploy's staging directory
# is unique (rev + local pid), so overlapping rsyncs never touch shared
# release inputs (review CR-11).
STAGING_ROOT="$REMOTE_DIR-staging"
STAGE="$STAGING_ROOT/$REV-$$"
log "Ensuring $HOST:$REMOTE_DIR and $STAGING_ROOT exist and belong to $REMOTE_USER"
ssh -t "$HOST" "sudo sh -c 'mkdir -p \"$REMOTE_DIR\" \"$STAGING_ROOT\" && chown \"$REMOTE_USER:\" \"$REMOTE_DIR\" \"$STAGING_ROOT\"'"
# --- 2. Sync the source tree to this deploy's staging directory ----------------
log "Syncing repository to $HOST:$STAGE"
# --delete mirrors the repo into the (empty) stage; excluded files (a local
# .env, build output) never ship. The server-side publish step protects the
# server's own .env and release marker.
rsync -az --delete \
--exclude '.git' \
--exclude '.env' \
--exclude '.env.*' \
--exclude '.deployed-revision' \
--exclude 'node_modules' \
--exclude 'web/.next' \
--exclude 'web/out' \
--exclude 'web/*.tsbuildinfo' \
--exclude '*.log' \
"$REPO_ROOT/" "$HOST:$STAGE/"
# --- 3. Run the server-side deploy as root (may prompt for your sudo password) --
# The STAGED copy of the script runs (not the old one in $REMOTE_DIR): it
# takes the server-side deployment lock, publishes the stage into the shared
# release directory, and records .deployed-revision only after everything
# passed — a failed deploy preserves the last successful release marker
# (review CR-11/OPS-04).
log "Running server-side deploy (lock + publish + backup + build + nginx + TLS)"
ssh -t "$HOST" "sudo bash '$STAGE/scripts/server-deploy.sh' '$REMOTE_DIR' '$DOMAIN' '$CERTBOT_EMAIL' '$REMOTE_USER' '$REV' '$STAGE'"
# --- 4. Final check from this machine ---------------------------------------------
log "Verifying https://$DOMAIN/login from this machine"
if curl -fsS -o /dev/null --max-time 15 "https://$DOMAIN/login"; then
log "Deploy complete: https://$DOMAIN (revision $REV)"
else
cat >&2 <<EOF
warning: https://$DOMAIN/login is not reachable from this machine even though
the server-side checks passed. This usually means DNS for $DOMAIN does not
point at the server from here, or a firewall blocks 80/443. Verify with:
dig +short $DOMAIN
EOF
exit 1
fi
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { chromium } from 'playwright'
// Run with the Docker stack up. Only uniquely created QA accounts are cleaned
// up; existing users, bookmarks and database tables are never reset.
const base = process.env.BASE_URL || 'http://localhost:3000'
// Review TEST-03: the cleanup path deletes accounts through the LOCAL compose
// database, so the runner must prove the browser target is that same stack
// before creating anything. Non-loopback targets are refused unless the
// operator explicitly acknowledges (E2E_ALLOW_REMOTE=1) that no cleanup is
// possible and accounts will persist (E2E_KEEP_DATA=1).
const repoRoot = new URL('..', import.meta.url)
const baseUrl = new URL(base)
const loopback = ['localhost', '127.0.0.1', '::1'].includes(baseUrl.hostname)
const keepData = process.env.E2E_KEEP_DATA === '1'
const localDbRunning = () => {
try {
return execFileSync('docker', ['compose', 'ps', '--status', 'running', '-q', 'db'], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== ''
} catch { return false }
}
if (!loopback) {
if (process.env.E2E_ALLOW_REMOTE !== '1') {
console.error(`Refusing to run against non-local target ${base}: cleanup is tied to the local compose database. Use the local stack, or set E2E_ALLOW_REMOTE=1 together with E2E_KEEP_DATA=1 to accept that QA accounts will persist on the remote target.`)
process.exit(1)
}
if (!keepData) {
console.error(`Remote target ${base} cannot be cleaned up through local docker; set E2E_KEEP_DATA=1 to acknowledge created accounts will persist.`)
process.exit(1)
}
}
if (!keepData && !localDbRunning()) {
console.error('Cleanup requires the local compose db service to be running (docker compose up -d db); refusing to create QA accounts that could not be removed.')
process.exit(1)
}
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] })
const accounts = []
const errors = []
let testFailed = false
const password = 'qa-test-long-password'
const email = `qa-${Date.now()}@computer.test`
const context = await browser.newContext()
const page = await context.newPage()
page.on('pageerror', error => errors.push(error.message))
async function json(response, status = 200) {
assert.equal(response.status(), status, `${response.url()}: ${await response.text()}`)
return response.json()
}
try {
// Review CR-03: every accepted registration answers with the SAME uniform
// {ok:true} body — the account id is private until authentication
// establishes ownership, so it comes from /api/me after login.
const signup = await json(await context.request.post(`${base}/api/signup`, { data: { email, password, name: 'Browser QA' } }), 201)
assert.deepEqual(signup, { ok: true })
await page.goto(`${base}/login`)
await page.getByPlaceholder('Email', { exact: true }).fill(email)
await page.getByPlaceholder('Password', { exact: true }).fill(password)
await page.getByRole('button', { name: 'Sign in with Email', exact: true }).click()
await page.waitForURL('**/pages')
await page.getByRole('heading', { name: 'No pages yet', exact: true }).waitFor()
const me = await json(await context.request.get(`${base}/api/me`))
accounts.push(me.user.id)
// Target/cleanup identity preflight (review TEST-03): the account created
// through BASE_URL must exist in the local compose database that cleanup
// deletes from. If it does not, the browser target is backed by some other
// database — abort before creating further data.
if (!keepData && loopback) {
assert.match(me.user.id, /^[a-z0-9]+$/i)
const found = execFileSync('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'computer', '-d', 'computer', '-Atc',
`SELECT count(*) FROM "User" WHERE id='${me.user.id}';`], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
if (found !== '1') {
throw new Error(`preflight failed: QA account ${me.user.id} created through ${base} is not present in the local compose database; target and cleanup environments differ (remove it on the target environment)`)
}
console.log('PASS: browser target and cleanup database are the same stack')
}
await page.getByRole('button', { name: 'New', exact: true }).first().click()
const dialog = page.getByRole('dialog')
await dialog.getByPlaceholder('https://...').fill('https://example.org/browser-qa')
await dialog.getByPlaceholder('Auto-filled from URL').fill('Browser QA bookmark')
// Wait for the create to land server-side; navigating earlier cancels the
// in-flight POST (Go fetches the preview before inserting, so a cancelled
// request context aborts creation).
const saved = page.waitForResponse(r => r.url().includes('/api/bookmarks') && r.request().method() === 'POST' && r.status() === 201)
await dialog.getByRole('button', { name: 'Save Bookmark', exact: true }).click()
await saved
await page.goto(`${base}/bookmarks`)
await page.getByText('Browser QA bookmark', { exact: true }).first().waitFor()
let bookmarks = await json(await context.request.get(`${base}/api/bookmarks`))
const bookmark = bookmarks.find(b => b.title === 'Browser QA bookmark')
assert.ok(bookmark?.id, 'UI-created bookmark was not persisted by Go')
await page.getByText('Browser QA bookmark', { exact: true }).first().click()
await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor()
// Optimistic concurrency (review CR-04): the PUT carries the version it edited from.
const update = await json(await context.request.put(`${base}/api/bookmarks/${bookmark.id}`, { data: { notes: '# Browser verified', isPublic: true, version: bookmark.version } }))
assert.equal(update.notes, '# Browser verified')
// Notion-style Quick Find (Cmd/Ctrl+K): open, dismiss, then jump straight
// to this bookmark's detail sheet through the ?open= deep link.
await page.keyboard.press('Escape')
await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor({ state: 'hidden' })
await page.keyboard.press('Control+k')
const quickFind = page.getByPlaceholder('Search pages, bookmarks, tags, and commands…')
await quickFind.waitFor()
await page.keyboard.press('Escape')
await quickFind.waitFor({ state: 'hidden' })
await page.keyboard.press('Control+k')
await quickFind.waitFor()
await quickFind.fill('Browser QA')
await page.getByRole('option', { name: /Browser QA bookmark/ }).first().click()
await page.waitForURL('**/bookmarks?open=*')
await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor()
console.log('PASS: Quick Find opens with Ctrl+K and deep-links to the bookmark sheet')
const publicContext = await browser.newContext()
const publicPage = await publicContext.newPage()
publicPage.on('pageerror', error => errors.push(error.message))
await publicPage.goto(`${base}/share/bookmark/${bookmark.id}`)
await publicPage.getByText('Browser QA bookmark', { exact: true }).first().waitFor()
// Privacy field assertions (review PRIV-04): the public API response is an
// allowlist — no owner attribution, no contact/recipient/operational data.
const publicBody = await json(await publicContext.request.get(`${base}/api/public/bookmarks/${bookmark.id}`))
assert.equal(publicBody.title, 'Browser QA bookmark')
assert.equal('# Browser verified', publicBody.notes, 'public link must publish notes (documented behavior)')
for (const key of ['owner', 'ownerId', 'contact', 'contactId', 'sharedWith', 'imapQuery', 'imapWatchEnabled', 'alertAt', 'alertSent', 'kanbanColumnId', 'kanbanPosition', 'kanbanStatus', 'pageLinks']) {
assert.equal(key in publicBody, false, `public response must not include ${key}`)
}
console.log('PASS: public response matches the documented privacy policy (no owner attribution)')
await publicContext.close()
await page.goto(`${base}/kanban`)
await page.getByRole('heading', { name: 'Kanban Board' }).waitFor()
await page.getByText('Browser QA bookmark', { exact: true }).waitFor()
const columns = await json(await context.request.get(`${base}/api/kanban/columns`))
assert.equal(columns.length, 4)
const custom = await json(await context.request.post(`${base}/api/kanban/columns`, { data: { label: 'QA done' } }), 201)
await json(await context.request.put(`${base}/api/kanban/cards/${bookmark.id}`, { data: { columnId: custom.id, position: 0, version: update.version } }))
const moved = await json(await context.request.delete(`${base}/api/kanban/columns/${custom.id}`))
assert.equal(moved.moved, 1)
await page.goto(`${base}/pages`)
await page.locator('main').getByRole('button', { name: 'New page', exact: true }).click()
await page.getByRole('textbox', { name: 'Page title' }).waitFor()
await page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.').fill('# QA page\n\nAutosaved markdown.')
await page.getByText('Saved', { exact: true }).waitFor()
const pages = await json(await context.request.get(`${base}/api/pages`))
const detail = await json(await context.request.get(`${base}/api/pages/${pages[0].id}`))
assert.equal(detail.content, '# QA page\n\nAutosaved markdown.')
// Notion-style slash menu: "/h2" at the start of a line inserts a heading.
const editor = page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.')
await editor.click()
await page.keyboard.press('Control+End')
await page.keyboard.press('Enter')
await page.keyboard.type('/h2')
await page.getByRole('option', { name: /Heading 2/ }).click()
await editor.evaluate((el, suffix) => new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`slash command did not insert heading: ${JSON.stringify(el.value)}`)), 5000)
const check = setInterval(() => { if (el.value.endsWith(suffix)) { clearTimeout(timeout); clearInterval(check); resolve() } }, 25)
}), '\n## ')
console.log('PASS: slash command menu inserts Markdown blocks')
await json(await context.request.patch(`${base}/api/pages/${pages[0].id}`, { data: { addBookmarkId: bookmark.id } }))
const markdown = await context.request.get(`${base}/api/pages/${pages[0].id}/markdown`)
assert.equal(markdown.status(), 200)
assert.ok((await markdown.text()).includes('Browser QA bookmark'))
// Collections (Notion-like upgrades phase 2, validation scenario): an
// existing bookmark joins a collection without duplication, gets a
// collection-scoped status, and the same membership is driven from both
// the table view and a board view.
await page.goto(`${base}/collections`)
await page.getByRole('heading', { name: 'Collections', exact: true }).waitFor()
await page.getByLabel('New collection name').fill('Research')
await page.getByRole('button', { name: 'Create collection' }).click()
await page.getByText('Collection created').waitFor()
await page.getByRole('button', { name: 'Add bookmarks' }).click()
await page.getByLabel('Search bookmarks').fill('Browser QA')
await page.getByRole('button', { name: 'Add', exact: true }).first().click()
await page.keyboard.press('Escape')
await page.getByText('Browser QA bookmark', { exact: true }).first().waitFor()
await page.getByRole('button', { name: 'Property' }).click()
await page.getByLabel('Property name').fill('Status')
await page.getByRole('button', { name: 'Option', exact: true }).click()
await page.getByLabel('Option label').first().fill('Backlog')
await page.getByRole('button', { name: 'Option', exact: true }).click()
await page.getByLabel('Option label').nth(1).fill('Doing')
await page.getByRole('button', { name: 'Create property' }).click()
await page.getByText('Property added').waitFor()
await page.getByLabel('Status', { exact: true }).first().click()
await page.getByRole('option', { name: 'Doing' }).click()
await page.getByRole('button', { name: 'Add view' }).click()
await page.getByLabel('View name').fill('Board')
await page.getByLabel('View type').click()
await page.getByRole('option', { name: 'Board' }).click()
await page.getByRole('button', { name: 'Create view' }).click()
await page.getByText('View added').waitFor()
await page.getByLabel('Group by property').click()
await page.getByRole('option', { name: 'Status' }).click()
await page.getByLabel('Move Browser QA bookmark').getByText('Doing').waitFor()
// Moving the card on the board and reading it back in the table proves both
// views edit one membership, not copies.
const boardCard = page.locator('[data-collection-item-id]').filter({ hasText: 'Browser QA bookmark' })
const backlogColumn = page.locator('[data-board-group]').filter({ hasText: 'Backlog' })
const movedMembership = page.waitForResponse(r => r.url().includes('/items/') && r.request().method() === 'PATCH' && r.status() === 200)
await boardCard.locator('[data-drag-handle]').dragTo(backlogColumn)
await movedMembership
await page.getByRole('button', { name: 'Table', exact: true }).click()
await page.getByLabel('Status', { exact: true }).first().getByText('Backlog').waitFor()
const membership = await json(await context.request.get(`${base}/api/collections`))
assert.equal(membership[0]._count.items, 1, 'bookmark count after membership add')
console.log('PASS: collections — membership without duplication, typed properties, table+board over one membership')
for (const [path, heading] of [['bookmarks', 'All Bookmarks'], ['tags', 'Tags'], ['calendar', 'Calendar'], ['collections', 'Collections'], ['contacts', 'Contacts'], ['settings', 'Settings']]) {
await page.goto(`${base}/${path}`)
await page.getByRole('heading', { name: heading, exact: true }).first().waitFor()
}
assert.deepEqual(errors, [], 'Browser runtime errors')
await json(await context.request.delete(`${base}/api/bookmarks/${bookmark.id}`))
console.log('PASS: browser login, Go-backed bookmark create/edit/delete, public sharing, quick find deep links, kanban, collections, pages/autosave/slash-commands/export, all application screens')
} catch (error) {
testFailed = true
throw error
} finally {
await browser.close()
if (!keepData && accounts.length) {
// Cleanup failures must not mask the real test error; log them and fail
// the run only when the tests themselves passed.
try {
assert.ok(accounts.every(id => /^[a-z0-9]+$/i.test(id)))
execFileSync('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'computer', '-d', 'computer', '-v', 'ON_ERROR_STOP=1', '-c', `DELETE FROM "User" WHERE id IN (${accounts.map(id => `'${id}'`).join(',')});`], { cwd: repoRoot, stdio: 'inherit' })
} catch (cleanupError) {
console.error(`QA account cleanup failed (${accounts.join(',')}); delete manually.`, cleanupError.message)
if (!testFailed) process.exitCode = 1
}
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"name": "scripts",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"playwright": "1.58.2"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"private": true,
"type": "module",
"scripts": { "test": "node e2e.mjs", "test:regressions": "node regressions.mjs" },
"devDependencies": { "playwright": "1.58.2" }
}
+500
View File
@@ -0,0 +1,500 @@
import assert from 'node:assert/strict'
import http from 'node:http'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
// The real built Next server/UI with isolated API fixtures: no app users or DB writes.
const pages = new Map(['a', 'b'].map((id, position) => [id, { id, title: `Page ${id.toUpperCase()}`, content: '', version: 1, position, pinned: false, bookmarks: [] }]))
const tags = [{ id: 'one', name: 'First tag', color: '#123456' }, { id: 'two', name: 'Second tag', color: '#654321' }]
const kanbanColumns = [
{ id: 'inbox', label: 'Inbox', color: '#123456', key: 'INBOX', position: 0 },
{ id: 'todo', label: 'To Do', color: '#abcdef', key: 'TODO', position: 1 },
]
const bookmarks = Array.from({ length: 205 }, (_, i) => ({
id: `bookmark-${i}`, ownerId: 'fixture-user', title: `Fixture ${String(i).padStart(3, '0')}`, url: 'https://127.0.0.1/test',
description: '', notes: '', tags: [{ tag: tags[0] }], sharedWith: [], kanbanStatus: 'INBOX', kanbanColumnId: 'inbox', kanbanPosition: i,
isPublic: false, imapWatchEnabled: false, alertAt: null, dueDate: null, version: 0, createdAt: '2026-10-07T00:00:00Z', updatedAt: '2026-10-07T00:00:00Z',
}))
let failSaves = false
// Review CR-02: forces Go's signout endpoint to fail so the browser test can
// prove a revocation failure is reported accurately and local sign-out still
// completes.
let failSignout = false
// Fails only saves whose body.content matches (deterministic mid-loop
// failure for the DATA-07 envelope test — no racing the serial queue).
let failContent = null
// Mirrors the proxy's streaming body cap (web/app/api/[...path]/route.ts).
const MAX_PROXY_BODY = 1 << 20
// Records Authorization headers of POST /api/auth/signout so the browser
// sign-out test can prove the Next server reached Go's revocation endpoint.
const signouts = []
let delaySaves = 0
let activeSaves = 0
let maxConcurrentSaves = 0
let paginatedRequests = 0
// Board moves are recorded so the browser test can prove a drag reached the
// API with the card's expected version/position.
const kanbanMoves = []
// Counts every PATCH /api/pages/* attempt (including 409/500 rejections) so
// tests can prove a paused conflict issues no further saves (review TEST-01).
let patchAttempts = 0
const api = http.createServer(async (req, res) => {
try {
let raw = ''
for await (const chunk of req) raw += chunk
const body = raw ? JSON.parse(raw) : {}
const url = new URL(req.url, 'http://fixture')
let result = []
if (url.pathname === '/api/auth/login') result = { user: { id: 'fixture-user', name: 'Fixture', email: body.email, role: 'USER' }, tokenVersion: 1 }
else if (url.pathname === '/api/auth/signout' && req.method === 'POST') {
signouts.push(req.headers.authorization || '')
if (failSignout) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture revocation failed"}'); return }
result = { ok: true }
}
else if (url.pathname === '/api/me') result = { user: { id: 'fixture-user' }, effectiveUser: { id: 'fixture-user' }, isAdmin: false, viewingAs: null }
// The list endpoint is a metadata projection: content bodies stay out (PERF-01).
else if (url.pathname === '/api/pages') {
if (req.method === 'PUT') {
for (const [position, id] of (body.order || []).entries()) {
const page = pages.get(id)
if (page) page.position = position
}
result = { ok: true }
} else {
result = [...pages.values()].sort((a, b) => a.position - b.position).map(({ content, ...summary }) => summary)
}
}
else if (url.pathname.startsWith('/api/pages/')) {
const id = url.pathname.split('/')[3]
if (req.method === 'PATCH') {
patchAttempts++
if (failSaves) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture save failed"}'); return }
if (failContent !== null && body.content === failContent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture save failed"}'); return }
const target = pages.get(id)
// Optimistic concurrency (DATA-01): a stale base version is a 409.
if (body.version !== undefined && body.version !== target.version) {
res.writeHead(409, { 'Content-Type': 'application/json' })
res.end('{"error":"Page was modified elsewhere; reload to see the latest version"}')
return
}
activeSaves++
maxConcurrentSaves = Math.max(maxConcurrentSaves, activeSaves)
await new Promise(resolve => setTimeout(resolve, delaySaves))
const { version, ...data } = body
Object.assign(target, data)
if ('content' in body) target.version++
activeSaves--
}
result = pages.get(id)
} else if (url.pathname === '/api/bookmarks') {
const shared = url.searchParams.get('shared') === 'true'
const matching = shared ? [] : bookmarks.filter(b => b.title.includes(url.searchParams.get('search') || ''))
const offset = Number(url.searchParams.get('cursor') || 0)
const limit = Number(url.searchParams.get('limit') || 200)
// Mirror the Go permission-specific projections (PRIV-02): shared
// readers get the narrowed allowlist shape, owners the full row.
const project = b => shared ? (({ contact, contactId, sharedWith, alertAt, alertSent, imapQuery, imapWatchEnabled, kanbanColumnId, kanbanPosition, ...rest }) => rest)(b) : b
result = matching.slice(offset, offset + limit).map(project)
if (offset + limit < matching.length) res.setHeader('X-Next-Cursor', String(offset + limit))
if (offset) paginatedRequests++
} else if (url.pathname === '/api/tags') result = tags
else if (url.pathname === '/api/kanban/columns') result = kanbanColumns
else if (/^\/api\/kanban\/cards\/[^/]+$/.test(url.pathname) && req.method === 'PUT') {
const bookmark = bookmarks.find(b => b.id === url.pathname.split('/')[4])
if (!bookmark) {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end('{"error":"Not found"}')
return
}
if (body.version === undefined) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end('{"error":"version is required when moving a card"}')
return
}
if (body.version !== bookmark.version) {
res.writeHead(409, { 'Content-Type': 'application/json' })
res.end('{"error":"Bookmark was modified elsewhere"}')
return
}
const column = kanbanColumns.find(c => c.id === body.columnId)
if (!column) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end('{"error":"Invalid columnId"}')
return
}
const target = bookmarks
.filter(b => b.id !== bookmark.id && b.kanbanColumnId === column.id)
.sort((a, b) => a.kanbanPosition - b.kanbanPosition || b.updatedAt.localeCompare(a.updatedAt))
const position = Math.max(0, Math.min(Number(body.position) || 0, target.length))
target.splice(position, 0, bookmark)
bookmark.kanbanColumnId = column.id
bookmark.version++
target.forEach((b, i) => { b.kanbanPosition = i })
kanbanMoves.push({ id: bookmark.id, columnId: column.id, position, version: body.version })
result = bookmark
} else if (/^\/api\/bookmarks\/[^/]+$/.test(url.pathname)) {
const bookmark = bookmarks.find(b => b.id === url.pathname.split('/')[3])
if (req.method === 'PUT') {
// Review CR-04: mirror Go's bookmark concurrency contract — version
// is required (400), a stale base is 409 with the row untouched,
// success bumps the revision.
if (body.version === undefined) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end('{"error":"version is required when updating a bookmark; refetch it and retry"}')
return
}
if (body.version !== bookmark.version) {
res.writeHead(409, { 'Content-Type': 'application/json' })
res.end('{"error":"Bookmark was modified elsewhere; reload to see the latest version"}')
return
}
const { tagIds, version, ...data } = body
Object.assign(bookmark, data)
if (tagIds) bookmark.tags = tagIds.map(id => ({ tag: tags.find(tag => tag.id === id) }))
bookmark.version++
}
result = bookmark
} else if (url.pathname.endsWith('/signals')) result = { sources: [], signals: [], types: [] }
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
} catch (error) { res.writeHead(500); res.end(String(error)) }
})
await new Promise(resolve => api.listen(0, '127.0.0.1', resolve))
// The free-port probe is TOCTOU-racy: another process can grab the port
// between close and `next start`. Retry up to three ports before giving up.
let base = ''
let web = null
let logs = ''
for (let attempt = 1; attempt <= 3; attempt++) {
const portProbe = http.createServer()
await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve))
const port = portProbe.address().port
await new Promise(resolve => portProbe.close(resolve))
base = `http://localhost:${port}`
logs = ''
web = spawn(process.execPath, ['node_modules/next/dist/bin/next', 'start', '--port', String(port)], {
cwd: fileURLToPath(new URL('../web/', import.meta.url)),
env: { ...process.env, AUTH_SECRET: 'isolated-browser-fixture-secret', NEXTAUTH_SECRET: 'isolated-browser-fixture-secret', NEXTAUTH_URL: base, API_BASE_URL: `http://127.0.0.1:${api.address().port}`, OIDC_ISSUER: '', OIDC_CLIENT_ID: '', OIDC_CLIENT_SECRET: '' },
stdio: ['ignore', 'pipe', 'pipe'],
})
web.stdout.on('data', data => logs += data)
web.stderr.on('data', data => logs += data)
let started = false
for (let i = 0; i < 150; i++) {
try { if ((await fetch(`${base}/login`)).ok) { started = true; break } } catch { /* not up yet */ }
if (/EADDRINUSE/.test(logs) || web.exitCode !== null) break
await new Promise(resolve => setTimeout(resolve, 100))
}
if (started) break
web.kill('SIGKILL')
if (attempt === 3) assert.fail(`Next server did not start after 3 ports: ${logs}`)
}
const attacker = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/html')
res.end(`<form method="POST" action="${base}/api/auth/login" enctype="text/plain"><input name='{"email":"attacker@computer.test","password":"password","extra":"' value='"}'></form><script>document.forms[0].submit()</script>`)
})
await new Promise(resolve => attacker.listen(0, '127.0.0.1', resolve))
async function eventually(check, label) {
for (let i = 0; i < 100; i++) {
if (await check()) return
await new Promise(resolve => setTimeout(resolve, 100))
}
assert.fail(label)
}
let browser
try {
await eventually(async () => { try { return (await fetch(`${base}/login`)).ok } catch { return false } }, `Next server did not start: ${logs}`)
browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] })
const context = await browser.newContext({ timezoneId: 'America/Chicago' })
const page = await context.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
page.on('dialog', dialog => dialog.accept())
await page.goto(`http://127.0.0.1:${attacker.address().port}`)
await page.waitForURL(`${base}/api/auth/login`)
assert.equal((await context.request.get(`${base}/api/auth/session`).then(r => r.json()))?.user, undefined)
for (const headers of [{ Origin: 'https://attacker.invalid' }, {}]) {
const response = await context.request.post(`${base}/api/auth/login`, { headers, data: { email: 'attacker@computer.test', password: 'password' } })
assert.equal(response.status(), 403)
}
const plain = await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base, 'Content-Type': 'text/plain' }, data: '{"email":"attacker@computer.test"}' })
assert.equal(plain.status(), 415)
const login = await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base }, data: { email: 'fixture@computer.test', password: 'password' } })
assert.equal(login.status(), 200)
console.log('PASS: forged logins rejected; legitimate login works')
// Review CR-14: an oversized chunked body (no usable Content-Length) must
// be rejected by the proxy's streaming limit — counted while read and
// cancelled at the cap, never fully buffered first.
const chunkedStatus = await new Promise((resolve) => {
const payload = Buffer.alloc(MAX_PROXY_BODY + 1, 0x61)
const u = new URL(`${base}/api/pages`)
const req = http.request({
host: u.hostname, port: u.port, path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' },
}, res => { res.resume(); res.on('end', () => resolve(res.statusCode)) })
req.on('error', () => resolve(-1))
for (let i = 0; i < payload.length; i += 65536) req.write(payload.subarray(i, Math.min(i + 65536, payload.length)))
req.end()
})
assert.equal(chunkedStatus, 413, 'oversized chunked body was not rejected at the proxy')
console.log('PASS: oversized chunked request bodies are rejected while streaming')
const editor = page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.')
await page.goto(`${base}/pages?id=a`)
await editor.fill('A edits before switching')
await page.getByRole('button', { name: 'Page B', exact: true }).click()
await eventually(() => page.getByRole('textbox', { name: 'Page title' }).inputValue().then(v => v === 'Page B'), 'Page B not selected')
await editor.fill('B edits')
await eventually(() => pages.get('b').content === 'B edits', 'Page B not saved')
assert.equal(pages.get('a').content, 'A edits before switching')
console.log('PASS: rapid page switching preserves both pages')
delaySaves = 900
maxConcurrentSaves = 0
await editor.fill('First in-flight edit')
await eventually(() => activeSaves === 1, 'Save did not start')
await editor.fill('Newest edit wins')
await eventually(() => pages.get('b').content === 'Newest edit wins' && activeSaves === 0, 'Newer edit did not save')
assert.equal(maxConcurrentSaves, 1)
delaySaves = 0
console.log('PASS: saves are serialized and newer edits win')
failSaves = true
await editor.fill('Recover this failed draft')
await page.getByRole('button', { name: 'Page A', exact: true }).click()
await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor()
assert.equal(await page.getByRole('textbox', { name: 'Page title' }).inputValue(), 'Page B')
await page.reload()
await eventually(() => editor.inputValue().then(value => value === 'Recover this failed draft'), 'Draft not recovered after reload')
failSaves = false
await page.getByRole('button', { name: 'Save now', exact: true }).click()
await eventually(() => pages.get('b').content === 'Recover this failed draft', 'Retry did not persist draft')
console.log('PASS: failed saves block switching, survive reload, and can be retried')
// Cross-session conflict (review DATA-04/TEST-01): after another "device"
// saves a newer version, the 409 pauses autosave in a distinct conflict
// state. Typing must not schedule another save; only the explicit
// overwrite button may replace the remote content.
await page.goto(`${base}/pages?id=a`)
await page.getByRole('button', { name: 'Edit', exact: true }).click()
await editor.fill('Local draft before conflict')
await eventually(() => pages.get('a').content === 'Local draft before conflict', 'Draft did not save before the conflict')
pages.get('a').content = 'Remote edit wins'
pages.get('a').version++
await editor.fill('Edit after remote change')
await page.getByText('This page was changed elsewhere', { exact: true }).waitFor()
const attemptsAtConflict = patchAttempts
await editor.fill('Typing must not overwrite')
await new Promise(resolve => setTimeout(resolve, 1200))
assert.equal(patchAttempts, attemptsAtConflict, 'Typing during a conflict scheduled another save')
assert.equal(pages.get('a').content, 'Remote edit wins', 'Conflict silently overwrote the remote content')
await page.getByRole('button', { name: 'Overwrite with my draft', exact: true }).click()
await eventually(() => pages.get('a').content === 'Typing must not overwrite', 'Explicit overwrite did not persist')
console.log('PASS: conflicts pause autosave until an explicit overwrite')
// Recovery after reload (review DATA-03/TEST-01): a failed draft remembers
// the server version it was based on. When another session saved in the
// meantime, recovery enters the conflict state instead of silently
// rebasing the stale draft onto the newer version.
failSaves = true
await page.goto(`${base}/pages?id=b`)
await page.getByRole('button', { name: 'Edit', exact: true }).click()
await editor.fill('Draft with an older base')
await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor()
pages.get('b').content = 'Remote saved while draft failed'
pages.get('b').version++
failSaves = false
await page.reload()
await eventually(() => editor.inputValue().then(value => value === 'Draft with an older base'), 'Draft not recovered after reload')
await page.getByText('This page was changed elsewhere', { exact: true }).waitFor()
// Snapshot AFTER recovery: the reload itself lets the departing page flush
// its dirty draft once (the fixture rejects it with a 409 against the newer
// version). What must not happen is any save from the recovered session.
const attemptsAfterRecovery = patchAttempts
await new Promise(resolve => setTimeout(resolve, 1000))
assert.equal(patchAttempts, attemptsAfterRecovery, 'Recovered draft saved without an explicit resolution')
assert.equal(pages.get('b').content, 'Remote saved while draft failed', 'Recovery rebased onto and clobbered the newer version')
await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click()
await eventually(() => editor.inputValue().then(value => value === 'Remote saved while draft failed'), 'Discard did not load the remote content')
console.log('PASS: recovered drafts keep their original base and conflict instead of rebasing')
// Unknown-provenance draft (review DATA-06): a legacy plain-string draft
// carries no baseVersion, so recovery cannot prove it predates the remote
// content. It must pause in the review-required conflict state instead of
// saving over whatever the server holds.
await page.goto(`${base}/pages?id=a`)
await eventually(() => page.getByRole('textbox', { name: 'Page title' }).inputValue().then(v => v === 'Page A'), 'Page A did not open')
await page.evaluate(() => sessionStorage.setItem('computer:page-draft:a', JSON.stringify('Legacy draft of unknown origin')))
const attemptsBeforeLegacy = patchAttempts
await page.reload()
await eventually(() => editor.inputValue().then(value => value === 'Legacy draft of unknown origin'), 'Legacy draft not recovered')
await page.getByText('This page was changed elsewhere', { exact: true }).waitFor()
await new Promise(resolve => setTimeout(resolve, 1000))
assert.equal(patchAttempts, attemptsBeforeLegacy, 'Unknown-base draft saved without review')
assert.equal(pages.get('a').content, 'Typing must not overwrite', 'Unknown-base draft clobbered the remote content')
await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click()
await eventually(() => editor.inputValue().then(value => value === 'Typing must not overwrite'), 'Discard did not load the remote content')
console.log('PASS: unknown-base legacy drafts pause for review instead of saving')
// Envelope refresh (review DATA-07): when a later save in the same serial
// loop fails, the stored recovery envelope must carry the newest content
// AND the base advanced by the earlier success — otherwise reload shows a
// spurious conflict for a draft that was already cleanly rebased.
delaySaves = 900
failContent = 'Envelope part two'
await page.goto(`${base}/pages?id=b`)
await page.getByRole('button', { name: 'Edit', exact: true }).click()
await editor.fill('Envelope part one')
await eventually(() => activeSaves === 1, 'First envelope save did not start')
await editor.fill('Envelope part two')
await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor()
assert.equal(pages.get('b').content, 'Envelope part one', 'First save of the loop did not land')
await page.reload()
await eventually(() => editor.inputValue().then(value => value === 'Envelope part two'), 'Newest loop content not recovered')
assert.equal(await page.getByText('This page was changed elsewhere', { exact: true }).count(), 0,
'Recovery hit a spurious conflict — the stored base was not refreshed after the successful save')
delaySaves = 0
failContent = null
await page.getByRole('button', { name: 'Save now', exact: true }).click()
await eventually(() => pages.get('b').content === 'Envelope part two', 'Retry did not persist the recovered draft')
console.log('PASS: recovery envelope keeps the latest content and refreshed base')
// Metadata mutations must not rebase a stale draft (review CR-17): a
// rename/pin PATCH response carries the CURRENT content revision but never
// acknowledges the draft's base. Adopting it would let a stale draft
// overwrite newer remote content without any conflict.
await page.goto(`${base}/pages?id=a`)
await page.getByRole('button', { name: 'Edit', exact: true }).click()
await editor.fill('Metadata rebase draft')
await eventually(() => pages.get('a').content === 'Metadata rebase draft', 'Draft did not save before the metadata mutation')
pages.get('a').content = 'Remote content stays'
pages.get('a').version++
await page.locator('button[title="Pin to sidebar"]').click()
await eventually(() => pages.get('a').pinned === true, 'Pin metadata patch did not land')
await editor.fill('Stale draft after metadata patch')
await page.getByText('This page was changed elsewhere', { exact: true }).waitFor()
assert.equal(pages.get('a').content, 'Remote content stays', 'Metadata response rebased the stale draft over the remote content')
await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click()
await eventually(() => editor.inputValue().then(value => value === 'Remote content stays'), 'Discard did not load the remote content')
console.log('PASS: metadata mutations do not rebase stale content drafts')
await page.goto(`${base}/bookmarks`)
await eventually(() => page.locator('tbody tr').count().then(n => n === 205), 'Collection truncated at 200')
assert.ok(paginatedRequests > 0, 'Cursor not forwarded through proxy')
await page.getByText('Fixture 000', { exact: true }).click()
const sheet = page.getByRole('dialog')
const fillSheet = async () => {
await sheet.getByLabel('URL', { exact: true }).fill('https://127.0.0.1/changed')
await sheet.getByLabel('Description', { exact: true }).fill('Edited description')
await sheet.getByLabel('First tag', { exact: true }).uncheck()
await sheet.getByLabel('Second tag', { exact: true }).check()
await sheet.locator('input[type="datetime-local"]').fill('2026-10-08T10:30')
}
await fillSheet()
// Review CR-04: a concurrent edit elsewhere bumps the revision while this
// form is open. The save must surface a conflict, preserve the unsaved
// draft, and leave the remote state untouched — no silent overwrite.
bookmarks[0].version++
bookmarks[0].notes = 'Remote edit wins'
await sheet.getByRole('button', { name: 'Save Changes', exact: true }).click()
await page.getByText('This bookmark was changed elsewhere', { exact: false }).first().waitFor()
assert.equal(bookmarks[0].url, 'https://127.0.0.1/test', 'Stale bookmark save overwrote the remote state')
assert.equal(bookmarks[0].notes, 'Remote edit wins', 'Stale bookmark save clobbered the remote notes')
// The sheet keeps the unsaved draft and offers an explicit resolution.
await sheet.getByRole('button', { name: 'Load latest version', exact: true }).click()
await eventually(() => sheet.getByLabel('URL', { exact: true }).inputValue().then(v => v === 'https://127.0.0.1/test'), 'Sheet did not reload the latest version')
await fillSheet()
await sheet.getByRole('button', { name: 'Save Changes', exact: true }).click()
await eventually(() => bookmarks[0].url === 'https://127.0.0.1/changed', 'Bookmark update missing after conflict resolution')
assert.equal(bookmarks[0].description, 'Edited description')
assert.deepEqual(bookmarks[0].tags.map(({ tag }) => tag.id), ['two'])
assert.equal(bookmarks[0].alertAt, '2026-10-08T15:30:00.000Z')
assert.equal(bookmarks[0].notes, 'Remote edit wins', 'Conflict resolution lost the remote notes')
assert.deepEqual(errors, [], 'Browser runtime errors')
console.log('PASS: 205 bookmarks displayed; stale saves conflict visibly; URL/description/tags editable; local alert time converted to UTC')
// Kanban drag-and-drop persists within-column ordering and cross-column
// moves through the versioned card endpoint.
await page.goto(`${base}/kanban`)
await page.getByRole('heading', { name: 'Kanban Board' }).waitFor()
const inboxColumn = page.locator('[data-column-id="inbox"]')
const todoColumn = page.locator('[data-column-id="todo"]')
await inboxColumn.locator('[data-bookmark-id="bookmark-2"]').dragTo(
inboxColumn.locator('[data-bookmark-id="bookmark-0"]'),
{ targetPosition: { x: 20, y: 5 } },
)
await eventually(() => kanbanMoves.length === 1, 'Within-column drag did not reach the API')
assert.deepEqual(kanbanMoves[0], { id: 'bookmark-2', columnId: 'inbox', position: 0, version: 0 })
await inboxColumn.locator('[data-bookmark-id="bookmark-3"]').dragTo(todoColumn)
await eventually(() => kanbanMoves.length === 2, 'Cross-column drag did not reach the API')
assert.deepEqual(kanbanMoves[1], { id: 'bookmark-3', columnId: 'todo', position: 0, version: 0 })
await todoColumn.locator('[data-bookmark-id="bookmark-3"]').waitFor()
// Touch/keyboard fallback: the card menu moves without a drag.
await todoColumn.locator('[data-bookmark-id="bookmark-3"]').getByRole('button', { name: /options/i }).click()
await page.getByRole('menuitem', { name: 'Move to Inbox' }).click()
await eventually(() => kanbanMoves.length === 3, 'Card menu move did not reach the API')
assert.deepEqual(kanbanMoves[2], { id: 'bookmark-3', columnId: 'inbox', position: 204, version: 1 })
await inboxColumn.locator('[data-bookmark-id="bookmark-3"]').waitFor()
console.log('PASS: kanban drag reorders within a column and moves cards between columns')
// Page tabs share the persisted position with the sidebar; dragging a tab
// issues the complete order permutation and both surfaces reload. Reset the
// pin from the earlier metadata test so both pages are in one reorder group.
pages.get('a').pinned = false
await page.goto(`${base}/pages`)
await page.locator('[data-page-tab="a"]').waitFor()
await page.locator('[data-page-tab="b"] [data-page-tab-drag]').dragTo(
page.locator('[data-page-tab="a"]'),
{ targetPosition: { x: 5, y: 5 } },
)
await eventually(() => pages.get('b')?.position === 0 && pages.get('a')?.position === 1, 'Page tab drag did not reorder')
// The sidebar uses the same complete order permutation; drag a back above b.
await page.locator('[data-page-id="a"] [data-page-drag-handle]').dragTo(
page.locator('[data-page-id="b"]'),
{ targetPosition: { x: 10, y: 5 } },
)
await eventually(() => pages.get('a')?.position === 0 && pages.get('b')?.position === 1, 'Sidebar page drag did not reorder')
console.log('PASS: page tabs and sidebar persist drag reordering')
// Revocation failure path (review CR-02): when Go's revocation fails, the
// local sign-out still completes, but the UI must say so — local cookie
// removal is not confirmed global revocation, and other sessions survive.
await page.goto(`${base}/bookmarks`)
await page.keyboard.press('Escape') // close any detail sheet so the top bar is reachable
failSignout = true
const failedRevoke = page.waitForResponse(r => r.url().includes('/api/session/revoke') && r.request().method() === 'POST')
await page.getByRole('button', { name: 'Sign out', exact: true }).click()
await page.getByText('global revocation failed', { exact: false }).first().waitFor()
assert.equal((await (await failedRevoke).json()).revoked, false, 'Revoke route reported success despite the backend failure')
await page.waitForURL(`${base}/login`)
failSignout = false
console.log('PASS: failed global revocation is reported and local sign-out still completes')
// Browser sign-out must reach Go's revocation (review AUTH-LIFECYCLE): the
// explicit /api/session/revoke call (with the NextAuth signOut event as
// backstop) posts to Go /api/auth/signout with a signed assertion, so the
// tokenVersion bump kills Go cookie sessions and any previously minted
// assertions — not just the local NextAuth JWT.
await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base }, data: { email: 'fixture@computer.test', password: 'password' } })
await page.goto(`${base}/pages`)
const revoked = page.waitForResponse(r => r.url().includes('/api/session/revoke') && r.request().method() === 'POST')
await page.getByRole('button', { name: 'Sign out', exact: true }).click()
assert.equal((await (await revoked).json()).revoked, true, 'Revoke route did not confirm global revocation')
await page.waitForURL(`${base}/login`)
await eventually(() => signouts.length > 0, 'Go signout was not called during browser sign-out')
const authHeader = signouts[signouts.length - 1]
assert.ok(authHeader.startsWith('Bearer '), 'Signout call carried no bearer assertion')
const claim = JSON.parse(Buffer.from(authHeader.slice('Bearer '.length).split('.')[1], 'base64url').toString())
assert.equal(claim.uid, 'fixture-user')
assert.equal(claim.typ, 'assertion')
assert.equal(claim.ver, 1)
assert.deepEqual(errors, [], 'Browser runtime errors during sign-out')
console.log('PASS: browser sign-out revokes sessions through the Go backend')
} finally {
await browser?.close()
web.kill('SIGTERM')
api.closeAllConnections(); api.close()
attacker.closeAllConnections(); attacker.close()
}
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env bash
# Server-side half of the deployment, run as root on the target server by
# scripts/deploy.sh:
# ssh -t HOST sudo bash .../server-deploy.sh DIR DOMAIN EMAIL USER [REVISION] [STAGE]
#
# Provisions missing packages (docker, compose plugin, nginx, certbot,
# rsync), then — under a server-wide deployment lock (review CR-11) —
# publishes the per-deploy STAGE directory into the shared release DIR,
# writes .env on first deploy, backs up the database, builds/starts the
# Compose stack, waits for health, installs the nginx reverse proxy with a
# Let's Encrypt cert, and records the release marker only after everything
# passed. Overlapping deployments serialize on the lock; their rsync targets
# are unique staging directories, so shared release inputs never change
# mid-flight.
set -euo pipefail
REMOTE_DIR="${1:?usage: server-deploy.sh DIR DOMAIN EMAIL RUN_USER [REVISION] [STAGE]}"
DOMAIN="$2"
EMAIL="$3"
RUN_USER="$4"
REVISION="${5:-}"
STAGE="${6:-}"
# Host-side ports for the Compose bindings. The defaults from docker-compose.yml
# (8080/3000) collide with other apps on shared servers, so production pins
# these via .env; override here if 18080/13000 are ever taken too.
APP_HOST_PORT="${APP_HOST_PORT:-18080}"
WEB_HOST_PORT="${WEB_HOST_PORT:-13000}"
# Pinned upstream tool versions (review OPS-02): fallback downloads install
# exactly these releases and verify them against the checksums Docker
# publishes with the release. Overrides must be deliberate.
COMPOSE_VERSION="${COMPOSE_VERSION:-v5.6.0}"
BUILDX_VERSION="${BUILDX_VERSION:-v0.38.0}"
log() { printf '\033[1;34m[server]==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[server] error:\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" = "0" ] || die "must run as root (the local deploy.sh does: ssh -t ... sudo bash $0)"
# --- 0. Serialize deployments (review CR-11) ------------------------------------
# One server-wide lock held for the whole critical section: publishing the
# staged tree, the Compose build/release, nginx configuration and the release
# marker must never interleave with a second deployment. A waiter blocks (up
# to 30 minutes) instead of mixing source and release state; the lock dies
# with this process, so a crashed deploy cannot wedge the server.
DEPLOY_LOCK="${DEPLOY_LOCK:-/var/lock/computer-deploy.lock}"
exec 9>"$DEPLOY_LOCK"
log "Waiting for the deployment lock ($DEPLOY_LOCK)"
flock -w 1800 9 || die "another deployment held $DEPLOY_LOCK for more than 30 minutes; refusing to overlap"
log "Deployment lock acquired"
# --- 1. Provision packages ----------------------------------------------------
log "Checking required packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# Install one package, but only if apt actually has it (mirrors differ).
install_pkg() {
apt-cache show "$1" >/dev/null 2>&1 || return 1
apt-get install -y -qq "$1"
}
command -v docker >/dev/null 2>&1 || install_pkg docker.io || die "docker not installed and docker.io unavailable via apt"
if ! docker compose version >/dev/null 2>&1; then
install_pkg docker-compose-v2 || install_pkg docker-compose-plugin || true
fi
command -v nginx >/dev/null 2>&1 || install_pkg nginx || die "nginx not installed and unavailable via apt"
command -v rsync >/dev/null 2>&1 || install_pkg rsync || die "rsync not installed and unavailable via apt (needed to publish the staged release)"
command -v flock >/dev/null 2>&1 || die "flock is required to serialize deployments (util-linux)"
if ! command -v certbot >/dev/null 2>&1; then
install_pkg certbot || die "certbot unavailable via apt"
install_pkg python3-certbot-nginx || die "python3-certbot-nginx unavailable via apt"
fi
if ! docker compose version >/dev/null 2>&1; then
log "Compose plugin not in apt; installing pinned upstream $COMPOSE_VERSION (checksum-verified)"
# GitHub release assets use uname -m names (x86_64/aarch64), not dpkg arches.
arch="$(uname -m)"
case "$arch" in
x86_64 | aarch64 | armv7l | s390x | ppc64le) ;;
*) die "no upstream Compose binary for architecture '$arch'" ;;
esac
asset="docker-compose-linux-$arch"
tmp="$(mktemp -d)"
curl -fsSL "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/$asset" -o "$tmp/$asset"
curl -fsSL "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/$asset.sha256" -o "$tmp/$asset.sha256"
(cd "$tmp" && sha256sum -c "$asset.sha256") || die "Compose $COMPOSE_VERSION checksum verification failed; refusing to install"
mkdir -p /usr/local/libexec/docker/cli-plugins
install -m 755 "$tmp/$asset" /usr/local/libexec/docker/cli-plugins/docker-compose
rm -rf "$tmp"
docker compose version >/dev/null 2>&1 || die "docker compose still unavailable"
fi
# Upstream compose builds via buildx and requires >= 0.17.0; Debian's
# docker.io ships an older (or no) buildx, so install the upstream plugin.
buildx_ok() {
docker buildx version >/dev/null 2>&1 || return 1
local v
v="$(docker buildx version | sed -n 's/.* v\([0-9][0-9.]*\).*/\1/p' | head -n1)"
[ -n "$v" ] || return 1
[ "$(printf '%s\n0.17.0\n' "$v" | sort -V | head -n1)" = "0.17.0" ]
}
if ! buildx_ok; then
log "Installing pinned upstream buildx $BUILDX_VERSION (compose build needs >= 0.17.0; checksum-verified)"
case "$(uname -m)" in
x86_64) barch=amd64 ;;
aarch64) barch=arm64 ;;
armv7l) barch=arm ;;
*) die "no upstream buildx binary for architecture '$(uname -m)'" ;;
esac
asset="buildx-$BUILDX_VERSION.linux-$barch"
tmp="$(mktemp -d)"
curl -fsSL "https://github.com/docker/buildx/releases/download/$BUILDX_VERSION/$asset" -o "$tmp/$asset"
curl -fsSL "https://github.com/docker/buildx/releases/download/$BUILDX_VERSION/checksums.txt" -o "$tmp/checksums.txt"
# checksums.txt covers every asset; verify exactly the pinned one (exact
# field match — a substring grep would also catch .provenance/.sbom assets
# and fail the check).
awk -v name="*$asset" '$2 == name' "$tmp/checksums.txt" > "$tmp/$asset.sha256"
[ -s "$tmp/$asset.sha256" ] || die "no published checksum for $asset"
(cd "$tmp" && sha256sum -c "$asset.sha256") || die "buildx $BUILDX_VERSION checksum verification failed; refusing to install"
mkdir -p /usr/local/libexec/docker/cli-plugins
install -m 755 "$tmp/$asset" /usr/local/libexec/docker/cli-plugins/docker-buildx
rm -rf "$tmp"
buildx_ok || die "docker buildx still unavailable or too old"
fi
# Record what provisioning settled on (review OPS-02).
log "compose plugin: $(docker compose version 2>/dev/null | head -n1)"
log "buildx plugin: $(docker buildx version 2>/dev/null | head -n1)"
systemctl enable --now docker nginx
# Let RUN_USER drive docker without sudo for day-to-day commands.
id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker || usermod -aG docker "$RUN_USER"
# --- 1b. Publish the staged release into the shared directory (under lock) -------
# Review CR-11: the workstation rsynced a UNIQUE staging directory, so this
# mirror is the only moment shared release inputs change — and it happens
# atomically-ish under the deployment lock, never mid-build of another run.
if [ -n "$STAGE" ]; then
[ -d "$STAGE" ] || die "staging directory $STAGE is missing"
mkdir -p "$REMOTE_DIR"
log "Publishing staged release $STAGE -> $REMOTE_DIR"
# The server's own .env (secrets) and the last successful release marker
# survive the mirror; everything else becomes exactly the staged revision.
rsync -a --delete \
--exclude '.env' \
--exclude '.env.*' \
--exclude '.deployed-revision' \
"$STAGE/" "$REMOTE_DIR/"
rm -rf "$STAGE"
# Best-effort prune of stages abandoned by failed deploys (older than 7 days).
find "$(dirname "$STAGE")" -maxdepth 1 -mindepth 1 -mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
[ -f "$REMOTE_DIR/docker-compose.yml" ] || die "$REMOTE_DIR/docker-compose.yml missing - rsync first"
# --- 2. .env (created once; never touch AUTH_SECRET afterwards) ---------------
cd "$REMOTE_DIR"
if [ ! -f .env ]; then
# A lost .env while the data volume remains would generate a fresh
# POSTGRES_PASSWORD that the existing database does not have, locking the
# app out of its own data. Refuse instead.
if docker volume inspect computer_pgdata >/dev/null 2>&1; then
die ".env is missing but the computer_pgdata volume exists - restore the .env backup (it holds POSTGRES_PASSWORD and AUTH_SECRET); refusing to regenerate"
fi
log "Creating .env with generated secrets (back it up with the database!)"
umask 077
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
AUTH_SECRET=$(openssl rand -hex 32)
NEXTAUTH_URL=https://$DOMAIN
COOKIE_SECURE=1
ADMIN_EMAILS=
POSTGRES_VOLUME=computer_pgdata
POSTGRES_VOLUME_EXTERNAL=false
APP_HOST_PORT=$APP_HOST_PORT
WEB_HOST_PORT=$WEB_HOST_PORT
OIDC_ISSUER=REPLACE_ME
OIDC_CLIENT_ID=REPLACE_ME
OIDC_CLIENT_SECRET=REPLACE_ME
EOF
umask 022
else
log "Keeping existing .env"
grep -q '^AUTH_SECRET=..*' .env || die ".env exists but AUTH_SECRET is empty"
# Pin the host ports even on an .env created before port overrides existed.
grep -q '^APP_HOST_PORT=' .env || echo "APP_HOST_PORT=$APP_HOST_PORT" >> .env
grep -q '^WEB_HOST_PORT=' .env || echo "WEB_HOST_PORT=$WEB_HOST_PORT" >> .env
url="$(sed -n 's/^NEXTAUTH_URL=//p' .env | tail -n1)"
secure="$(sed -n 's/^COOKIE_SECURE=//p' .env | tail -n1)"
[ "$url" = "https://$DOMAIN" ] || log "warning: NEXTAUTH_URL is '$url', expected 'https://$DOMAIN'"
[ "$secure" = "1" ] || log "warning: COOKIE_SECURE is '$secure', expected '1' behind HTTPS"
fi
# Day-to-day `docker compose` runs as RUN_USER, which must be able to read .env.
chown "$RUN_USER:" .env
chmod 600 .env
# Effective ports are whatever .env says (they may predate this run).
APP_PORT="$(sed -n 's/^APP_HOST_PORT=//p' .env | tail -n1)"; APP_PORT="${APP_PORT:-8080}"
WEB_PORT="$(sed -n 's/^WEB_HOST_PORT=//p' .env | tail -n1)"; WEB_PORT="${WEB_PORT:-3000}"
# Fail early with a clear message if another process squats the ports while
# our stack is not the thing holding them.
port_busy() { ss -tln | awk '{print $4}' | grep -qE "[:.]$1\$"; }
if [ -z "$(docker compose ps -q app 2>/dev/null)" ]; then
port_busy "$APP_PORT" && die "port $APP_PORT is already in use by another process - set APP_HOST_PORT and re-run"
port_busy "$WEB_PORT" && die "port $WEB_PORT is already in use by another process - set WEB_HOST_PORT and re-run"
fi
# --- 2b. Back up the database before touching the stack -------------------------
# The volume survives rebuilds, but a volume is not a backup: take a pg_dump
# before every deploy and keep the 15 most recent dumps plus matching .env
# secret snapshots (review OPS-03: AUTH_SECRET/ENCRYPTION_KEY are required to
# decrypt integration data when restoring). Skipping is never silent: an
# existing database that is not running gets started for the backup, and a
# failed dump aborts the deploy. Dumps are 0600 in a 0700 directory; copy
# them off-host — a same-disk backup does not survive disk loss.
BACKUP_DIR="${BACKUP_DIR:-/var/backups/computer}"
pg_volume="$(sed -n 's/^POSTGRES_VOLUME=//p' .env 2>/dev/null | tail -n1)"; pg_volume="${pg_volume:-computer_pgdata}"
have_existing_db() {
[ -n "$(docker compose ps -aq db 2>/dev/null)" ] || docker volume inspect "$pg_volume" >/dev/null 2>&1
}
if have_existing_db; then
if ! docker compose ps --status running -q db 2>/dev/null | grep -q .; then
log "Database exists but is not running; starting it so the pre-deploy backup actually happens"
docker compose up -d db
db_deadline=$((SECONDS + 90))
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$(docker compose ps -q db)" 2>/dev/null)" = "healthy" ]; do
[ $SECONDS -lt $db_deadline ] || die "database never became healthy for the pre-deploy backup"
sleep 3
done
fi
log "Backing up database to $BACKUP_DIR"
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
dump="$BACKUP_DIR/computer-$stamp.sql.gz"
if docker compose exec -T db pg_dump -U computer -d computer | gzip > "$dump"; then
chmod 600 "$dump"
# Capture the secrets required to decrypt/restore integration data.
cp -p .env "$BACKUP_DIR/env-$stamp.bak"
chmod 600 "$BACKUP_DIR/env-$stamp.bak"
ls -1t "$BACKUP_DIR"/computer-*.sql.gz 2>/dev/null | tail -n +16 | xargs -r rm -f
ls -1t "$BACKUP_DIR"/env-*.bak 2>/dev/null | tail -n +16 | xargs -r rm -f
log "Backup written: $dump ($(du -h "$dump" | cut -f1)) + env-$stamp.bak; copy both off-host"
else
rm -f "$dump"
die "pg_dump failed - aborting deploy to protect the database"
fi
else
log "No existing database container or volume ($pg_volume) found - first deploy, nothing to back up"
fi
# --- 3. Build and start the stack ---------------------------------------------
log "docker compose up -d --build (first build takes several minutes)"
docker compose up -d --build
docker compose ps -a
# Retain a named artifact of this release for rollback (review OPS-04):
# tag the built images with the deployed revision when deploy.sh passed one.
if [ -n "$REVISION" ]; then
for svc in app web; do
img="$(docker compose images -q "$svc" 2>/dev/null | head -n1)"
if [ -n "$img" ]; then
docker tag "$img" "computer-$svc:$REVISION"
log "tagged computer-$svc:$REVISION"
fi
done
fi
# --- 4. Wait for health ---------------------------------------------------------
log "Waiting for app health and web /login"
deadline=$((SECONDS + 300))
app_id="$(docker compose ps -q app)"
until [ -n "$app_id" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$app_id" 2>/dev/null)" = "healthy" ]; do
[ $SECONDS -lt $deadline ] || { docker compose ps -a; docker compose logs --tail=50 app web; die "app never became healthy"; }
sleep 3
done
until curl -fsS -o /dev/null "http://127.0.0.1:$WEB_PORT/login"; do
[ $SECONDS -lt $deadline ] || die "web never answered on 127.0.0.1:$WEB_PORT";
sleep 3
done
log "Stack is healthy"
# --- 5. nginx reverse proxy -------------------------------------------------------
log "Installing nginx config for $DOMAIN"
TEMPLATE="$REMOTE_DIR/deploy/nginx/computer.conf.template"
[ -f "$TEMPLATE" ] || die "missing $TEMPLATE (should have been rsynced)"
# Render the generic template with this deployment's domain and the server's
# actual WEB_HOST_PORT binding.
RENDERED="/tmp/$DOMAIN.$$.conf"
sed -e "s|__DOMAIN__|$DOMAIN|g" -e "s|__WEB_PORT__|$WEB_PORT|g" "$TEMPLATE" > "$RENDERED"
if [ -d /etc/nginx/sites-available ]; then
install -m 644 "$RENDERED" "/etc/nginx/sites-available/$DOMAIN"
ln -sfn "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN"
SITE_CONF="/etc/nginx/sites-available/$DOMAIN"
else
install -m 644 "$RENDERED" "/etc/nginx/conf.d/$DOMAIN.conf"
SITE_CONF="/etc/nginx/conf.d/$DOMAIN.conf"
fi
rm -f "$RENDERED"
if [ ! -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then
# HTTP-01 needs $DOMAIN to resolve to this server before certbot runs.
server_ip="$(curl -fsS --max-time 10 https://api.ipify.org || true)"
dns_ip="$(getent ahostsv4 "$DOMAIN" | awk '{print $1; exit}')"
if [ -n "$server_ip" ] && [ "$dns_ip" != "$server_ip" ]; then
die "$DOMAIN resolves to '$dns_ip' but this server is '$server_ip' - fix DNS, then re-run"
fi
log "No certificate yet: bootstrapping via HTTP-only config + certbot"
# Park the TLS config (nginx -t fails on missing cert files) and serve :80.
mv "$SITE_CONF" "/etc/nginx/$DOMAIN.conf.pending"
rm -f "/etc/nginx/sites-enabled/$DOMAIN"
cat > "/etc/nginx/conf.d/$DOMAIN-bootstrap.conf" <<BOOT
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
location / { proxy_pass http://127.0.0.1:$WEB_PORT; proxy_set_header Host \$host; }
}
BOOT
nginx -t
systemctl reload nginx
certbot certonly --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$EMAIL" --keep-until-expiring
rm -f "/etc/nginx/conf.d/$DOMAIN-bootstrap.conf"
mv "/etc/nginx/$DOMAIN.conf.pending" "$SITE_CONF"
[ -d /etc/nginx/sites-available ] && ln -sfn "$SITE_CONF" "/etc/nginx/sites-enabled/$DOMAIN"
# Reload nginx after each successful renewal.
if [ ! -e /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ]; then
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
printf '#!/bin/sh\nsystemctl reload nginx\n' > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
fi
fi
nginx -t
systemctl reload nginx
# --- 6. End-to-end check through nginx --------------------------------------------
log "Verifying https://$DOMAIN/login via nginx"
# Retry briefly: right after a reload, early connections can still hit the
# previous config's default 443 vhost (wrong cert / connection refused).
ok=""
for _ in $(seq 10); do
if curl -fsS -o /dev/null --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/login"; then
ok=1; break
fi
sleep 3
done
[ -n "$ok" ] || die "nginx -> web TLS check failed after retries"
# --- 7. Release marker (review CR-11/OPS-04) --------------------------------------
# Recorded only now that every check passed, so .deployed-revision always
# names the revision actually serving; a failed deploy leaves the previous
# marker (and the previous revision-tagged images) intact for rollback.
if [ -n "$REVISION" ]; then
printf '%s\n' "$REVISION" > "$REMOTE_DIR/.deployed-revision"
log "Recorded deployed revision $REVISION"
fi
log "Server-side deploy complete"