Add path-prefix deployment support for migrating to podsteadr's domain
Regress needs to live at podsteadr.atobitcoin.io/regress/ since / is already podsteadr. Two coordinated pieces: - VITE_BASE build arg (frontend asset/script paths, %BASE_URL% in index.html for the nostr-provider.js script tag) - ROUTE_PREFIX runtime env (backend routes registered under the prefix via Fastify's plugin-encapsulation, including /api/health) The nginx location must forward the prefix unstripped (proxy_pass with no trailing path) — NIP-98 login signs the exact URL it calls, so a stripped prefix makes the backend reconstruct a different URL than what was signed and every login fails. Caught this with a real nginx+docker integration test locally before it could break the live migration, then fixed a matching bug in auth.ts (it was signing the unprefixed URL while api.ts fetched the prefixed one). New routePrefix.test.ts proves the prefix is actually enforced, including a negative case. 40 tests passing. Also fixes a latent bug: import.meta.env usage had no vite/client type reference, so it only ever passed typecheck by accident in earlier local runs — added the standard vite-env.d.ts.
This commit is contained in:
+26
-16
@@ -44,25 +44,35 @@ export async function buildApp(opts: BuildAppOptions): Promise<FastifyInstance>
|
||||
await app.register(cors, { origin: true, credentials: true, methods: ['GET', 'POST', 'DELETE'] });
|
||||
await app.register(nostrAuth, { db, config });
|
||||
|
||||
app.get('/api/health', async () => ({ status: 'ok' }));
|
||||
// Everything below is mounted under ROUTE_PREFIX (empty by default, i.e.
|
||||
// root) so a single build can be deployed either standalone or under a
|
||||
// shared domain's path — see config.ts for why the prefix must be
|
||||
// preserved end-to-end rather than stripped by the reverse proxy.
|
||||
const prefix = config.ROUTE_PREFIX;
|
||||
await app.register(
|
||||
async (scoped) => {
|
||||
scoped.get('/api/health', async () => ({ status: 'ok' }));
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.register(placesRoutes);
|
||||
await app.register(claimsRoutes);
|
||||
await app.register(linksRoutes);
|
||||
await app.register(fieldsRoutes);
|
||||
await app.register(syncRoutes);
|
||||
await scoped.register(authRoutes);
|
||||
await scoped.register(placesRoutes);
|
||||
await scoped.register(claimsRoutes);
|
||||
await scoped.register(linksRoutes);
|
||||
await scoped.register(fieldsRoutes);
|
||||
await scoped.register(syncRoutes);
|
||||
|
||||
const staticDir = config.STATIC_DIR;
|
||||
if (staticDir && existsSync(staticDir)) {
|
||||
await app.register(fastifyStatic, { root: staticDir });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.raw.url?.startsWith('/api/')) {
|
||||
return reply.code(404).send({ error: 'not found' });
|
||||
const staticDir = config.STATIC_DIR;
|
||||
if (staticDir && existsSync(staticDir)) {
|
||||
await scoped.register(fastifyStatic, { root: staticDir, prefix: '/' });
|
||||
scoped.setNotFoundHandler((req, reply) => {
|
||||
if (req.raw.url?.startsWith(`${prefix}/api/`)) {
|
||||
return reply.code(404).send({ error: 'not found' });
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
},
|
||||
{ prefix },
|
||||
);
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
db.close();
|
||||
|
||||
@@ -6,6 +6,13 @@ const envSchema = z.object({
|
||||
DATA_DIR: z.string().default('./data'),
|
||||
STATIC_DIR: z.string().optional(),
|
||||
PUBLIC_URL: z.string().url().default('http://localhost:8096'),
|
||||
// Set when this instance is deployed under a path prefix on a shared domain
|
||||
// (e.g. "/regress" on podsteadr.atobitcoin.io/regress/) rather than at
|
||||
// root. Must match nginx's location block, which must NOT strip the
|
||||
// prefix — the NIP-98 `u` tag the frontend signs includes it, so the
|
||||
// backend needs to see (and route) the same full path. Leave unset ("") to
|
||||
// deploy at root, e.g. its own dedicated host/port.
|
||||
ROUTE_PREFIX: z.string().default(''),
|
||||
NIP98_MAX_SKEW_SECS: z.coerce.number().default(60),
|
||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||
// Default sync area: Madeira, Portugal (Funchal-centered radius covering the whole island).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildApp } from './app.js';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
// Mirrors deploying under e.g. podsteadr.atobitcoin.io/regress/, where nginx
|
||||
// forwards the full prefixed path unchanged (not stripped) — see config.ts.
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
|
||||
let app: FastifyInstance;
|
||||
let dataDir: string;
|
||||
|
||||
function nip98Header(url: string, method: string): string {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [['u', url], ['method', method]],
|
||||
},
|
||||
sk,
|
||||
);
|
||||
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'regress-prefix-test-'));
|
||||
const config = loadConfig({
|
||||
DATA_DIR: dataDir,
|
||||
PUBLIC_URL: 'https://podsteadr.atobitcoin.io',
|
||||
ROUTE_PREFIX: '/regress',
|
||||
} as NodeJS.ProcessEnv);
|
||||
app = await buildApp({ config, dbPath: ':memory:', logger: false });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('ROUTE_PREFIX deployment', () => {
|
||||
it('serves health under the prefix, not at root', async () => {
|
||||
const prefixed = await app.inject({ method: 'GET', url: '/regress/api/health' });
|
||||
expect(prefixed.statusCode).toBe(200);
|
||||
|
||||
const atRoot = await app.inject({ method: 'GET', url: '/api/health' });
|
||||
expect(atRoot.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('accepts a NIP-98 login signed for the full prefixed URL (matching what nginx forwards unstripped)', async () => {
|
||||
const url = 'https://podsteadr.atobitcoin.io/regress/api/auth/login';
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/regress/api/auth/login',
|
||||
headers: { authorization: nip98Header(url, 'POST') },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().pubkey).toBe(pk);
|
||||
});
|
||||
|
||||
it('rejects a login signed for the unprefixed URL — proves the prefix is actually enforced, not incidentally ignored', async () => {
|
||||
const unprefixedUrl = 'https://podsteadr.atobitcoin.io/api/auth/login';
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/regress/api/auth/login',
|
||||
headers: { authorization: nip98Header(unprefixedUrl, 'POST') },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user