Merge branch 'name-request-refactor' into 'main'

Name request refactor

See merge request soapbox-pub/ditto!708
This commit is contained in:
Alex Gleason 2025-03-03 21:41:36 +00:00
commit 393d87071b
19 changed files with 477 additions and 189 deletions

View file

@ -74,6 +74,7 @@
"@soapbox/logi": "jsr:@soapbox/logi@^0.3.0",
"@soapbox/safe-fetch": "jsr:@soapbox/safe-fetch@^2.0.0",
"@std/assert": "jsr:@std/assert@^0.225.1",
"@std/async": "jsr:@std/async@^1.0.10",
"@std/cli": "jsr:@std/cli@^0.223.0",
"@std/crypto": "jsr:@std/crypto@^0.224.0",
"@std/encoding": "jsr:@std/encoding@^0.224.0",

5
deno.lock generated
View file

@ -58,6 +58,7 @@
"jsr:@std/assert@^1.0.10": "1.0.11",
"jsr:@std/assert@~0.213.1": "0.213.1",
"jsr:@std/assert@~0.225.1": "0.225.3",
"jsr:@std/async@^1.0.10": "1.0.10",
"jsr:@std/bytes@0.223": "0.223.0",
"jsr:@std/bytes@0.224": "0.224.0",
"jsr:@std/bytes@0.224.0": "0.224.0",
@ -604,6 +605,9 @@
"jsr:@std/internal@^1.0.5"
]
},
"@std/async@1.0.10": {
"integrity": "2ff1b1c7d33d1416159989b0f69e59ec7ee8cb58510df01e454def2108b3dbec"
},
"@std/bytes@0.223.0": {
"integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8"
},
@ -2489,6 +2493,7 @@
"jsr:@soapbox/logi@0.3",
"jsr:@soapbox/safe-fetch@2",
"jsr:@std/assert@~0.225.1",
"jsr:@std/async@^1.0.10",
"jsr:@std/cli@0.223",
"jsr:@std/crypto@0.224",
"jsr:@std/encoding@0.224",

View file

@ -0,0 +1,25 @@
import { DittoConf } from '@ditto/conf';
import { NPostgres } from '@nostrify/db';
import { genEvent } from '@nostrify/nostrify/test';
import { assertEquals } from '@std/assert';
import { DittoPolyPg } from './DittoPolyPg.ts';
import { TestDB } from './TestDB.ts';
Deno.test('TestDB', async () => {
const conf = new DittoConf(Deno.env);
const orig = new DittoPolyPg(conf.databaseUrl);
await using db = new TestDB(orig);
await db.migrate();
await db.clear();
const store = new NPostgres(orig.kysely);
await store.event(genEvent());
assertEquals((await store.count([{}])).count, 1);
await db.clear();
assertEquals((await store.count([{}])).count, 0);
});

View file

@ -0,0 +1,49 @@
import { type Kysely, sql } from 'kysely';
import type { DittoDB } from '../DittoDB.ts';
import type { DittoTables } from '../DittoTables.ts';
/** Wraps another DittoDB implementation to clear all data when disposed. */
export class TestDB implements DittoDB {
constructor(private db: DittoDB) {}
get kysely(): Kysely<DittoTables> {
return this.db.kysely;
}
get poolSize(): number {
return this.db.poolSize;
}
get availableConnections(): number {
return this.db.availableConnections;
}
migrate(): Promise<void> {
return this.db.migrate();
}
listen(channel: string, callback: (payload: string) => void): void {
return this.db.listen(channel, callback);
}
/** Truncate all tables. */
async clear(): Promise<void> {
const query = sql<{ tablename: string }>`select tablename from pg_tables where schemaname = current_schema()`;
const { rows } = await query.execute(this.db.kysely);
for (const { tablename } of rows) {
if (tablename.startsWith('kysely_')) {
continue; // Skip Kysely's internal tables
} else {
await sql`truncate table ${sql.ref(tablename)} cascade`.execute(this.db.kysely);
}
}
}
async [Symbol.asyncDispose](): Promise<void> {
await this.clear();
await this.db[Symbol.asyncDispose]();
}
}

View file

