1047 lines
42 KiB
Diff
1047 lines
42 KiB
Diff
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<FastifyInstance> {
|
|
+ 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<typeof envSchema> & { defaultRelays: string[] };
|
|
+export type Config = z.infer<typeof envSchema> & {
|
|
+ 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 {
|
|
|