This commit is contained in:
Executable
+107
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "test": "node e2e.mjs", "test:regressions": "node regressions.mjs" },
|
||||
"devDependencies": { "playwright": "1.58.2" }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Executable
+354
@@ -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"
|
||||
Reference in New Issue
Block a user