@ -2,6 +2,7 @@ export { DittoPglite } from './adapters/DittoPglite.ts';
export { DittoPolyPg } from './adapters/DittoPolyPg.ts';
export { DittoPostgres } from './adapters/DittoPostgres.ts';
export { DummyDB } from './adapters/DummyDB.ts';
export { TestDB } from './adapters/TestDB.ts';
export type { DittoDB } from './DittoDB.ts';
export type { DittoTables } from './DittoTables.ts';

View file

@ -55,8 +55,6 @@ import {
adminSetRelaysController,
deleteZapSplitsController,
getZapSplitsController,
nameRequestController,
nameRequestsController,
statusZapSplitsController,
updateInstanceController,
updateZapSplitsController,
@ -149,6 +147,7 @@ import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
import dittoNamesRoute from '@/routes/dittoNamesRoute.ts';
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
export interface AppEnv extends DittoEnv {
@ -446,8 +445,7 @@ app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSe
app.put('/api/v1/admin/ditto/instance', userMiddleware({ role: 'admin' }), updateInstanceController);
app.post('/api/v1/ditto/names', userMiddleware(), nameRequestController);
app.get('/api/v1/ditto/names', userMiddleware(), nameRequestsController);
app.route('/api/v1/ditto/names', dittoNamesRoute);
app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController);
app.post(

View file

@ -1,19 +1,17 @@
import { paginated } from '@ditto/mastoapi/pagination';
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
import { AppController } from '@/app.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getAuthor } from '@/queries.ts';
import { addTag } from '@/utils/tags.ts';
import { createEvent, parseBody, updateAdminEvent } from '@/utils/api.ts';
import { parseBody, updateAdminEvent } from '@/utils/api.ts';
import { getInstanceMetadata } from '@/utils/instance.ts';
import { deleteTag } from '@/utils/tags.ts';
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
import { screenshotsSchema } from '@/schemas/nostr.ts';
import { booleanParamSchema, percentageSchema } from '@/schema.ts';
import { percentageSchema } from '@/schema.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderNameRequest } from '@/views/ditto.ts';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts';
import { updateListAdminEvent } from '@/utils/api.ts';
@ -81,102 +79,6 @@ function renderRelays(event: NostrEvent): RelayEntity[] {
}, [] as RelayEntity[]);
}
const nameRequestSchema = z.object({
name: z.string().email(),
reason: z.string().max(500).optional(),
});
export const nameRequestController: AppController = async (c) => {
const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey();
const result = nameRequestSchema.safeParse(await c.req.json());
if (!result.success) {
return c.json({ error: 'Invalid username', schema: result.error }, 400);
}
const { name, reason } = result.data;
const [existing] = await relay.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]);
if (existing) {
return c.json({ error: 'Name request already exists' }, 400);
}
const r: string[][] = [['r', name]];
if (name !== name.toLowerCase()) {
r.push(['r', name.toLowerCase()]);
}
const event = await createEvent({
kind: 3036,
content: reason,
tags: [
...r,
['L', 'nip05.domain'],
['l', name.split('@')[1], 'nip05.domain'],
['p', await conf.signer.getPublicKey()],
],
}, c);
await hydrateEvents({ ...c.var, events: [event] });
const nameRequest = await renderNameRequest(event);
return c.json(nameRequest);
};
const nameRequestsSchema = z.object({
approved: booleanParamSchema.optional(),
rejected: booleanParamSchema.optional(),
});
export const nameRequestsController: AppController = async (c) => {
const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey();
const params = c.get('pagination');
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
const filter: NostrFilter = {
kinds: [30383],
authors: [await conf.signer.getPublicKey()],
'#k': ['3036'],
'#p': [pubkey],
...params,
};
if (approved) {
filter['#n'] = ['approved'];
}
if (rejected) {
filter['#n'] = ['rejected'];
}
const orig = await relay.query([filter]);
const ids = new Set<string>();
for (const event of orig) {
const d = event.tags.find(([name]) => name === 'd')?.[1];
if (d) {
ids.add(d);
}
}
if (!ids.size) {
return c.json([]);
}
const events = await relay.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
.then((events) => hydrateEvents({ ...c.var, events }));
const nameRequests = await Promise.all(
events.map((event) => renderNameRequest(event)),
);
return paginated(c, orig, nameRequests);
};
const zapSplitSchema = z.record(
n.id(),
z.object({

View file

@ -0,0 +1,62 @@
import { TestApp } from '@ditto/mastoapi/test';
import { assertEquals } from '@std/assert';
import route from './dittoNamesRoute.ts';
Deno.test('POST / creates a name request event', async () => {
await using app = new TestApp();
const { conf, relay } = app.var;
const user = app.user();
app.route('/', route);
const response = await app.api.post('/', { name: 'Alex@Ditto.pub', reason: 'for testing' });
assertEquals(response.status, 200);
const [event] = await relay.query([{ kinds: [3036], authors: [await user.signer.getPublicKey()] }]);
assertEquals(event?.tags, [
['r', 'Alex@Ditto.pub'],
['r', 'alex@ditto.pub'],
['L', 'nip05.domain'],
['l', 'ditto.pub', 'nip05.domain'],
['p', await conf.signer.getPublicKey()],
]);
assertEquals(event?.content, 'for testing');
});
Deno.test('POST / can be called multiple times with the same name', async () => {
await using app = new TestApp();
app.user();
app.route('/', route);
const response1 = await app.api.post('/', { name: 'alex@ditto.pub' });
const response2 = await app.api.post('/', { name: 'alex@ditto.pub' });
assertEquals(response1.status, 200);
assertEquals(response2.status, 200);
});
Deno.test('POST / returns 400 if the name has already been granted', async () => {
await using app = new TestApp();
const { conf, relay } = app.var;
app.user();
app.route('/', route);
const grant = await conf.signer.signEvent({
kind: 30360,
tags: [['d', 'alex@ditto.pub']],
content: '',
created_at: 0,
});
await relay.event(grant);
const response = await app.api.post('/', { name: 'alex@ditto.pub' });
assertEquals(response.status, 400);
});

View file

@ -0,0 +1,130 @@
import { paginationMiddleware, userMiddleware } from '@ditto/mastoapi/middleware';
import { DittoRoute } from '@ditto/mastoapi/router';
import { z } from 'zod';
import { createEvent } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderNameRequest } from '@/views/ditto.ts';
import { booleanParamSchema } from '@/schema.ts';
import { NostrFilter } from '@nostrify/nostrify';
const nameRequestSchema = z.object({
name: z.string().email(),
reason: z.string().max(500).optional(),
});
const route = new DittoRoute();
route.post('/', userMiddleware(), async (c) => {
const { conf, relay, user } = c.var;
const result = nameRequestSchema.safeParse(await c.req.json());
if (!result.success) {
return c.json({ error: 'Invalid username', schema: result.error }, 422);
}
const pubkey = await user.signer.getPublicKey();
const adminPubkey = await conf.signer.getPublicKey();
const { name, reason } = result.data;
const [_localpart, domain] = name.split('@');
if (domain.toLowerCase() !== conf.url.host.toLowerCase()) {
return c.json({ error: 'Unsupported domain' }, 422);
}
const d = name.toLowerCase();
const [grant] = await relay.query([{ kinds: [30360], authors: [adminPubkey], '#d': [d] }]);
if (grant) {
return c.json({ error: 'Name has already been granted' }, 400);
}
const [pending] = await relay.query([{
kinds: [30383],
authors: [adminPubkey],
'#p': [pubkey],
'#k': ['3036'],
'#r': [d],
'#n': ['pending'],
limit: 1,
}]);
if (pending) {
return c.json({ error: 'You have already requested that name, and it is pending approval by staff' }, 400);
}
const tags: string[][] = [['r', name]];
if (name !== name.toLowerCase()) {
tags.push(['r', name.toLowerCase()]);
}
const event = await createEvent({
kind: 3036,
content: reason,
tags: [
...tags,
['L', 'nip05.domain'],
['l', domain.toLowerCase(), 'nip05.domain'],
['p', await conf.signer.getPublicKey()],
],
}, c);
await hydrateEvents({ ...c.var, events: [event] });
const nameRequest = await renderNameRequest(event);
return c.json(nameRequest);
});
const nameRequestsSchema = z.object({
approved: booleanParamSchema.optional(),
rejected: booleanParamSchema.optional(),
});
route.get('/', paginationMiddleware(), userMiddleware(), async (c) => {
const { conf, relay, user, pagination } = c.var;
const pubkey = await user!.signer.getPublicKey();
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
const filter: NostrFilter = {
kinds: [30383],
authors: [await conf.signer.getPublicKey()],
'#k': ['3036'],
'#p': [pubkey],
...pagination,
};
if (approved) {
filter['#n'] = ['approved'];
}
if (rejected) {
filter['#n'] = ['rejected'];
}
const orig = await relay.query([filter]);
const ids = new Set<string>();
for (const event of orig) {
const d = event.tags.find(([name]) => name === 'd')?.[1];
if (d) {
ids.add(d);
}
}
if (!ids.size) {
return c.json([]);
}
const events = await relay.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
.then((events) => hydrateEvents({ ...c.var, events }));
const nameRequests = await Promise.all(
events.map((event) => renderNameRequest(event)),
);
return c.var.paginate(orig, nameRequests);
});
export default route;

View file

@ -2,12 +2,39 @@ import { DittoPolyPg } from '@ditto/db';
import { DittoConf } from '@ditto/conf';
import { genEvent, MockRelay } from '@nostrify/nostrify/test';
import { assertEquals } from '@std/assert';
import { waitFor } from '@std/async/unstable-wait-for';
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { DittoRelayStore } from './DittoRelayStore.ts';
import type { NostrMetadata } from '@nostrify/types';
Deno.test('generates set event for nip05 request', async () => {
await using test = setupTest();
const admin = await test.conf.signer.getPublicKey();
const event = genEvent({ kind: 3036, tags: [['r', 'alex@gleasonator.dev'], ['p', admin]] });
await test.store.event(event);
const filter = { kinds: [30383], authors: [admin], '#d': [event.id] };
await waitFor(async () => {
const { count } = await test.store.count([filter]);
return count > 0;
}, 3000);
const [result] = await test.store.query([filter]);
assertEquals(result?.tags, [
['d', event.id],
['p', event.pubkey],
['k', '3036'],
['r', 'alex@gleasonator.dev'],
['n', 'pending'],
]);
});
Deno.test('updateAuthorData sets nip05', async () => {
const alex = generateSecretKey();
@ -38,20 +65,25 @@ Deno.test('updateAuthorData sets nip05', async () => {
assertEquals(row?.nip05_hostname, 'gleasonator.dev');
});
function setupTest(cb: (req: Request) => Response | Promise<Response>) {
function setupTest(cb?: (req: Request) => Response | Promise<Response>) {
const conf = new DittoConf(Deno.env);
const db = new DittoPolyPg(conf.databaseUrl);
const relay = new MockRelay();
const mockFetch: typeof fetch = async (input, init) => {
const req = new Request(input, init);
return await cb(req);
if (cb) {
return await cb(req);
} else {
return new Response('Not mocked', { status: 404 });
}
};
const store = new DittoRelayStore({ conf, db, relay, fetch: mockFetch });
return {
db,
conf,
store,
[Symbol.asyncDispose]: async () => {
await store[Symbol.asyncDispose]();

View file

@ -358,19 +358,24 @@ export class DittoRelayStore implements NRelay {
}
if (event.kind === 3036 && tagsAdmin) {
const rel = await signer.signEvent({
kind: 30383,
content: '',
tags: [
['d', event.id],
['p', event.pubkey],
['k', '3036'],
['n', 'pending'],
],
created_at: Math.floor(Date.now() / 1000),
});
const r = event.tags.find(([name]) => name === 'r')?.[1];
await this.event(rel, { signal: AbortSignal.timeout(1000) });
if (r) {
const rel = await signer.signEvent({
kind: 30383,
content: '',
tags: [
['d', event.id],
['p', event.pubkey],
['k', '3036'],
['r', r.toLowerCase()],
['n', 'pending'],
],
created_at: Math.floor(Date.now() / 1000),
});
await this.event(rel, { signal: AbortSignal.timeout(1000) });
}
}
}

View file

@ -58,17 +58,19 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
return result;
}, new Set<string>());
const favicons = (
await db.kysely
.selectFrom('domain_favicons')
.select(['domain', 'favicon'])
.where('domain', 'in', [...domains])
.execute()
)
.reduce((result, { domain, favicon }) => {
result[domain] = favicon;
return result;
}, {} as Record<string, string>);
const favicons: Record<string, string> = domains.size
? (
await db.kysely
.selectFrom('domain_favicons')
.select(['domain', 'favicon'])
.where('domain', 'in', [...domains])
.execute()
)
.reduce((result, { domain, favicon }) => {
result[domain] = favicon;
return result;
}, {} as Record<string, string>)
: {};
const stats = {
authors: authorStats,

View file

@ -27,10 +27,10 @@ async function createEvent<E extends (DittoEnv & { Variables: { user?: User } })
}
const event = await user.signer.signEvent({
content: '',
created_at: nostrNow(),
tags: [],
...t,
content: t.content ?? '',
created_at: t.created_at ?? nostrNow(),
tags: t.tags ?? [],
});
await relay.event(event, { signal, publish: true });

View file

@ -1,21 +1,26 @@
import { setUser, testApp } from '@ditto/mastoapi/test';
import { TestApp } from '@ditto/mastoapi/test';
import { assertEquals } from '@std/assert';
import { userMiddleware } from './userMiddleware.ts';
import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts';
Deno.test('no user 401', async () => {
const { app } = testApp();
await using app = new TestApp();
const response = await app.use(userMiddleware()).request('/');
assertEquals(response.status, 401);
});
Deno.test('unsupported signer 400', async () => {
const { app, relay } = testApp();
const signer = new ReadOnlySigner('0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd');
await using app = new TestApp();
const user = {
signer: new ReadOnlySigner('0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd'),
relay: app.var.relay,
};
app.user(user);
const response = await app
.use(setUser({ signer, relay }))
.use(userMiddleware({ enc: 'nip44' }))
.use((c, next) => {
c.var.user.signer.nip44.encrypt; // test that the type is set
@ -27,10 +32,11 @@ Deno.test('unsupported signer 400', async () => {
});
Deno.test('with user 200', async () => {
const { app, user } = testApp();
await using app = new TestApp();
app.user();
const response = await app
.use(setUser(user))
.use(userMiddleware())
.get('/', (c) => c.text('ok'))
.request('/');
@ -39,10 +45,11 @@ Deno.test('with user 200', async () => {
});
Deno.test('user and role 403', async () => {
const { app, user } = testApp();
await using app = new TestApp();
app.user();
const response = await app
.use(setUser(user))
.use(userMiddleware({ role: 'admin' }))
.request('/');
@ -50,7 +57,10 @@ Deno.test('user and role 403', async () => {
});
Deno.test('admin role 200', async () => {
const { conf, app, user, relay } = testApp();
await using app = new TestApp();
const { conf, relay } = app.var;
const user = app.user();
const event = await conf.signer.signEvent({
kind: 30382,
@ -65,7 +75,6 @@ Deno.test('admin role 200', async () => {
await relay.event(event);
const response = await app
.use(setUser(user))
.use(userMiddleware({ role: 'admin' }))
.get('/', (c) => c.text('ok'))
.request('/');

View file

@ -1,13 +1,14 @@
import { DittoConf } from '@ditto/conf';
import { DittoPolyPg } from '@ditto/db';
import { DummyDB } from '@ditto/db';
import { Hono } from '@hono/hono';
import { MockRelay } from '@nostrify/nostrify/test';
import { assertEquals } from '@std/assert';
import { DittoApp } from './DittoApp.ts';
import { DittoRoute } from './DittoRoute.ts';
Deno.test('DittoApp', async () => {
await using db = new DittoPolyPg('memory://');
await using db = new DummyDB();
const conf = new DittoConf(new Map());
const relay = new MockRelay();
@ -20,4 +21,11 @@ Deno.test('DittoApp', async () => {
// @ts-expect-error Passing a non-DittoRoute to route.
app.route('/', hono);
app.get('/error', () => {
throw new Error('test error');
});
const response = await app.request('/error');
assertEquals(response.status, 500);
});

View file

@ -3,11 +3,13 @@ import { Hono } from '@hono/hono';
import type { HonoOptions } from '@hono/hono/hono-base';
import type { DittoEnv } from './DittoEnv.ts';
export type DittoAppOpts = Omit<DittoEnv['Variables'], 'signal' | 'requestId'> & HonoOptions<DittoEnv>;
export class DittoApp extends Hono<DittoEnv> {
// @ts-ignore Require a DittoRoute for type safety.
declare route: (path: string, app: Hono<DittoEnv>) => Hono<DittoEnv>;
constructor(opts: Omit<DittoEnv['Variables'], 'signal' | 'requestId'> & HonoOptions<DittoEnv>) {
constructor(protected opts: DittoAppOpts) {
super(opts);
this.use((c, next) => {

View file

@ -50,6 +50,6 @@ export class DittoRoute extends Hono<DittoEnv> {
}
}
return c.json({ error: 'Something went wrong' }, 500);
throw error;
};
}

View file

@ -1,41 +1 @@
import { DittoConf } from '@ditto/conf';
import { type DittoDB, DummyDB } from '@ditto/db';
import { DittoApp, type DittoMiddleware } from '@ditto/mastoapi/router';
import { type NostrSigner, type NRelay, NSecSigner } from '@nostrify/nostrify';
import { MockRelay } from '@nostrify/nostrify/test';
import { generateSecretKey, nip19 } from 'nostr-tools';
import type { User } from '@ditto/mastoapi/middleware';
export function testApp(): {
app: DittoApp;
relay: NRelay;
conf: DittoConf;
db: DittoDB;
user: {
signer: NostrSigner;
relay: NRelay;
};
} {
const db = new DummyDB();
const nsec = nip19.nsecEncode(generateSecretKey());
const conf = new DittoConf(new Map([['DITTO_NSEC', nsec]]));
const relay = new MockRelay();
const app = new DittoApp({ conf, relay, db });
const user = {
signer: new NSecSigner(generateSecretKey()),
relay,
};
return { app, relay, conf, db, user };
}
export function setUser<S extends NostrSigner>(user: User<S>): DittoMiddleware<{ user: User<S> }> {
return async (c, next) => {
c.set('user', user);
await next();
};
}
export { TestApp } from './test/TestApp.ts';

View file

@ -0,0 +1,97 @@
import { DittoConf } from '@ditto/conf';
import { type DittoDB, DummyDB } from '@ditto/db';
import { HTTPException } from '@hono/hono/http-exception';
import { type NRelay, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { DittoApp, type DittoAppOpts } from '../router/DittoApp.ts';
import type { Context } from '@hono/hono';
import type { User } from '../middleware/User.ts';
import { MockRelay } from '@nostrify/nostrify/test';
interface DittoVars {
db: DittoDB;
conf: DittoConf;
relay: NRelay;
}
export class TestApp extends DittoApp implements AsyncDisposable {
private _user?: User;
constructor(opts?: Partial<DittoAppOpts>) {
const nsec = nip19.nsecEncode(generateSecretKey());
const conf = opts?.conf ?? new DittoConf(
new Map([
['DITTO_NSEC', nsec],
['LOCAL_DOMAIN', 'https://ditto.pub'],
]),
);
const db = opts?.db ?? new DummyDB();
const relay = opts?.relay ?? new MockRelay();
super({
db,
conf,
relay,
...opts,
});
this.use(async (c: Context<{ Variables: { user?: User } }>, next) => {
c.set('user', this._user);
await next();
});
this.onError((err, c) => {
if (err instanceof HTTPException) {
if (err.res) {
return err.res;
} else {
return c.json({ error: err.message }, err.status);
}
}
throw err;
});
}
get var(): DittoVars {
return {
db: this.opts.db,
conf: this.opts.conf,
relay: this.opts.relay,
};
}
user(user?: User): User {
user ??= this.createUser();
this._user = user;
return user;
}
createUser(sk?: Uint8Array): User {
return {
relay: this.opts.relay,
signer: new NSecSigner(sk ?? generateSecretKey()),
};
}
api = {
get: async (path: string): Promise<Response> => {
return await this.request(path);
},
post: async (path: string, body: unknown): Promise<Response> => {
return await this.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
};
async [Symbol.asyncDispose](): Promise<void> {
await this.opts.db[Symbol.asyncDispose]();
}
}