import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'; import { randomBytes } from '@noble/ciphers/webcrypto'; import { sha512 } from '@noble/hashes/sha2'; import * as nostrTools from '@nostr/tools'; import { base64 } from '@scure/base'; import { Database } from 'jsr:@db/sqlite'; import { NSchema as n } from 'jsr:@nostrify/nostrify'; import type { NostrClientREQ, NostrEvent, NostrFilter, } from 'jsr:@nostrify/types'; import { encodeBase64 } from 'jsr:@std/encoding@0.224/base64'; import { CHUNK_CLEANUP_INTERVAL, CHUNK_MAX_AGE, POW_TO_MINE, } from './consts.ts'; import { ChunkedEventReceived, EventAlreadyExistsException, createEncryptedEvent, createEncryptedEventForPubkey, decryptEvent, } from './eventEncryptionDecryption.ts'; import { createNewCCN, getActiveCCN, getAllCCNs, getCCNPrivateKeyByPubkey, isAddressableEvent, isArray, isCCNReplaceableEvent, isLocalhost, isReplaceableEvent, isValidJSON, parseATagQuery, } from './utils.ts'; import { encryptUint8Array, encryptionKey } from './utils/encryption.ts'; import { getEveFilePath } from './utils/files.ts'; import { log, setupLogger } from './utils/logs.ts'; import { mixQuery, sql, sqlPartial } from './utils/queries.ts'; await setupLogger(); if (!Deno.env.has('ENCRYPTION_KEY')) { log.error( `Missing ENCRYPTION_KEY. Please set it in your env.\nA new one has been generated for you: ENCRYPTION_KEY="${encodeBase64( randomBytes(32), )}"`, ); Deno.exit(1); } await Deno.mkdir(await getEveFilePath('ccn_keys'), { recursive: true }); const db = new Database(await getEveFilePath('db')); const pool = new nostrTools.SimplePool(); const relays = [ 'wss://relay.arx-ccn.com/', 'wss://relay.dannymorabito.com/', 'wss://nos.lol/', 'wss://nostr.einundzwanzig.space/', 'wss://nostr.massmux.com/', 'wss://nostr.mom/', 'wss://nostr.wine/', 'wss://purplerelay.com/', 'wss://relay.damus.io/', 'wss://relay.goodmorningbitcoin.com/', 'wss://relay.lexingtonbitcoin.org/', 'wss://relay.nostr.band/', 'wss://relay.primal.net/', 'wss://relay.snort.social/', 'wss://strfry.iris.to/', 'wss://cache2.primal.net/v1', ]; export function runMigrations(db: Database, latestVersion: number) { const migrations = [...Deno.readDirSync(`${import.meta.dirname}/migrations`)]; migrations.sort((a, b) => { const aVersion = Number.parseInt(a.name.split('-')[0], 10); const bVersion = Number.parseInt(b.name.split('-')[0], 10); return aVersion - bVersion; }); for (const migrationFile of migrations) { const migrationVersion = Number.parseInt( migrationFile.name.split('-')[0], 10, ); if (migrationVersion > latestVersion) { log.info( `Running migration ${migrationFile.name} (version ${migrationVersion})`, ); const start = Date.now(); const migrationSql = Deno.readTextFileSync( `${import.meta.dirname}/migrations/${migrationFile.name}`, ); db.run('BEGIN TRANSACTION'); try { db.run(migrationSql); const end = Date.now(); const durationMs = end - start; sql` INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status) VALUES (${migrationVersion}, ${migrationFile.name}, ${new Date().toISOString()}, ${durationMs}, 'success'); db.run("COMMIT TRANSACTION"); `(db); } catch (e) { db.run('ROLLBACK TRANSACTION'); const error = e instanceof Error ? e : typeof e === 'string' ? new Error(e) : new Error(JSON.stringify(e)); const end = Date.now(); const durationMs = end - start; sql` INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status, error_message) VALUES (${migrationVersion}, ${migrationFile.name}, ${new Date().toISOString()}, ${durationMs}, 'failed', ${error.message}); `(db); throw e; } db.run('END TRANSACTION'); } } } function addEventToDb( decryptedEvent: nostrTools.VerifiedEvent, encryptedEvent: nostrTools.VerifiedEvent, ccnPubkey: string, ) { const existingEvent = sql` SELECT * FROM events WHERE id = ${decryptedEvent.id} `(db)[0]; if (existingEvent) throw new EventAlreadyExistsException(); const isInvite = decryptedEvent.tags.findIndex( (tag: string[]) => tag[0] === 'type' && tag[1] === 'invite', ) !== -1; if (isInvite) { const shadContent = bytesToHex( sha512.create().update(decryptedEvent.content).digest(), ); const inviteUsed = sql` SELECT COUNT(*) as count FROM inviter_invitee WHERE invite_hash = ${shadContent} `(db)[0].count; if (inviteUsed > 0) { throw new Error('Invite already used'); } const inviteEvent = sql` SELECT * FROM events WHERE kind = 9999 AND id IN ( SELECT event_id FROM event_tags WHERE tag_name = 'i' AND tag_id IN ( SELECT tag_id FROM event_tags_values WHERE value_position = 1 AND value = ${shadContent} ) ) `(db)[0]; if (!inviteEvent) { throw new Error('Invite event not found'); } const inviterPubkey = inviteEvent.pubkey; const inviteePubkey = decryptedEvent.pubkey; db.run('BEGIN TRANSACTION'); sql` INSERT INTO inviter_invitee (ccn_pubkey, inviter_pubkey, invitee_pubkey, invite_hash) VALUES (${ccnPubkey}, ${inviterPubkey}, ${inviteePubkey}, ${shadContent}) `(db); sql` INSERT INTO allowed_writes (ccn_pubkey, pubkey) VALUES (${ccnPubkey}, ${inviteePubkey}) `(db); db.run('COMMIT TRANSACTION'); const allowedPubkeys = sql` SELECT pubkey FROM allowed_writes WHERE ccn_pubkey = ${ccnPubkey} `(db).flatMap((row) => row.pubkey); const ccnName = sql` SELECT name FROM ccns WHERE pubkey = ${ccnPubkey} `(db)[0].name; getCCNPrivateKeyByPubkey(ccnPubkey).then((ccnPrivateKey) => { if (!ccnPrivateKey) { throw new Error('CCN private key not found'); } const tags = allowedPubkeys.map((pubkey) => ['p', pubkey]); tags.push(['t', 'invite']); tags.push(['name', ccnName]); const privateKeyEvent = nostrTools.finalizeEvent( nostrTools.nip13.minePow( { kind: 9998, created_at: Date.now(), content: base64.encode(ccnPrivateKey), tags, pubkey: ccnPubkey, }, POW_TO_MINE, ), ccnPrivateKey, ); const encryptedKeyEvent = createEncryptedEventForPubkey( inviteePubkey, privateKeyEvent, ); publishToRelays(encryptedKeyEvent); }); return; } const isAllowedWrite = sql` SELECT COUNT(*) as count FROM allowed_writes WHERE ccn_pubkey = ${ccnPubkey} AND pubkey = ${decryptedEvent.pubkey} `(db)[0].count; if (isAllowedWrite === 0) { throw new Error('Not allowed to write to this CCN'); } try { db.run('BEGIN TRANSACTION'); if (isReplaceableEvent(decryptedEvent.kind)) { sql` UPDATE events SET replaced = 1 WHERE kind = ${decryptedEvent.kind} AND pubkey = ${decryptedEvent.pubkey} AND created_at < ${decryptedEvent.created_at} AND ccn_pubkey = ${ccnPubkey} `(db); } if (isAddressableEvent(decryptedEvent.kind)) { const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1]; if (dTag) { sql` UPDATE events SET replaced = 1 WHERE kind = ${decryptedEvent.kind} AND pubkey = ${decryptedEvent.pubkey} AND created_at < ${decryptedEvent.created_at} AND ccn_pubkey = ${ccnPubkey} AND id IN ( SELECT event_id FROM event_tags WHERE tag_name = 'd' AND tag_id IN ( SELECT tag_id FROM event_tags_values WHERE value_position = 1 AND value = ${dTag} ) ) `(db); } } if (isCCNReplaceableEvent(decryptedEvent.kind)) { const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1]; sql` UPDATE events SET replaced = 1 WHERE kind = ${decryptedEvent.kind} AND created_at < ${decryptedEvent.created_at} AND ccn_pubkey = ${ccnPubkey} AND id IN ( SELECT event_id FROM event_tags WHERE tag_name = 'd' AND tag_id IN ( SELECT tag_id FROM event_tags_values WHERE value_position = 1 AND value = ${dTag} ) ) `(db); } sql` INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen, ccn_pubkey) VALUES ( ${decryptedEvent.id}, ${encryptedEvent.id}, ${decryptedEvent.pubkey}, ${decryptedEvent.created_at}, ${decryptedEvent.kind}, ${decryptedEvent.content}, ${decryptedEvent.sig}, unixepoch(), ${ccnPubkey} ) `(db); if (decryptedEvent.tags) { for (let i = 0; i < decryptedEvent.tags.length; i++) { const tag = sql` INSERT INTO event_tags(event_id, tag_name, tag_index) VALUES ( ${decryptedEvent.id}, ${decryptedEvent.tags[i][0]}, ${i} ) RETURNING tag_id `(db)[0]; for (let j = 1; j < decryptedEvent.tags[i].length; j++) { sql` INSERT INTO event_tags_values(tag_id, value_position, value) VALUES ( ${tag.tag_id}, ${j}, ${decryptedEvent.tags[i][j]} ) `(db); } } } db.run('COMMIT TRANSACTION'); } catch (e) { db.run('ROLLBACK TRANSACTION'); throw e; } } function encryptedEventIsInDb(event: nostrTools.VerifiedEvent) { return sql` SELECT * FROM events WHERE original_id = ${event.id} `(db)[0]; } function cleanupOldChunks() { const cutoffTime = Math.floor((Date.now() - CHUNK_MAX_AGE) / 1000); sql`DELETE FROM event_chunks WHERE created_at < ${cutoffTime}`(db); } let knownOriginalEventsCache: string[] = []; function updateKnownEventsCache() { knownOriginalEventsCache = sql`SELECT original_id FROM events`(db).flatMap( (row) => row.original_id, ); } /** * Creates a subscription event handler for processing encrypted events. * This handler decrypts and adds valid events to the database. * @param database The database instance to use * @returns An event handler function */ function createSubscriptionEventHandler(database: Database) { return async (event: nostrTools.Event) => { if (knownOriginalEventsCache.indexOf(event.id) >= 0) return; if (!nostrTools.verifyEvent(event)) { log.warn('Invalid event received'); return; } if (encryptedEventIsInDb(event)) return; try { const decryptedEvent = await decryptEvent(database, event); addEventToDb(decryptedEvent, event, decryptedEvent.ccnPubkey); updateKnownEventsCache(); } catch (e) { if (e instanceof EventAlreadyExistsException) return; if (e instanceof ChunkedEventReceived) { return; } } }; } /** * Publishes an event to relays, handling both single events and chunked events * @param encryptedEvent The encrypted event or array of chunked events */ async function publishToRelays( encryptedEvent: nostrTools.Event | nostrTools.Event[], ): Promise { if (Array.isArray(encryptedEvent)) { for (const chunk of encryptedEvent) { await Promise.any(pool.publish(relays, chunk)); } } else { await Promise.any(pool.publish(relays, encryptedEvent)); } } function setupAndSubscribeToExternalEvents() { const isInitialized = sql` SELECT name FROM sqlite_master WHERE type='table' AND name='migration_history' `(db)[0]; if (!isInitialized) runMigrations(db, -1); const latestVersion = sql` SELECT migration_version FROM migration_history WHERE status = 'success' ORDER BY migration_version DESC LIMIT 1 `(db)[0]?.migration_version ?? -1; runMigrations(db, latestVersion); const allCCNs = sql`SELECT pubkey FROM ccns`(db); const ccnPubkeys = allCCNs.map((ccn) => ccn.pubkey); pool.subscribeMany( relays, [ { '#p': ccnPubkeys, kinds: [1059], }, ], { onevent: createSubscriptionEventHandler(db), }, ); updateKnownEventsCache(); setInterval(cleanupOldChunks, CHUNK_CLEANUP_INTERVAL); } await setupAndSubscribeToExternalEvents(); class UserConnection { public socket: WebSocket; public subscriptions: Map; public db: Database; constructor( socket: WebSocket, subscriptions: Map, db: Database, ) { this.socket = socket; this.subscriptions = subscriptions; this.db = db; } /** * Sends a response to the client * @param responseArray The response array to send */ sendResponse(responseArray: unknown[]): void { this.socket.send(JSON.stringify(responseArray)); } /** * Sends a notice to the client * @param message The message to send */ sendNotice(message: string): void { this.sendResponse(['NOTICE', message]); } /** * Sends an event to the client * @param subscriptionId The subscription ID * @param event The event to send */ sendEvent(subscriptionId: string, event: NostrEvent): void { this.sendResponse(['EVENT', subscriptionId, event]); } /** * Sends an end of stored events message * @param subscriptionId The subscription ID */ sendEOSE(subscriptionId: string): void { this.sendResponse(['EOSE', subscriptionId]); } /** * Sends an OK response * @param eventId The event ID * @param success Whether the operation was successful * @param message The message to send */ sendOK(eventId: string, success: boolean, message: string): void { this.sendResponse(['OK', eventId, success, message]); } } function filtersMatchingEvent( event: NostrEvent, connection: UserConnection, ): string[] { const matching = []; for (const subscription of connection.subscriptions.keys()) { const filters = connection.subscriptions.get(subscription); if (!filters) continue; const isMatching = filters.every((filter) => Object.entries(filter).every(([type, value]) => { if (type === 'ids') return value.includes(event.id); if (type === 'kinds') return value.includes(event.kind); if (type === 'authors') return value.includes(event.pubkey); if (type === 'since') return event.created_at >= value; if (type === 'until') return event.created_at <= value; if (type === 'limit') return event.created_at <= value; if (type.startsWith('#')) { const tagName = type.slice(1); return event.tags.some( (tag: string[]) => tag[0] === tagName && value.includes(tag[1]), ); } return false; }), ); if (isMatching) matching.push(subscription); } return matching; } function handleRequest(connection: UserConnection, request: NostrClientREQ) { const [, subscriptionId, ...filters] = request; if (connection.subscriptions.has(subscriptionId)) { return log.warn('Duplicate subscription ID'); } log.info( `New subscription: ${subscriptionId} with filters: ${JSON.stringify( filters, )}`, ); const activeCCN = getActiveCCN(connection.db); if (!activeCCN) { connection.sendNotice('No active CCN found'); return log.warn('No active CCN found'); } let query = sqlPartial`SELECT * FROM events WHERE replaced = 0 AND ccn_pubkey = ${activeCCN.pubkey}`; const filtersAreNotEmpty = filters.some((filter) => { return Object.values(filter).some((value) => { return value.length > 0; }); }); if (filtersAreNotEmpty) { query = mixQuery(query, sqlPartial`AND`); for (let i = 0; i < filters.length; i++) { // filters act as OR, filter groups act as AND query = mixQuery(query, sqlPartial`(`); const filter = Object.entries(filters[i]).filter(([type, value]) => { if (type === 'ids') return value.length > 0; if (type === 'authors') return value.length > 0; if (type === 'kinds') return value.length > 0; if (type.startsWith('#')) return value.length > 0; if (type === 'since') return value > 0; if (type === 'until') return value > 0; return false; }); for (let j = 0; j < filter.length; j++) { const [type, value] = filter[j]; if (type === 'ids') { const uniqueIds = [...new Set(value)]; query = mixQuery(query, sqlPartial`id IN (`); for (let k = 0; k < uniqueIds.length; k++) { const id = uniqueIds[k] as string; query = mixQuery(query, sqlPartial`${id}`); if (k < uniqueIds.length - 1) { query = mixQuery(query, sqlPartial`,`); } } query = mixQuery(query, sqlPartial`)`); } if (type === 'authors') { const uniqueAuthors = [...new Set(value)]; query = mixQuery(query, sqlPartial`pubkey IN (`); for (let k = 0; k < uniqueAuthors.length; k++) { const author = uniqueAuthors[k] as string; query = mixQuery(query, sqlPartial`${author}`); if (k < uniqueAuthors.length - 1) { query = mixQuery(query, sqlPartial`,`); } } query = mixQuery(query, sqlPartial`)`); } if (type === 'kinds') { const uniqueKinds = [...new Set(value)]; query = mixQuery(query, sqlPartial`kind IN (`); for (let k = 0; k < uniqueKinds.length; k++) { const kind = uniqueKinds[k] as number; query = mixQuery(query, sqlPartial`${kind}`); if (k < uniqueKinds.length - 1) { query = mixQuery(query, sqlPartial`,`); } } query = mixQuery(query, sqlPartial`)`); } if (type.startsWith('#')) { const tag = type.slice(1); const uniqueValues = [...new Set(value)]; query = mixQuery(query, sqlPartial`(`); for (let k = 0; k < uniqueValues.length; k++) { const tagValue = uniqueValues[k] as string; if (tag === 'a') { const aTagInfo = parseATagQuery(tagValue); if (aTagInfo.dTag && aTagInfo.dTag !== '') { if (isCCNReplaceableEvent(aTagInfo.kind)) { // CCN replaceable event reference query = mixQuery( query, sqlPartial`id IN ( SELECT e.id FROM events e JOIN event_tags t ON e.id = t.event_id JOIN event_tags_values v ON t.tag_id = v.tag_id WHERE e.kind = ${aTagInfo.kind} AND t.tag_name = 'd' AND v.value_position = 1 AND v.value = ${aTagInfo.dTag} )`, ); } else { // Addressable event reference query = mixQuery( query, sqlPartial`id IN ( SELECT e.id FROM events e JOIN event_tags t ON e.id = t.event_id JOIN event_tags_values v ON t.tag_id = v.tag_id WHERE e.kind = ${aTagInfo.kind} AND e.pubkey = ${aTagInfo.pubkey} AND t.tag_name = 'd' AND v.value_position = 1 AND v.value = ${aTagInfo.dTag} )`, ); } } else { // Replaceable event reference query = mixQuery( query, sqlPartial`id IN ( SELECT id FROM events WHERE kind = ${aTagInfo.kind} AND pubkey = ${aTagInfo.pubkey} )`, ); } } else { // Regular tag handling (unchanged) query = mixQuery( query, sqlPartial`id IN ( SELECT t.event_id FROM event_tags t WHERE t.tag_name = ${tag} AND t.tag_id IN ( SELECT v.tag_id FROM event_tags_values v WHERE v.value_position = 1 AND v.value = ${tagValue} ) )`, ); } if (k < uniqueValues.length - 1) { query = mixQuery(query, sqlPartial`OR`); } } query = mixQuery(query, sqlPartial`)`); } if (type === 'since') { query = mixQuery(query, sqlPartial`created_at >= ${value}`); } if (type === 'until') { query = mixQuery(query, sqlPartial`created_at <= ${value}`); } if (j < filter.length - 1) query = mixQuery(query, sqlPartial`AND`); } query = mixQuery(query, sqlPartial`)`); if (i < filters.length - 1) query = mixQuery(query, sqlPartial`OR`); } } query = mixQuery(query, sqlPartial`ORDER BY created_at ASC`); log.debug(query.query, ...query.values); const events = connection.db.prepare(query.query).all(...query.values); for (let i = 0; i < events.length; i++) { const rawTags = sql`SELECT * FROM event_tags_view WHERE event_id = ${ events[i].id }`(connection.db); const tagsByIndex = new Map< number, { name: string; values: Map; } >(); for (const tag of rawTags) { let tagData = tagsByIndex.get(tag.tag_index); if (!tagData) { tagData = { name: tag.tag_name, values: new Map(), }; tagsByIndex.set(tag.tag_index, tagData); } tagData.values.set(tag.tag_value_position, tag.tag_value); } const tagsArray = Array.from(tagsByIndex.entries()) .sort(([indexA], [indexB]) => indexA - indexB) .map(([_, tagData]) => { const { name, values } = tagData; return [ name, ...Array.from(values.entries()) .sort(([posA], [posB]) => posA - posB) .map(([_, value]) => value), ]; }); const event = { id: events[i].id, pubkey: events[i].pubkey, created_at: events[i].created_at, kind: events[i].kind, tags: tagsArray, content: events[i].content, sig: events[i].sig, }; connection.sendEvent(subscriptionId, event); } connection.sendEOSE(subscriptionId); connection.subscriptions.set(subscriptionId, filters); } async function handleEvent( connection: UserConnection, event: nostrTools.Event, ) { const valid = nostrTools.verifyEvent(event); if (!valid) { connection.sendNotice('Invalid event'); return log.warn('Invalid event'); } const activeCCN = getActiveCCN(connection.db); if (!activeCCN) { connection.sendNotice('No active CCN found'); return log.warn('No active CCN found'); } const encryptedEvent = await createEncryptedEvent(event, connection.db); try { if (Array.isArray(encryptedEvent)) { await publishToRelays(encryptedEvent); addEventToDb(event, encryptedEvent[0], activeCCN.pubkey); } else { addEventToDb(event, encryptedEvent, activeCCN.pubkey); await publishToRelays(encryptedEvent); } } catch (e) { if (e instanceof EventAlreadyExistsException) { log.warn('Event already exists'); return; } } connection.sendOK(event.id, true, 'Event added'); const filtersThatMatchEvent = filtersMatchingEvent(event, connection); for (let i = 0; i < filtersThatMatchEvent.length; i++) { const filter = filtersThatMatchEvent[i]; connection.sendEvent(filter, event); } } function handleClose(connection: UserConnection, subscriptionId: string) { if (!connection.subscriptions.has(subscriptionId)) { return log.warn( `Closing unknown subscription? That's weird. Subscription ID: ${subscriptionId}`, ); } connection.subscriptions.delete(subscriptionId); } /** * Activates a CCN by setting it as the active one in the database * @param database The database instance to use * @param pubkey The public key of the CCN to activate */ function activateCCN(database: Database, pubkey: string): void { sql`UPDATE ccns SET is_active = 0`(database); sql`UPDATE ccns SET is_active = 1 WHERE pubkey = ${pubkey}`(database); } /** * Handles errors in socket operations, logs them and sends a notification to the client * @param connection The WebSocket connection * @param operation The operation that failed * @param error The error that occurred */ function handleSocketError( connection: UserConnection, operation: string, error: unknown, ): void { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; log.error(`Error ${operation}: ${errorMessage}`); connection.sendNotice(`Failed to ${operation}`); } async function handleCreateCCN( connection: UserConnection, data: { name: string; seed?: string; creator: string }, ): Promise { try { if (!data.name || typeof data.name !== 'string') { connection.sendNotice('Name is required'); return; } if (!data.creator || typeof data.creator !== 'string') { connection.sendNotice('Creator is required'); return; } const newCcn = await createNewCCN( connection.db, data.name, data.creator, data.seed, ); activateCCN(connection.db, newCcn.pubkey); pool.subscribeMany( relays, [ { '#p': [newCcn.pubkey], kinds: [1059], }, ], { onevent: createSubscriptionEventHandler(connection.db), }, ); connection.sendResponse([ 'OK', 'CCN CREATED', true, JSON.stringify({ pubkey: newCcn.pubkey, name: data.name, }), ]); log.info(`CCN created: ${data.name}`); } catch (error: unknown) { handleSocketError(connection, 'create CCN', error); } } function handleGetCCNs(connection: UserConnection): void { try { const ccns = getAllCCNs(connection.db); connection.sendResponse(['OK', 'CCN LIST', true, JSON.stringify(ccns)]); } catch (error: unknown) { handleSocketError(connection, 'get CCNs', error); } } function handleActivateCCN( connection: UserConnection, data: { pubkey: string }, ): void { try { if (!data.pubkey || typeof data.pubkey !== 'string') { connection.sendNotice('CCN pubkey is required'); return; } const ccnExists = sql` SELECT COUNT(*) as count FROM ccns WHERE pubkey = ${data.pubkey} `(connection.db)[0].count; if (ccnExists === 0) { connection.sendNotice('CCN not found'); return; } for (const subscriptionId of connection.subscriptions.keys()) { connection.sendResponse([ 'CLOSED', subscriptionId, 'Subscription closed due to CCN activation', ]); } connection.subscriptions.clear(); log.info('All subscriptions cleared due to CCN activation'); activateCCN(connection.db, data.pubkey); const activatedCCN = sql` SELECT pubkey, name FROM ccns WHERE pubkey = ${data.pubkey} `(connection.db)[0]; connection.sendResponse([ 'OK', 'CCN ACTIVATED', true, JSON.stringify(activatedCCN), ]); log.info(`CCN activated: ${activatedCCN.name}`); } catch (error: unknown) { handleSocketError(connection, 'activate CCN', error); } } async function handleAddCCN( connection: UserConnection, data: { name: string; allowedPubkeys: string[]; privateKey: string }, ): Promise { try { if (!data.privateKey || typeof data.privateKey !== 'string') { connection.sendNotice('CCN private key is required'); return; } const privateKeyBytes = hexToBytes(data.privateKey); const pubkey = nostrTools.getPublicKey(privateKeyBytes); const ccnExists = sql` SELECT COUNT(*) as count FROM ccns WHERE pubkey = ${pubkey} `(connection.db)[0].count; if (ccnExists > 0) { connection.sendNotice('CCN already exists'); return; } const ccnPublicKey = nostrTools.getPublicKey(privateKeyBytes); const ccnPrivPath = await getEveFilePath(`ccn_keys/${ccnPublicKey}`); const encryptedPrivateKey = encryptUint8Array( privateKeyBytes, encryptionKey, ); Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey)); db.run('BEGIN TRANSACTION'); sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${data.name})`( db, ); for (const allowedPubkey of data.allowedPubkeys) sql`INSERT INTO allowed_writes (ccn_pubkey, pubkey) VALUES (${ccnPublicKey}, ${allowedPubkey})`( db, ); db.run('COMMIT TRANSACTION'); activateCCN(connection.db, ccnPublicKey); pool.subscribeMany( relays, [ { '#p': [ccnPublicKey], kinds: [1059], }, ], { onevent: createSubscriptionEventHandler(connection.db), }, ); connection.sendResponse([ 'OK', 'CCN ADDED', true, JSON.stringify({ pubkey: ccnPublicKey, name: 'New CCN', }), ]); } catch (error: unknown) { handleSocketError(connection, 'ADD CCN', error); } } function handleCCNCommands( connection: UserConnection, command: string, data: unknown, ) { switch (command) { case 'CREATE': return handleCreateCCN( connection, data as { name: string; seed?: string; creator: string }, ); case 'ADD': return handleAddCCN( connection, data as { name: string; allowedPubkeys: string[]; privateKey: string }, ); case 'LIST': return handleGetCCNs(connection); case 'ACTIVATE': return handleActivateCCN(connection, data as { pubkey: string }); default: return log.warn('Invalid CCN command'); } } Deno.serve({ port: 6942, handler: (request) => { if (request.headers.get('upgrade') === 'websocket') { if (!isLocalhost(request)) { return new Response( 'Forbidden. Please read the Arx-CCN documentation for more information on how to interact with the relay.', { status: 403 }, ); } const { socket, response } = Deno.upgradeWebSocket(request); const connection = new UserConnection(socket, new Map(), db); socket.onopen = () => log.info('User connected'); socket.onmessage = (event) => { log.debug(`Received: ${event.data}`); if (typeof event.data !== 'string' || !isValidJSON(event.data)) { return log.warn('Invalid request'); } const data = JSON.parse(event.data); if (!isArray(data)) return log.warn('Invalid request'); const msgType = data[0]; switch (msgType) { case 'REQ': return handleRequest(connection, n.clientREQ().parse(data)); case 'EVENT': return handleEvent(connection, n.clientEVENT().parse(data)[1]); case 'CLOSE': return handleClose(connection, n.clientCLOSE().parse(data)[1]); case 'CCN': return handleCCNCommands(connection, data[1] as string, data[2]); default: return log.warn('Invalid request'); } }; socket.onclose = () => log.info('User disconnected'); return response; } return new Response( Deno.readTextFileSync(`${import.meta.dirname}/public/landing.html`), { headers: { 'Content-Type': 'text/html' }, }, ); }, });