feat(cutover): Plan 8b — point void.hynesy.com at Void 2 (alpha.18)

CF Access multi-aud: CF_ACCESS_AUD now accepts a comma-separated
allow-list so requests through either the void.hynesy.com or
void2-app.hynesy.com CF Access app are honoured as owner. Fails
closed; unlisted auds rejected. Adds multi-aud test.

Void 1 (CT 301) becomes legacy but stays running untouched as an
instant rollback. -alpha tag kept pending owner sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-05 00:50:57 +10:00
parent 191790098a
commit 147b4f514c
5 changed files with 19 additions and 3 deletions

View File

@@ -3,6 +3,11 @@
All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).
## 2.0.0-alpha.18 — Plan 8b cutover: `void.hynesy.com` now serves Void 2
- **Go-live.** `void.hynesy.com` (CT 301 → Void 1) is repointed at **Void 2** (CT 311, `.216:3000`) at the Traefik edge. Void 1 is now **legacy** — CT 301 stays running untouched as an instant-rollback fallback; nothing is retired or renamed yet. The `-alpha` tag is intentionally **kept** pending owner sign-off.
- **CF Access multi-aud** (`lib/auth/cf_access.js`): `CF_ACCESS_AUD` now accepts a **comma-separated allow-list** so a request through *either* CF Access app — `void.hynesy.com` (aud `0e7190f4…`) or `void2-app.hynesy.com` (aud `a381f270…`) — is honoured as owner. Still fails closed; an unlisted aud is rejected. Prod env updated to carry both auds.
- Cutover is fully reversible: revert the Traefik `void` service URL to `http://192.168.1.11:2424` and `docker restart traefik`.
## 2.0.0-alpha.17 — Settings, project management, terminal, AI Usage, "The Void" space + UI polish
- **Settings** (`#/settings`): API tokens (mint/list/revoke), Agents list with an expandable **profile viewer** (persona/"soul" + capabilities/scopes via `GET /api/agents/:id/profile`), Orthos Mode placeholder.
- **Per-space project management**: Void-1-style expandable cards with inline **status**, **Details**, **Tasks**, **Linked references**, **↻ Research** (Eithan stub → `POST /api/projects/:id/research`), Edit/New modal, Delete-with-confirm. Migration 019 adds research fields; `GET /api/projects/:id/links` resolves linked pages/refs.

View File

@@ -49,7 +49,12 @@ export async function verifyAccessJwt(jwt, { teamDomain, aud, fetchImpl = fetch,
const claims = b64urlJson(p);
const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (!auds.includes(aud)) throw new Error('aud mismatch');
// `aud` may be a single value or a comma-separated list (multiple CF Access
// apps front the same origin — e.g. void.hynesy.com and void2-app.hynesy.com
// during the 8b cutover). Accept the token if it carries ANY allowed aud.
const wanted = (Array.isArray(aud) ? aud : String(aud).split(','))
.map((a) => a.trim()).filter(Boolean);
if (!auds.some((a) => wanted.includes(a))) throw new Error('aud mismatch');
const t = Math.floor(now() / 1000);
if (claims.exp && t > claims.exp) throw new Error('expired');
if (claims.nbf && t < claims.nbf - 60) throw new Error('not yet valid');

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.0.0-alpha.17",
"version": "2.0.0-alpha.18",
"type": "module",
"private": true,
"scripts": {

View File

@@ -13,7 +13,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js';
import httpProxy from 'http-proxy';
const VERSION = '2.0.0-alpha.17';
const VERSION = '2.0.0-alpha.18';
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the

View File

@@ -39,6 +39,12 @@ describe('verifyAccessJwt', () => {
it('rejects a wrong audience (another app cannot grant access)', async () => {
await expect(verifyAccessJwt(mintJwt({ ...valid, aud: 'someone-else' }), opts)).rejects.toThrow(/aud/);
});
it('accepts any aud from a comma-separated allow-list (8b cutover: two CF apps front one origin)', async () => {
const multi = { ...opts, aud: `void-app-aud, ${AUD}` };
const c = await verifyAccessJwt(mintJwt(valid), multi); // token carries AUD (the 2nd allowed)
expect(c.email).toBe(EMAIL);
await expect(verifyAccessJwt(mintJwt({ ...valid, aud: 'unlisted' }), multi)).rejects.toThrow(/aud/);
});
it('rejects an expired token', async () => {
await expect(verifyAccessJwt(mintJwt({ ...valid, exp: sec - 10 }), opts)).rejects.toThrow(/expired/);
});