diff --git a/server/src/app.test.ts b/server/src/app.test.ts index 3db2e8c..8146129 100644 --- a/server/src/app.test.ts +++ b/server/src/app.test.ts @@ -34,10 +34,10 @@ beforeAll(async () => { DATA_DIR: dataDir, PUBLIC_URL: 'http://localhost:8095', NOSTR_RELAYS: '', // no relay publishing in tests + PODSTEADR_ADMIN_PUBKEYS: pk, } as NodeJS.ProcessEnv); app = await buildApp({ config, dbPath: ':memory:', logger: false }); }); - afterAll(async () => { await app.close(); rmSync(dataDir, { recursive: true, force: true }); @@ -57,7 +57,7 @@ describe('auth', () => { }); expect(res.statusCode).toBe(200); expect(res.json().pubkey).toBe(pk); - expect(res.json().isAdmin).toBe(true); // first login claims admin + expect(res.json().isAdmin).toBe(true); // operator-bound login claims admin const setCookie = res.headers['set-cookie'] as string; expect(setCookie).toContain('podsteadr_session='); cookie = setCookie.split(';')[0]; @@ -73,7 +73,7 @@ describe('auth', () => { }); it('serves /api/auth/me with the session cookie', async () => { - const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } }); + const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie, origin: 'http://localhost:8095' } }); expect(res.statusCode).toBe(200); expect(res.json().pubkey).toBe(pk); }); @@ -87,7 +87,7 @@ describe('podcasts, episodes, feed', () => { const res = await app.inject({ method: 'POST', url: '/api/podcasts', - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { title: 'My Show', description: 'About things', @@ -111,7 +111,7 @@ describe('podcasts, episodes, feed', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { title: 'Ep 1', sha256: sha, size: 1000, mime: 'video/mp4' }, }); expect(res.statusCode).toBe(201); @@ -132,7 +132,7 @@ describe('podcasts, episodes, feed', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { title: 'Bad', sha256: 'd'.repeat(64), size: 1000 }, }); expect(res.statusCode).toBe(422); @@ -167,7 +167,7 @@ describe('streams + mediamtx auth webhook', () => { const res = await app.inject({ method: 'POST', url: '/api/streams', - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { title: 'Live Test', summary: 'hi', hashtags: ['podsteadr'] }, }); expect(res.statusCode).toBe(201); @@ -228,7 +228,7 @@ describe('streams + mediamtx auth webhook', () => { const rot = await app.inject({ method: 'POST', url: `/api/streams/${streamId}/rotate-key`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, }); expect(rot.statusCode).toBe(200); const oldKey = await app.inject({ @@ -258,7 +258,7 @@ describe('settings', () => { const res = await app.inject({ method: 'PUT', url: '/api/settings', - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { blossom_url: 'https://blossom.example.com' }, }); expect(res.statusCode).toBe(200); @@ -290,7 +290,7 @@ describe('settings', () => { const res = await app.inject({ method: 'PUT', url: '/api/settings', - headers: { cookie: cookie2 }, + headers: { cookie: cookie2, origin: 'http://localhost:8095' }, payload: { blossom_url: 'https://evil.example.com' }, }); expect(res.statusCode).toBe(403); diff --git a/server/src/auth-bootstrap.test.ts b/server/src/auth-bootstrap.test.ts new file mode 100644 index 0000000..1a5d8c7 --- /dev/null +++ b/server/src/auth-bootstrap.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { afterEach, 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'; + +const apps: FastifyInstance[] = []; +const dirs: string[] = []; + +function loginHeader(secretKey: Uint8Array): string { + const event = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['u', 'http://localhost:8095/api/auth/login'], + ['method', 'POST'], + ['nonce', randomUUID()], + ], + }, + secretKey, + ); + return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; +} + +async function open(dataDir: string, bindings: string): Promise { + const config = loadConfig({ + DATA_DIR: dataDir, + PUBLIC_URL: 'http://localhost:8095', + NOSTR_RELAYS: '', + PODSTEADR_ADMIN_PUBKEYS: bindings, + } as NodeJS.ProcessEnv); + const app = await buildApp({ config, logger: false }); + apps.push(app); + return app; +} + +async function login(app: FastifyInstance, secretKey: Uint8Array) { + return app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { authorization: loginHeader(secretKey) }, + }); +} + +afterEach(async () => { + for (const app of apps.splice(0)) await app.close(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('login bootstrap authorization', () => { + it('admits users but reserves first-admin claim for a bound identity', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'podsteadr-bootstrap-')); + dirs.push(dataDir); + const boundKey = generateSecretKey(); + const unboundKey = generateSecretKey(); + const app = await open(dataDir, getPublicKey(boundKey)); + + const unbound = await login(app, unboundKey); + expect(unbound.statusCode).toBe(200); + expect(unbound.json().isAdmin).toBe(false); + + const bound = await login(app, boundKey); + expect(bound.statusCode).toBe(200); + expect(bound.json().isAdmin).toBe(true); + expect(app.ctx.db.prepare('SELECT COUNT(*) AS n FROM users').get()).toEqual({ n: 2 }); + }); + + it('preserves the existing admin and users when bindings change across restart', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'podsteadr-bootstrap-')); + dirs.push(dataDir); + const originalKey = generateSecretKey(); + const replacementKey = generateSecretKey(); + const originalPubkey = getPublicKey(originalKey); + + const first = await open(dataDir, originalPubkey); + expect((await login(first, originalKey)).json().isAdmin).toBe(true); + await first.close(); + apps.splice(apps.indexOf(first), 1); + + const reopened = await open(dataDir, getPublicKey(replacementKey)); + expect((await login(reopened, replacementKey)).json().isAdmin).toBe(false); + expect((await login(reopened, originalKey)).json().isAdmin).toBe(true); + expect(reopened.ctx.settings.get('admin_pubkey')).toBe(originalPubkey); + expect(reopened.ctx.db.prepare('SELECT COUNT(*) AS n FROM users').get()).toEqual({ n: 2 }); + }); +}); diff --git a/server/src/auth-origin.test.ts b/server/src/auth-origin.test.ts new file mode 100644 index 0000000..683259b --- /dev/null +++ b/server/src/auth-origin.test.ts @@ -0,0 +1,143 @@ +import { randomUUID } from 'node:crypto'; +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'; + +const publicOrigin = 'http://localhost:8095'; +const additionalOrigin = 'https://podsteadr.example'; +const sk = generateSecretKey(); +const pk = getPublicKey(sk); +let app: FastifyInstance; +let dataDir: string; +let cookie: 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], ['nonce', randomUUID()]], + }, sk); + return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; +} + +beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'podsteadr-origin-test-')); + const config = loadConfig({ + DATA_DIR: dataDir, + PUBLIC_URL: publicOrigin, + PODSTEADR_TRUSTED_ORIGINS: `${additionalOrigin}/`, + NOSTR_RELAYS: '', + PODSTEADR_ADMIN_PUBKEYS: pk, + } as NodeJS.ProcessEnv); + app = await buildApp({ config, dbPath: ':memory:', logger: false }); + const login = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { authorization: nip98Header(`${publicOrigin}/api/auth/login`, 'POST') }, + }); + expect(login.statusCode).toBe(200); + cookie = (login.headers['set-cookie'] as string).split(';')[0]; +}); + +afterAll(async () => { + await app.close(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('cookie Origin and NIP-98 URL binding', () => { + it('rejects cookie mutations with missing or untrusted Origin', async () => { + for (const origin of [undefined, 'http://localhost:9999', 'null']) { + const res = await app.inject({ + method: 'PUT', + url: '/api/settings', + headers: origin ? { cookie, origin } : { cookie }, + payload: {}, + }); + expect(res.statusCode).toBe(403); + } + }); + + it('accepts cookie mutations from PUBLIC_URL and explicit configured origins', async () => { + for (const origin of [publicOrigin, additionalOrigin]) { + const res = await app.inject({ + method: 'PUT', + url: '/api/settings', + headers: { cookie, origin }, + payload: {}, + }); + expect(res.statusCode).toBe(200); + } + }); + + it('does not require Origin for safe cookie reads or public reads', async () => { + const me = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } }); + expect(me.statusCode).toBe(200); + const publicRead = await app.inject({ + method: 'GET', + url: '/api/settings/public', + headers: { origin: 'https://attacker.example' }, + }); + expect(publicRead.statusCode).toBe(200); + }); + + it('ignores hostile Host and forwarded headers when matching NIP-98 URLs', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { + authorization: nip98Header('https://attacker.example/api/auth/login', 'POST'), + host: 'attacker.example', + 'x-forwarded-host': 'attacker.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().error).toMatch(/URL/i); + }); + + it('accepts NIP-98 URLs bound to an explicitly configured origin', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { + authorization: nip98Header(`${additionalOrigin}/api/auth/login`, 'POST'), + host: 'attacker.example', + 'x-forwarded-host': 'attacker.example', + 'x-forwarded-proto': 'http', + }, + }); + expect(res.statusCode).toBe(200); + }); + + it('allows an exact NIP-98 mutation without ambient cookie Origin', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/settings', + headers: { authorization: nip98Header(`${publicOrigin}/api/settings`, 'PUT') }, + payload: {}, + }); + expect(res.statusCode).toBe(200); + }); + + it('protects cookie logout from cross-origin requests', async () => { + const rejected = await app.inject({ method: 'POST', url: '/api/auth/logout', headers: { cookie } }); + expect(rejected.statusCode).toBe(403); + const stillValid = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } }); + expect(stillValid.statusCode).toBe(200); + + const accepted = await app.inject({ + method: 'POST', + url: '/api/auth/logout', + headers: { cookie, origin: publicOrigin }, + }); + expect(accepted.statusCode).toBe(200); + const ended = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } }); + expect(ended.statusCode).toBe(401); + }); +}); diff --git a/server/src/config.test.ts b/server/src/config.test.ts new file mode 100644 index 0000000..8fff033 --- /dev/null +++ b/server/src/config.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { loadConfig } from './config.js'; + +const a = 'a'.repeat(64); +const b = 'b'.repeat(64); + +describe('operator admin bindings', () => { + it('fails closed when no binding is supplied', () => { + expect(() => loadConfig({} as NodeJS.ProcessEnv)).toThrow(/PODSTEADR_ADMIN_PUBKEYS/); + }); + + it.each(['', ' ', 'abc', `${a},`, `${a},${'G'.repeat(64)}`])( + 'rejects an empty or malformed binding: %j', + (value) => { + expect(() => loadConfig({ PODSTEADR_ADMIN_PUBKEYS: value } as NodeJS.ProcessEnv)).toThrow( + /Nostr public keys/, + ); + }, + ); + + it('parses multiple bindings without granting access to duplicate entries', () => { + const config = loadConfig({ PODSTEADR_ADMIN_PUBKEYS: `${a}, ${b},${a}` } as NodeJS.ProcessEnv); + expect(config.PODSTEADR_ADMIN_PUBKEYS).toEqual([a, b]); + }); + + it('uses PUBLIC_URL plus normalized explicitly configured origins', () => { + const config = loadConfig({ + PUBLIC_URL: 'https://podsteadr.example/app', + PODSTEADR_ADMIN_PUBKEYS: a, + PODSTEADR_TRUSTED_ORIGINS: 'http://node.local:8095/, https://podsteadr.example', + } as NodeJS.ProcessEnv); + expect(config.trustedOrigins).toEqual([ + 'https://podsteadr.example', + 'http://node.local:8095', + ]); + }); + + it.each([ + 'javascript:alert(1)', + 'https://user@example.com', + 'https://example.com/path', + 'https://example.com?query=1', + 'https://example.com,#empty', + ])('rejects a non-origin trusted entry: %s', (value) => { + expect(() => loadConfig({ + PODSTEADR_ADMIN_PUBKEYS: a, + PODSTEADR_TRUSTED_ORIGINS: value, + } as NodeJS.ProcessEnv)).toThrow(/invalid trusted origin/); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index f2dde7a..c27f866 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,5 +1,45 @@ import { z } from 'zod'; +const nostrPubkeys = z.string().transform((value, ctx) => { + const keys = value.split(',').map((key) => key.trim()); + if (keys.length === 0 || keys.some((key) => !/^[0-9a-f]{64}$/.test(key))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'expected a non-empty comma-separated list of lowercase 64-character Nostr public keys', + }); + return z.NEVER; + } + return [...new Set(keys)]; +}); + +const trustedOrigins = z.string().default('').transform((value, ctx) => { + if (!value.trim()) return []; + const origins: string[] = []; + for (const candidate of value.split(',').map((origin) => origin.trim())) { + try { + const url = new URL(candidate); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error('not an HTTP(S) origin'); + } + origins.push(url.origin); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `invalid trusted origin: ${candidate || '(empty)'}`, + }); + return z.NEVER; + } + } + return [...new Set(origins)]; +}); + const envSchema = z.object({ PORT: z.coerce.number().default(8095), HOST: z.string().default('0.0.0.0'), @@ -21,14 +61,24 @@ const envSchema = z.object({ NIP98_MAX_SKEW_SECS: z.coerce.number().default(60), SESSION_TTL_DAYS: z.coerce.number().default(30), MEDIAMTX_POLL_INTERVAL_MS: z.coerce.number().default(3000), + // Archipelago renders this from {{NODE_IDENTITY_PUBKEYS}}. There is no + // default: an unbound installation must not fall back to first-login admin. + PODSTEADR_ADMIN_PUBKEYS: nostrPubkeys, + // Optional additional browser/NIP-98 origins. Forwarded headers never extend + // this allow-list; every non-PUBLIC_URL origin must be configured explicitly. + PODSTEADR_TRUSTED_ORIGINS: trustedOrigins, }); -export type Config = z.infer & { defaultRelays: string[] }; +export type Config = z.infer & { + defaultRelays: string[]; + trustedOrigins: string[]; +}; export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const parsed = envSchema.parse(env); return { ...parsed, + trustedOrigins: [...new Set([new URL(parsed.PUBLIC_URL).origin, ...parsed.PODSTEADR_TRUSTED_ORIGINS])], defaultRelays: parsed.NOSTR_RELAYS.split(',') .map((r) => r.trim()) .filter(Boolean), diff --git a/server/src/plugins/nostr-auth.ts b/server/src/plugins/nostr-auth.ts index b711ae6..0d99bfe 100644 --- a/server/src/plugins/nostr-auth.ts +++ b/server/src/plugins/nostr-auth.ts @@ -20,6 +20,10 @@ function nowSecs(): number { return Math.floor(Date.now() / 1000); } +function isSafeMethod(method: string): boolean { + return ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()); +} + export interface NostrAuthOptions { db: DB; config: Config; @@ -37,16 +41,20 @@ export default fp(async function nostrAuth(app: FastifyInstance, opts: NostrAuth app.decorateRequest('userPubkey', null); - /** Candidate URLs the client may have signed: configured public URL + the Host header it used. */ + /** Candidate URLs come only from explicitly configured origins. */ function candidateUrls(req: FastifyRequest): string[] { - const publicOrigin = new URL(config.PUBLIC_URL).origin; - const urls = [`${publicOrigin}${req.raw.url}`]; - const host = req.headers['x-forwarded-host'] ?? req.headers.host; - if (host) { - const proto = (req.headers['x-forwarded-proto'] as string | undefined) ?? 'http'; - urls.push(`${proto}://${host}${req.raw.url}`); + return config.trustedOrigins.map((origin) => `${origin}${req.raw.url}`); + } + + function hasTrustedOrigin(req: FastifyRequest): boolean { + const header = req.headers.origin; + if (!header || Array.isArray(header)) return false; + try { + const url = new URL(header); + return url.origin === header && config.trustedOrigins.includes(url.origin); + } catch { + return false; } - return urls; } function verifyNip98Request(req: FastifyRequest): string { @@ -88,16 +96,8 @@ export default fp(async function nostrAuth(app: FastifyInstance, opts: NostrAuth }); app.decorate('requireAuth', async (req: FastifyRequest, reply: FastifyReply) => { - // Session cookie first (the normal browser path)… - const sessionId = req.cookies[SESSION_COOKIE]; - if (sessionId) { - const row = selectSession.get(sessionId) as { pubkey: string; expires_at: number } | undefined; - if (row && row.expires_at > nowSecs()) { - req.userPubkey = row.pubkey; - return; - } - } - // …then a NIP-98 header (CLI / scripting path). + // A NIP-98 header is a request-bound proof and does not rely on cookie + // ambient authority. Prefer it even when a stale session cookie is present. if (req.headers.authorization?.startsWith('Nostr ')) { try { req.userPubkey = verifyNip98Request(req); @@ -109,6 +109,20 @@ export default fp(async function nostrAuth(app: FastifyInstance, opts: NostrAuth throw err; } } + + // Cookie-authenticated mutations require a configured exact Origin. Host + // and X-Forwarded-* are deliberately irrelevant to this decision. + const sessionId = req.cookies[SESSION_COOKIE]; + if (sessionId) { + const row = selectSession.get(sessionId) as { pubkey: string; expires_at: number } | undefined; + if (row && row.expires_at > nowSecs()) { + if (!isSafeMethod(req.method) && !hasTrustedOrigin(req)) { + return reply.code(403).send({ error: 'untrusted or missing request origin' }); + } + req.userPubkey = row.pubkey; + return; + } + } return reply.code(401).send({ error: 'not authenticated' }); }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 7c702de..5081d74 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -25,7 +25,7 @@ export default async function authRoutes(app: FastifyInstance) { throw err; } upsertUser.run(pubkey, nowSecs(), nowSecs()); - settings.claimAdminIfUnset(pubkey); + settings.claimAdminIfAuthorized(pubkey); // Optional profile hints from the client (it has the user's kind-0 metadata). const body = req.body as { displayName?: string; lud16?: string } | null; @@ -42,7 +42,7 @@ export default async function authRoutes(app: FastifyInstance) { return { pubkey, isAdmin: settings.isAdmin(pubkey) }; }); - app.post('/api/auth/logout', async (req, reply) => { + app.post('/api/auth/logout', { preHandler: app.requireAuth }, async (req, reply) => { app.destroySession(req, reply); return { ok: true }; }); diff --git a/server/src/routes/marketplace.test.ts b/server/src/routes/marketplace.test.ts index 2b05f70..795d879 100644 --- a/server/src/routes/marketplace.test.ts +++ b/server/src/routes/marketplace.test.ts @@ -9,6 +9,8 @@ import type { FastifyInstance } from 'fastify'; // available in this test environment — fake it deterministically so the surrounding purchase // flow (DB writes, receipt signing, paywall gating) can be verified end to end. let nextQuote = 0; +let mintCalls = 0; +let failNextMint = false; vi.mock('../services/cashu.js', () => { class FakeCashuMintClient { async requestMintQuote(amountSats: number) { @@ -18,6 +20,11 @@ vi.mock('../services/cashu.js', () => { return true; } async mintAndStore() { + mintCalls += 1; + if (failNextMint) { + failNextMint = false; + throw new Error('injected unknown mint outcome'); + } return 'fake-proof-row-id'; } async payout(lud16: string, amountSats: number) { @@ -41,7 +48,6 @@ vi.mock('../services/cashu.js', () => { class CashuTokenInvalidError extends Error {} return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError, CashuTokenInvalidError }; }); - const { buildApp } = await import('../app.js'); const { loadConfig } = await import('../config.js'); @@ -87,6 +93,7 @@ beforeAll(async () => { DATA_DIR: dataDir, PUBLIC_URL: 'http://localhost:8095', NOSTR_RELAYS: '', + PODSTEADR_ADMIN_PUBKEYS: producerPk, } as NodeJS.ProcessEnv); app = await buildApp({ config, dbPath: ':memory:', logger: false }); @@ -96,7 +103,7 @@ beforeAll(async () => { const podcastRes = await app.inject({ method: 'POST', url: '/api/podcasts', - headers: { cookie: producerCookie }, + headers: { cookie: producerCookie, origin: 'http://localhost:8095' }, payload: { title: 'Paid Show', lightning_address: 'producer@getalby.com' }, }); podcastId = podcastRes.json().id; @@ -105,7 +112,7 @@ beforeAll(async () => { const episodeRes = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes`, - headers: { cookie: producerCookie }, + headers: { cookie: producerCookie, origin: 'http://localhost:8095' }, payload: { title: 'Paid Ep', sha256: sha, size: 1000, mime: 'video/mp4', price_sats: 500 }, }); vi.unstubAllGlobals(); @@ -123,7 +130,7 @@ describe('paywall gating', () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`, - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, }); expect(res.statusCode).toBe(402); expect(res.json().price_sats).toBe(500); @@ -133,7 +140,7 @@ describe('paywall gating', () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`, - headers: { cookie: producerCookie }, + headers: { cookie: producerCookie, origin: 'http://localhost:8095' }, }); expect(res.statusCode).toBe(200); expect(res.json().url).toContain(`${sha}.mp4`); @@ -147,7 +154,7 @@ describe('purchase flow', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, - headers: { cookie: producerCookie }, + headers: { cookie: producerCookie, origin: 'http://localhost:8095' }, payload: { source: 'producer' }, }); expect(res.statusCode).toBe(400); @@ -157,7 +164,7 @@ describe('purchase flow', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, payload: { source: 'producer' }, }); expect(res.statusCode).toBe(201); @@ -170,7 +177,7 @@ describe('purchase flow', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`, - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, }); expect(res.statusCode).toBe(201); const purchase = res.json(); @@ -191,16 +198,73 @@ describe('purchase flow', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`, - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, }); expect(res.statusCode).toBe(200); }); + it('fails closed instead of reminting a paid quote after an interrupted confirmation', async () => { + const sk = generateSecretKey(); + const recoveryBuyerPk = getPublicKey(sk); + const recoveryCookie = await login(sk); + const quoteId = 'interrupted-paid-quote'; + app.ctx.db.prepare( + 'INSERT INTO cashu_quotes (quote_id, purchase_ctx, state, created_at) VALUES (?, ?, ?, ?)', + ).run(quoteId, JSON.stringify({ + episodeId, + buyerPubkey: recoveryBuyerPk, + source: 'producer', + amountSats: 500, + }), 'paid', Math.floor(Date.now() / 1000)); + + const before = mintCalls; + const res = await app.inject({ + method: 'POST', + url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`, + headers: { cookie: recoveryCookie, origin: 'http://localhost:8095' }, + }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toMatch(/refusing to mint it again/); + expect(mintCalls).toBe(before); + }); + + it('persists the recovery guard when the mint outcome is unknown', async () => { + const sk = generateSecretKey(); + const recoveryCookie = await login(sk); + const requested = await app.inject({ + method: 'POST', + url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, + headers: { cookie: recoveryCookie, origin: 'http://localhost:8095' }, + payload: { source: 'producer' }, + }); + expect(requested.statusCode).toBe(201); + const quoteId = requested.json().quoteId as string; + failNextMint = true; + const before = mintCalls; + + const failed = await app.inject({ + method: 'POST', + url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`, + headers: { cookie: recoveryCookie, origin: 'http://localhost:8095' }, + }); + expect(failed.statusCode).toBe(503); + expect(app.ctx.db.prepare('SELECT state FROM cashu_quotes WHERE quote_id = ?').get(quoteId)) + .toEqual({ state: 'paid' }); + + const retry = await app.inject({ + method: 'POST', + url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`, + headers: { cookie: recoveryCookie, origin: 'http://localhost:8095' }, + }); + expect(retry.statusCode).toBe(409); + expect(mintCalls).toBe(before + 1); + }); + it('rejects buying the same episode twice', async () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, payload: { source: 'producer' }, }); expect(res.statusCode).toBe(400); @@ -210,7 +274,7 @@ describe('purchase flow', () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`, - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, }); expect(res.statusCode).toBe(200); expect(res.json().url).toContain(`${sha}.mp4`); @@ -232,7 +296,7 @@ describe('paying with a Cashu token', () => { const podcastRes = await app.inject({ method: 'POST', url: '/api/podcasts', - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { title: 'Token-Paid Show', lightning_address: 'tokenproducer@getalby.com' }, }); tokenPodcastId = podcastRes.json().id; @@ -241,7 +305,7 @@ describe('paying with a Cashu token', () => { const episodeRes = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { title: 'Token-Paid Ep', sha256: 'f'.repeat(64), size: 1000, mime: 'video/mp4', price_sats: 500 }, }); vi.unstubAllGlobals(); @@ -255,7 +319,7 @@ describe('paying with a Cashu token', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { source: 'producer', token: 'fake-token-500' }, }); expect(res.statusCode).toBe(201); @@ -271,7 +335,7 @@ describe('paying with a Cashu token', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { source: 'producer', token: 'fake-token-600' }, }); expect(res.statusCode).toBe(201); @@ -284,7 +348,7 @@ describe('paying with a Cashu token', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { source: 'producer', token: 'fake-token-100' }, }); expect(res.statusCode).toBe(400); @@ -297,7 +361,7 @@ describe('paying with a Cashu token', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, - headers: { cookie }, + headers: { cookie, origin: 'http://localhost:8095' }, payload: { source: 'producer', token: 'not-a-real-token' }, }); expect(res.statusCode).toBe(400); @@ -316,7 +380,7 @@ describe('resale', () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`, - headers: { cookie: strangerCookie }, + headers: { cookie: strangerCookie, origin: 'http://localhost:8095' }, payload: { download_url: resellerMirrorUrl, price_sats: 300 }, }); expect(res.statusCode).toBe(403); @@ -329,7 +393,7 @@ describe('resale', () => { res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`, - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, payload: { download_url: resellerMirrorUrl, price_sats: 300 }, }); } finally { @@ -360,7 +424,7 @@ describe('resale', () => { const purchaseRes = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, - headers: { cookie: secondBuyerCookie }, + headers: { cookie: secondBuyerCookie, origin: 'http://localhost:8095' }, payload: { source: buyerPk }, }); expect(purchaseRes.statusCode).toBe(201); @@ -370,7 +434,7 @@ describe('resale', () => { const confirmRes = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${resellerQuoteId}/confirm`, - headers: { cookie: secondBuyerCookie }, + headers: { cookie: secondBuyerCookie, origin: 'http://localhost:8095' }, }); expect(confirmRes.statusCode).toBe(201); const purchase = confirmRes.json(); @@ -396,20 +460,20 @@ describe('resale', () => { describe('earnings', () => { it('credits the producer for both the direct sale and their share of the resale', async () => { - const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: producerCookie } }); + const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: producerCookie, origin: 'http://localhost:8095' } }); expect(res.statusCode).toBe(200); // 500 sats direct sale + 50% of the 300-sat resale (default resale_producer_share_pct) expect(res.json().unwithdrawn_sats).toBe(500 + 150); }); it("credits the reseller for their share of the resale they made", async () => { - const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie } }); + const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie, origin: 'http://localhost:8095' } }); expect(res.statusCode).toBe(200); expect(res.json().unwithdrawn_sats).toBe(150); }); it('refuses to withdraw without a lightning address on file', async () => { - const res = await app.inject({ method: 'POST', url: '/api/earnings/withdraw', headers: { cookie: buyerCookie } }); + const res = await app.inject({ method: 'POST', url: '/api/earnings/withdraw', headers: { cookie: buyerCookie, origin: 'http://localhost:8095' } }); expect(res.statusCode).toBe(400); }); @@ -417,19 +481,19 @@ describe('earnings', () => { const res = await app.inject({ method: 'POST', url: '/api/earnings/withdraw', - headers: { cookie: buyerCookie }, + headers: { cookie: buyerCookie, origin: 'http://localhost:8095' }, payload: { lud16: 'reseller@getalby.com' }, }); expect(res.statusCode).toBe(200); expect(res.json().paid_sats).toBe(150); expect(res.json().preimage).toContain('reseller@getalby.com'); - const after = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie } }); + const after = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie, origin: 'http://localhost:8095' } }); expect(after.json().unwithdrawn_sats).toBe(0); }); it('remembers the lud16 for next time', async () => { - const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: buyerCookie } }); + const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: buyerCookie, origin: 'http://localhost:8095' } }); expect(res.json().lud16).toBe('reseller@getalby.com'); }); }); diff --git a/server/src/routes/marketplace.ts b/server/src/routes/marketplace.ts index 9179341..6a79a5d 100644 --- a/server/src/routes/marketplace.ts +++ b/server/src/routes/marketplace.ts @@ -130,6 +130,11 @@ export default async function marketplaceRoutes(app: FastifyInstance) { if (quoteRow.state === 'settled') { return getPurchase.get(eid, ctx.buyerPubkey) as Purchase; } + if (quoteRow.state === 'paid') { + return reply.code(409).send({ + error: 'paid quote requires recovery; refusing to mint it again', + }); + } // Resolve seller(s) + split now, at confirm time, rather than trusting whatever was // true when the quote was requested (a reseller could have been revoked meanwhile). @@ -161,7 +166,31 @@ export default async function marketplaceRoutes(app: FastifyInstance) { const paid = await cashu.isQuotePaid(quoteId); if (!paid) return reply.code(402).send({ error: 'invoice not paid yet' }); - await cashu.mintAndStore(ctx.amountSats, quoteId); + // Claim this paid quote before the externally visible mint operation. + // Only one confirmer can move pending -> paid. If the process stops after + // this point, a retry fails closed instead of attempting a second mint. + // Completing an interrupted `paid` quote needs mint-side reconciliation; + // it must never request another invoice or blindly mint again. + const claimed = db.prepare( + "UPDATE cashu_quotes SET state = 'paid' WHERE quote_id = ? AND state = 'pending'", + ).run(quoteId).changes; + if (claimed !== 1) { + const current = getQuote.get(quoteId) as { state: string } | undefined; + if (current?.state === 'settled') { + return getPurchase.get(eid, ctx.buyerPubkey) as Purchase; + } + return reply.code(409).send({ + error: 'paid quote requires recovery; refusing to mint it again', + }); + } + + try { + await cashu.mintAndStore(ctx.amountSats, quoteId); + } catch { + return reply.code(503).send({ + error: 'mint outcome requires recovery; refusing automatic retry', + }); + } const purchaseId = randomUUID(); const receipt = await publisher.publishPurchaseReceipt(settings.all().relays, { diff --git a/server/src/services/settings.test.ts b/server/src/services/settings.test.ts new file mode 100644 index 0000000..7cd0fe1 --- /dev/null +++ b/server/src/services/settings.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { loadConfig } from '../config.js'; +import { openDatabase, type DB } from '../db/database.js'; +import { SettingsService } from './settings.js'; + +const boundA = 'a'.repeat(64); +const boundB = 'b'.repeat(64); +const unbound = 'c'.repeat(64); +const databases: DB[] = []; + +function service(bindings: string, db = openDatabase(':memory:')): { db: DB; settings: SettingsService } { + databases.push(db); + const config = loadConfig({ PODSTEADR_ADMIN_PUBKEYS: bindings } as NodeJS.ProcessEnv); + return { db, settings: new SettingsService(db, config) }; +} + +afterEach(() => { + for (const db of databases.splice(0)) { + if (db.open) db.close(); + } +}); + +describe('operator-bound first-admin bootstrap', () => { + it('does not let an earlier unbound user claim administrator authority', () => { + const { db, settings } = service(`${boundA},${boundB}`); + db.prepare( + 'INSERT INTO users (pubkey, created_at, last_login_at) VALUES (?, ?, ?)', + ).run(unbound, 1, 1); + + expect(settings.claimAdminIfAuthorized(unbound)).toBe(false); + expect(settings.get('admin_pubkey')).toBeNull(); + expect(settings.claimAdminIfAuthorized(boundA)).toBe(true); + expect(settings.get('admin_pubkey')).toBe(boundA); + }); + + it('never reassigns an existing administrator to another bound identity', () => { + const { settings } = service(`${boundA},${boundB}`); + expect(settings.claimAdminIfAuthorized(boundA)).toBe(true); + expect(settings.claimAdminIfAuthorized(boundB)).toBe(false); + expect(settings.get('admin_pubkey')).toBe(boundA); + }); + + it('preserves an existing administrator even when current bindings differ', () => { + const db = openDatabase(':memory:'); + db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run('admin_pubkey', unbound); + const { settings } = service(`${boundA},${boundB}`, db); + + expect(settings.isAdmin(unbound)).toBe(true); + expect(settings.claimAdminIfAuthorized(boundA)).toBe(false); + expect(settings.get('admin_pubkey')).toBe(unbound); + }); +}); diff --git a/server/src/services/settings.ts b/server/src/services/settings.ts index 713e5de..a81781e 100644 --- a/server/src/services/settings.ts +++ b/server/src/services/settings.ts @@ -56,9 +56,22 @@ export class SettingsService { return publicBlossomUrl; } - /** First pubkey to ever log in becomes the admin. */ - claimAdminIfUnset(pubkey: string): void { - if (!this.get('admin_pubkey')) this.set('admin_pubkey', pubkey); + /** + * Claim an uninitialised installation only for an operator-bound identity. + * + * INSERT OR IGNORE makes competing first logins atomic. An existing admin is + * deliberately never changed here, including when the node identity list is + * later edited; reassignment requires a separate explicit migration policy. + */ + claimAdminIfAuthorized(pubkey: string): boolean { + const existing = this.get('admin_pubkey'); + if (existing) return existing === pubkey; + if (!this.config.PODSTEADR_ADMIN_PUBKEYS.includes(pubkey)) return false; + + this.db + .prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)') + .run('admin_pubkey', pubkey); + return this.isAdmin(pubkey); } isAdmin(pubkey: string): boolean {