diff --git a/.gitea/workflows/app.yml b/.gitea/workflows/app.yml index 372701f..57d2aac 100644 --- a/.gitea/workflows/app.yml +++ b/.gitea/workflows/app.yml @@ -32,12 +32,13 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 2 - name: Check patch, syntax and source contracts without building run: | git diff --check HEAD^ HEAD - for script in scripts/*.sh; do bash -n "$script"; done + for script in scripts/*.sh scripts/preview/*.sh; do bash -n "$script"; done node scripts/check-studio-contracts.mjs + node --check scripts/preview/fingerprint.mjs node --check scripts/verify-studio-visual.cjs node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs node --check apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs diff --git a/.gitea/workflows/preview.yml b/.gitea/workflows/preview.yml new file mode 100644 index 0000000..e023852 --- /dev/null +++ b/.gitea/workflows/preview.yml @@ -0,0 +1,32 @@ +name: Remote development preview source sync +on: + push: + branches: ['**'] + paths: + - 'apps/waterfall-wiki/**' + - 'packages/brand/**' + - 'pnpm-workspace.yaml' + - 'scripts/preview/**' + - '.gitea/workflows/preview.yml' +concurrency: + group: development-preview + cancel-in-progress: false +jobs: + sync-source: + # One preview slot follows one branch. Setup changes use an explicit refresh. + if: gitea.ref == format('refs/heads/{0}', vars.PREVIEW_BRANCH || 'codex/unified-web') + runs-on: ces-control + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Initialize once, otherwise synchronize files only + env: + SOURCE_SHA: ${{ gitea.sha }} + SOURCE_BRANCH: ${{ gitea.ref_name }} + run: | + if [ ! -s /opt/prompt-preview/fingerprint ]; then + bash scripts/preview/init.sh "$PWD" waterfall-wiki-tests:6654a1456bdcef77d83ca84373dc36188e15bd12 + fi + bash scripts/preview/sync.sh "$PWD" "$SOURCE_SHA" "$SOURCE_BRANCH" diff --git a/.gitea/workflows/python.yml b/.gitea/workflows/python.yml index 9982229..8cc6ec5 100644 --- a/.gitea/workflows/python.yml +++ b/.gitea/workflows/python.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 2 - name: Check Python and deployment syntax without executing tests run: | git diff --check HEAD^ HEAD diff --git a/apps/waterfall-wiki/src/lib/account-crypto.ts b/apps/waterfall-wiki/src/lib/account-crypto.ts index 36fb4ac..2567783 100644 --- a/apps/waterfall-wiki/src/lib/account-crypto.ts +++ b/apps/waterfall-wiki/src/lib/account-crypto.ts @@ -1,7 +1,8 @@ import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto'; import { promisify } from 'node:util'; const scrypt = promisify(derive); -export const ACCOUNT_COOKIE = 'wiki_account'; +export const ACCOUNT_COOKIE = process.env.ACCOUNT_COOKIE_NAME || 'wiki_account'; +if (!/^[A-Za-z0-9_-]{1,64}$/.test(ACCOUNT_COOKIE)) throw Error('Invalid ACCOUNT_COOKIE_NAME'); export const SESSION_TTL = 7 * 86400; export function normalizeEmail(value: unknown) { if (typeof value !== 'string') return null; diff --git a/apps/waterfall-wiki/src/lib/auth.ts b/apps/waterfall-wiki/src/lib/auth.ts index 9855b0c..a86bcd0 100644 --- a/apps/waterfall-wiki/src/lib/auth.ts +++ b/apps/waterfall-wiki/src/lib/auth.ts @@ -1,7 +1,8 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { readFileSync } from 'node:fs'; -export const SESSION_COOKIE = 'prompt_session'; +export const SESSION_COOKIE = process.env.AUTH_COOKIE_NAME || 'prompt_session'; +if (!/^[A-Za-z0-9_-]{1,64}$/.test(SESSION_COOKIE)) throw Error('Invalid AUTH_COOKIE_NAME'); export const SESSION_SECONDS = 7 * 24 * 60 * 60; export type AuthConfig = { secret: string; jwtKey: string }; export function authConfig(): AuthConfig | null { diff --git a/apps/waterfall-wiki/src/pages/api/dev-preview.ts b/apps/waterfall-wiki/src/pages/api/dev-preview.ts new file mode 100644 index 0000000..2a2c835 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/dev-preview.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (process.env.PREVIEW_MODE !== 'true') return res.status(404).end(); + if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).end(); + res.setHeader('Cache-Control', 'no-store'); + try { + const status = JSON.parse(await readFile('/preview-status/status.json', 'utf8')); + return req.method === 'HEAD' ? res.status(200).end() : res.status(200).json(status); + } catch { + return res.status(503).json({ error: 'Preview source has not synchronized yet' }); + } +} diff --git a/apps/waterfall-wiki/src/proxy.ts b/apps/waterfall-wiki/src/proxy.ts index 3c1ee00..6710d39 100644 --- a/apps/waterfall-wiki/src/proxy.ts +++ b/apps/waterfall-wiki/src/proxy.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { authConfig, SESSION_COOKIE, validSession } from './lib/auth'; export function proxy(request: NextRequest) { const path = request.nextUrl.pathname; + if (process.env.PREVIEW_MODE === 'true' && path === '/api/dev-preview' && ['GET','HEAD'].includes(request.method)) return NextResponse.next(); const publicBrandAssets = ['/favicon.ico','/apple-touch-icon.png','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/studio/favicon.ico','/studio/apple-touch-icon.png','/studio/brand/logo-light.svg','/studio/brand/logo-dark.svg','/studio/brand/icon-dark.svg']; if (['GET','HEAD'].includes(request.method) && publicBrandAssets.includes(path)) return NextResponse.next({headers:{'Referrer-Policy':'same-origin'}}); const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'}; diff --git a/scripts/preview/compose.yml b/scripts/preview/compose.yml new file mode 100644 index 0000000..ca228f5 --- /dev/null +++ b/scripts/preview/compose.yml @@ -0,0 +1,94 @@ +x-preview-env: &preview-env + DATABASE_URL: postgresql://prompt:${POSTGRES_PASSWORD:?required}@postgres:5432/prompt + S3_ENDPOINT: http://objects:9000 + S3_ACCESS_KEY: ${S3_ACCESS_KEY:?required} + S3_SECRET_KEY: ${S3_SECRET_KEY:?required} + S3_BUCKET: prompt-preview + AUTH_CONFIG_FILE: /auth/config.json + AUTH_COOKIE_SECURE: "false" + AUTH_COOKIE_NAME: preview_prompt_session + ACCOUNT_COOKIE_NAME: preview_wiki_account + ACCOUNT_PUBLIC_ORIGIN: http://8.211.154.134:${PREVIEW_PORT:-18136} + STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000 + SMTP_HOST: 127.0.0.1 + SMTP_PORT: "1" + SMTP_USERNAME: preview + SMTP_PASSWORD: "" + NEXT_TELEMETRY_DISABLED: "1" + PREVIEW_MODE: "true" + COREPACK_HOME: /tools/corepack + COREPACK_ENABLE_NETWORK: "0" + npm_config_offline: "true" +services: + waterfall-wiki: + image: ${PREVIEW_TOOL_IMAGE:?use a verified test image} + working_dir: /repo/apps/waterfall-wiki + user: "1000:1000" + command: ["pnpm", "dev", "--hostname", "0.0.0.0", "--port", "3000"] + init: true + restart: unless-stopped + environment: + <<: *preview-env + NODE_OPTIONS: --max-old-space-size=1536 + mem_limit: 2g + cpus: 1.0 + ports: ["0.0.0.0:${PREVIEW_PORT:-18136}:3000"] + volumes: + - /opt/prompt-preview/source:/repo + - dependencies:/repo/apps/waterfall-wiki/node_modules + - type: volume + source: next-cache + target: /repo/apps/waterfall-wiki/.next + - tools:/tools:ro + - /opt/prompt-preview/status:/preview-status:ro + - auth:/auth:ro + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: prompt + POSTGRES_USER: prompt + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} + volumes: ["database:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U prompt -d prompt"] + interval: 3s + timeout: 3s + retries: 30 + objects: + image: rustfs/rustfs:1.0.3 + restart: unless-stopped + environment: + RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:?required} + RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:?required} + RUSTFS_VOLUMES: /data + RUSTFS_ADDRESS: :9000 + RUSTFS_CONSOLE_ENABLE: "false" + volumes: ["objects:/data"] + prepare-tools: + image: ${PREVIEW_TOOL_IMAGE:?required} + profiles: [setup] + user: root + volumes: ["tools:/tools"] + command: ["/bin/sh", "-ec", "mkdir -p /tools/corepack && cp -a /root/.cache/node/corepack/. /tools/corepack/ && chown -R 1000:1000 /tools"] + migrate: + image: ${PREVIEW_TOOL_IMAGE:?required} + profiles: [setup] + environment: *preview-env + command: ["node", "dist/bootstrap.cjs"] + auth-admin: + image: ${PREVIEW_TOOL_IMAGE:?required} + profiles: [setup] + user: root + environment: *preview-env + volumes: ["auth:/auth"] + command: ["node", "dist/auth-cli.cjs", "init"] +volumes: + tools: + dependencies: + next-cache: + auth: + database: + objects: diff --git a/scripts/preview/fingerprint.mjs b/scripts/preview/fingerprint.mjs new file mode 100644 index 0000000..980b2f2 --- /dev/null +++ b/scripts/preview/fingerprint.mjs @@ -0,0 +1,13 @@ +// Startup/dependency/schema changes require an explicit preview refresh. +import {readFile,readdir} from 'node:fs/promises'; +import {resolve} from 'node:path'; +import {createHash} from 'node:crypto'; +const root=resolve(process.argv[2] || '.'); +const hash=createHash('sha256'); +for (const name of ['apps/waterfall-wiki/pnpm-lock.yaml','pnpm-workspace.yaml','apps/waterfall-wiki/next.config.ts','apps/waterfall-wiki/tsconfig.json','apps/waterfall-wiki/postcss.config.mjs']) hash.update(name).update(await readFile(resolve(root,name))); +for (const name of ['apps/waterfall-wiki/package.json','packages/brand/package.json']) { + const p=JSON.parse(await readFile(resolve(root,name),'utf8')); + hash.update(name).update(JSON.stringify({dependencies:p.dependencies,devDependencies:p.devDependencies,optionalDependencies:p.optionalDependencies,peerDependencies:p.peerDependencies,engines:p.engines,packageManager:p.packageManager,dev:p.scripts?.dev,prepare:p.scripts?.['brand:prepare'],exports:p.exports})); +} +for (const name of (await readdir(resolve(root,'apps/waterfall-wiki/db'))).filter(n=>n.endsWith('.sql')).sort()) hash.update(name).update(await readFile(resolve(root,'apps/waterfall-wiki/db',name))); +console.log(hash.digest('hex')); diff --git a/scripts/preview/init.sh b/scripts/preview/init.sh new file mode 100644 index 0000000..34050f1 --- /dev/null +++ b/scripts/preview/init.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SOURCE=$(realpath "${1:?checkout directory required}") +IMAGE=${2:?verified integration image required} +PORT=${PREVIEW_PORT:-18136} +[[ "$IMAGE" =~ ^waterfall-wiki-tests:[0-9a-f]{40}$ ]] || exit 2 +[[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 18136 && PORT <= 65535 )) || exit 2 +command -v rsync >/dev/null +command -v ss >/dev/null +docker image inspect "$IMAGE" >/dev/null +ROOT=/opt/prompt-preview +exec 7>/var/lock/prompt-preview-init.lock +flock -w 30 7 +if [ -s "$ROOT/fingerprint" ]; then echo 'Already initialized; use sync.sh, or perform an explicit manual refresh' >&2; exit 1; fi +if ss -H -ltn "sport = :$PORT" | grep -q .; then echo "Preview port $PORT is occupied; choose another PREVIEW_PORT" >&2; exit 1; fi +# The prebuilt tooling image must match dependency, startup and schema inputs. +expected=$(node "$SOURCE/scripts/preview/fingerprint.mjs" "$SOURCE") +actual=$(docker run --rm --entrypoint node \ + --mount "type=bind,src=$SOURCE/scripts/preview/fingerprint.mjs,dst=/tmp/preview-fingerprint.mjs,readonly" \ + "$IMAGE" /tmp/preview-fingerprint.mjs /repo) +[[ "$actual" == "$expected" ]] || { echo 'Tooling image does not match preview dependencies/configuration/schema' >&2; exit 1; } +install -d -m 0700 "$ROOT" +install -d -m 0755 -o 1000 -g 1000 "$ROOT/source" "$ROOT/status" +# Create independent preview credentials only once, never copy production secrets. +if [ ! -s "$ROOT/env" ]; then + ROOT="$ROOT" IMAGE="$IMAGE" PORT="$PORT" python3 - <<'PY' +import os,secrets,pathlib +p=pathlib.Path(os.environ['ROOT'])/'env' +with p.open('x') as f: + os.chmod(p,0o600) + f.write('PREVIEW_TOOL_IMAGE='+os.environ['IMAGE']+'\nPREVIEW_PORT='+os.environ['PORT']+'\n') + for key in ['POSTGRES_PASSWORD','S3_ACCESS_KEY','S3_SECRET_KEY']:f.write(key+'='+secrets.token_hex(24)+'\n') +PY +fi +rsync -a --delete --chown=1000:1000 \ + --exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='dist' \ + --exclude='*.tsbuildinfo' --exclude='.env' --exclude='.env.*' --exclude='.gitea' \ + "$SOURCE/" "$ROOT/source/" +install -m 0644 "$SOURCE/scripts/preview/compose.yml" "$ROOT/compose.yml" +compose(){ docker compose -p prompt-preview --env-file "$ROOT/env" -f "$ROOT/compose.yml" "$@"; } +compose up -d --no-build --no-recreate --wait --wait-timeout 120 postgres objects +compose run --rm --no-deps prepare-tools +compose run --rm --no-deps auth-admin +compose run --rm --no-deps migrate +# The dependency volume is seeded from the already-verified image, no npm install. +compose up -d --no-build --no-recreate --no-deps waterfall-wiki +printf '%s\n' "$expected" > "$ROOT/fingerprint" +for attempt in {1..90}; do + if curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health" >/dev/null; then + echo "Preview initialized on port $PORT; pnpm dev remains running" + exit 0 + fi + sleep 2 +done +compose logs --tail 60 waterfall-wiki +exit 1 diff --git a/scripts/preview/sync.sh b/scripts/preview/sync.sh new file mode 100644 index 0000000..e2fa0de --- /dev/null +++ b/scripts/preview/sync.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SOURCE=$(realpath "${1:?checkout directory required}") +SHA=${2:?commit SHA required} +BRANCH=${3:?branch required} +[[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || exit 2 +ROOT=/opt/prompt-preview +exec 8>/var/lock/prompt-preview-sync.lock +flock -w 30 8 +candidate=$(node "$SOURCE/scripts/preview/fingerprint.mjs" "$SOURCE") +if [ ! -s "$ROOT/fingerprint" ]; then + echo 'Preview is not initialized; run scripts/preview/init.sh once with a verified tooling image' >&2 + exit 1 +fi +if [ "$(cat "$ROOT/fingerprint")" != "$candidate" ]; then + echo 'Preview refresh required: dependencies, startup configuration or database schema changed. No install/restart/migration was performed.' >&2 + exit 1 +fi +id=$(docker compose -p prompt-preview --env-file "$ROOT/env" -f "$ROOT/compose.yml" ps -q waterfall-wiki) +test -n "$id" && test "$(docker inspect --format '{{.State.Running}}' "$id")" = true +# No git credentials, dependency folders, compiler output or environment files are copied. +rsync -a --checksum --no-times --delete --chown=1000:1000 \ + --exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='dist' \ + --exclude='*.tsbuildinfo' --exclude='.env' --exclude='.env.*' --exclude='.gitea' \ + --exclude='/apps/waterfall-wiki/public/brand/' --exclude='/apps/waterfall-wiki/public/favicon.ico' --exclude='/apps/waterfall-wiki/public/apple-touch-icon.png' "$SOURCE/" "$ROOT/source/" +# Keep generated public marks current using file copies only. +assets="$ROOT/source/packages/brand/assets" +public="$ROOT/source/apps/waterfall-wiki/public" +mkdir -p "$public/brand" +copy_asset() { + cmp -s "$1" "$2" || install -o 1000 -g 1000 -m 0644 "$1" "$2" +} +for name in logo-light.svg logo-dark.svg icon-dark.svg; do copy_asset "$assets/svg/$name" "$public/brand/$name"; done +copy_asset "$assets/icons/favicon.ico" "$public/favicon.ico" +copy_asset "$assets/icons/apple-touch-icon.png" "$public/apple-touch-icon.png" +SHA="$SHA" BRANCH="$BRANCH" DEST="$ROOT/status/status.json" python3 - <<'META' +import os,json,pathlib,datetime +destination=pathlib.Path(os.environ['DEST']) +temporary=destination.with_suffix('.tmp') +temporary.write_text(json.dumps({'sha':os.environ['SHA'],'branch':os.environ['BRANCH'],'syncedAt':datetime.datetime.now(datetime.timezone.utc).isoformat()})+'\n') +temporary.chmod(0o644) +temporary.replace(destination) +META +echo "Preview synchronized: $BRANCH $SHA (container $id)"