Feat: Implement support for multiple CCNs

This commit is contained in:
Danny Morabito 2025-04-09 13:40:02 +02:00
parent 097f02938d
commit a8ffce918e
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
7 changed files with 778 additions and 169 deletions

14
Makefile Normal file
View file

@ -0,0 +1,14 @@
TARGETS = x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin
.PHONY: all clean
all: $(TARGETS:%=dist/relay-%)
dist/relay-%: | dist/
deno compile -A -r --target $* --include migrations --include public --no-lock --output $@ index.ts
dist/:
mkdir -p dist
clean:
rm -f dist/*

View file

@ -3,10 +3,12 @@ import * as nostrTools from '@nostr/tools';
import { nip44 } from '@nostr/tools';
import { MAX_CHUNK_SIZE, MIN_POW, POW_TO_MINE } from './consts.ts';
import {
getCCNPrivateKey,
getCCNPubkey,
getActiveCCN,
getAllCCNs,
getCCNPrivateKeyByPubkey,
randomTimeUpTo2DaysInThePast,
} from './utils.ts';
import { None, type Option, Some, flatMap, map } from './utils/option.ts';
import { sql } from './utils/queries.ts';
export class EventAlreadyExistsException extends Error {}
@ -14,12 +16,16 @@ export class ChunkedEventReceived extends Error {}
export async function createEncryptedEvent(
event: nostrTools.VerifiedEvent,
db: Database,
): Promise<nostrTools.VerifiedEvent | nostrTools.VerifiedEvent[]> {
if (!event.id) throw new Error('Event must have an ID');
if (!event.sig) throw new Error('Event must be signed');
const ccnPubKey = await getCCNPubkey();
const ccnPrivateKey = await getCCNPrivateKey();
const activeCCN = getActiveCCN(db);
if (!activeCCN) throw new Error('No active CCN found');
const ccnPubKey = activeCCN.pubkey;
const ccnPrivateKey = await getCCNPrivateKeyByPubkey(ccnPubKey);
const eventJson = JSON.stringify(event);
if (eventJson.length <= MAX_CHUNK_SIZE) {
@ -95,12 +101,101 @@ export async function createEncryptedEvent(
return encryptedChunks;
}
/**
* Attempts to decrypt an event using a specific CCN private key
* @returns The decrypted event with CCN pubkey if successful, None otherwise
*/
function attemptDecryptWithKey(
event: nostrTools.Event,
ccnPrivkey: Uint8Array,
ccnPubkey: string,
): Option<nostrTools.VerifiedEvent> {
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
const sealResult = map(
Some(nip44.decrypt(event.content, conversationKey)),
JSON.parse,
);
return flatMap(sealResult, (seal) => {
if (!seal || seal.kind !== 13) return None();
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
if (!chunkTag) {
const contentResult = map(
Some(nip44.decrypt(seal.content, conversationKey)),
JSON.parse,
);
return map(contentResult, (content) => ({ ...content, ccnPubkey }));
}
return None();
});
}
/**
* Handles a chunked message by storing it in the database and checking if all chunks are received
* @returns The complete decrypted event if all chunks are received, throws ChunkedEventReceived otherwise
*/
function handleChunkedMessage(
db: Database,
event: nostrTools.Event,
ccnPrivkey: Uint8Array,
ccnPubkey: string,
): nostrTools.VerifiedEvent {
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
const sealResult = map(
Some(nip44.decrypt(event.content, conversationKey)),
JSON.parse,
);
const seal = sealResult.isSome ? sealResult.value : null;
if (!seal) {
throw new Error('Invalid chunked message format');
}
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
if (!chunkTag) {
throw new Error('Invalid chunked message format');
}
const [_, chunkIndex, totalChunks, messageId] = chunkTag;
const chunk = nip44.decrypt(seal.content, conversationKey);
const storedChunks = sql`
SELECT COUNT(*) as count
FROM event_chunks
WHERE message_id = ${messageId}
`(db)[0].count;
sql`
INSERT INTO event_chunks (message_id, chunk_index, total_chunks, content, created_at, ccn_pubkey)
VALUES (${messageId}, ${chunkIndex}, ${totalChunks}, ${chunk}, ${Math.floor(Date.now() / 1000)}, ${ccnPubkey})
`(db);
if (storedChunks + 1 === Number(totalChunks)) {
const allChunks = sql`
SELECT * FROM event_chunks
WHERE message_id = ${messageId}
ORDER BY chunk_index
`(db);
let fullContent = '';
for (const chunk of allChunks) {
fullContent += chunk.content;
}
const content = JSON.parse(fullContent);
return { ...content, ccnPubkey };
}
throw new ChunkedEventReceived();
}
export async function decryptEvent(
db: Database,
event: nostrTools.Event,
): Promise<nostrTools.VerifiedEvent> {
const ccnPrivkey = await getCCNPrivateKey();
): Promise<nostrTools.VerifiedEvent & { ccnPubkey: string }> {
if (event.kind !== 1059) {
throw new Error('Cannot decrypt event -- not a gift wrap');
}
@ -111,64 +206,30 @@ export async function decryptEvent(
throw new Error('Cannot decrypt event -- PoW too low');
}
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
const seal = JSON.parse(nip44.decrypt(event.content, conversationKey));
if (!seal) throw new Error('Cannot decrypt event -- no seal');
if (seal.kind !== 13) {
throw new Error('Cannot decrypt event subevent -- not a seal');
const allCCNs = getAllCCNs(db);
if (allCCNs.length === 0) {
throw new Error('No CCNs found');
}
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
if (!chunkTag) {
const content = JSON.parse(nip44.decrypt(seal.content, conversationKey));
return content as nostrTools.VerifiedEvent;
for (const ccn of allCCNs) {
const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey);
const decryptedEvent = attemptDecryptWithKey(event, ccnPrivkey, ccn.pubkey);
if (decryptedEvent.isSome) {
return { ...decryptedEvent.value, ccnPubkey: ccn.pubkey };
}
}
const [_, chunkIndex, totalChunks, messageId] = chunkTag;
const chunk = nip44.decrypt(seal.content, conversationKey);
for (const ccn of allCCNs) {
const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey);
try {
sql`
INSERT INTO event_chunks (
message_id,
chunk_index,
total_chunks,
chunk_data,
conversation_key,
created_at
) VALUES (
${messageId},
${Number(chunkIndex)},
${Number(totalChunks)},
${chunk},
${conversationKey},
${Math.floor(Date.now() / 1000)}
)
`(db);
const chunks = sql`
SELECT chunk_data
FROM event_chunks
WHERE message_id = ${messageId}
ORDER BY chunk_index ASC
`(db);
if (chunks.length === Number(totalChunks)) {
const completeEventJson = chunks.map((c) => c.chunk_data).join('');
sql`DELETE FROM event_chunks WHERE message_id = ${messageId}`(db);
return JSON.parse(completeEventJson) as nostrTools.VerifiedEvent;
}
throw new ChunkedEventReceived(
`Chunked event received (${chunks.length}/${totalChunks}) - messageId: ${messageId}`,
);
const chuncked = handleChunkedMessage(db, event, ccnPrivkey, ccn.pubkey);
return { ...chuncked, ccnPubkey: ccn.pubkey };
} catch (e) {
if (e instanceof Error && e.message.includes('UNIQUE constraint failed'))
throw new Error(
`Duplicate chunk received (${Number(chunkIndex) + 1}/${totalChunks}) - messageId: ${messageId}`,
);
if (e instanceof ChunkedEventReceived) {
throw e;
}
}
}
throw new Error('Failed to decrypt event with any CCN key');
}

367
index.ts
View file

@ -16,8 +16,9 @@ import {
decryptEvent,
} from './eventEncryptionDecryption.ts';
import {
getCCNPrivateKey,
getCCNPubkey,
createNewCCN,
getActiveCCN,
getAllCCNs,
isAddressableEvent,
isArray,
isCCNReplaceableEvent,
@ -115,6 +116,7 @@ export function runMigrations(db: Database, latestVersion: number) {
function addEventToDb(
decryptedEvent: nostrTools.VerifiedEvent,
encryptedEvent: nostrTools.VerifiedEvent,
ccnPubkey: string,
) {
const existingEvent = sql`
SELECT * FROM events WHERE id = ${decryptedEvent.id}
@ -131,6 +133,7 @@ function addEventToDb(
WHERE kind = ${decryptedEvent.kind}
AND pubkey = ${decryptedEvent.pubkey}
AND created_at < ${decryptedEvent.created_at}
AND ccn_pubkey = ${ccnPubkey}
`(db);
}
@ -143,6 +146,7 @@ function addEventToDb(
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'
@ -163,6 +167,7 @@ function addEventToDb(
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'
@ -176,7 +181,7 @@ function addEventToDb(
}
sql`
INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen) VALUES (
INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen, ccn_pubkey) VALUES (
${decryptedEvent.id},
${encryptedEvent.id},
${decryptedEvent.pubkey},
@ -184,7 +189,8 @@ function addEventToDb(
${decryptedEvent.kind},
${decryptedEvent.content},
${decryptedEvent.sig},
unixepoch()
unixepoch(),
${ccnPubkey}
)
`(db);
if (decryptedEvent.tags) {
@ -225,9 +231,58 @@ function cleanupOldChunks() {
sql`DELETE FROM event_chunks WHERE created_at < ${cutoffTime}`(db);
}
async function setupAndSubscribeToExternalEvents() {
const ccnPubkey = await getCCNPubkey();
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<void> {
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];
@ -241,72 +296,23 @@ async function setupAndSubscribeToExternalEvents() {
runMigrations(db, latestVersion);
const allCCNs = sql`SELECT pubkey FROM ccns`(db);
const ccnPubkeys = allCCNs.map((ccn) => ccn.pubkey);
pool.subscribeMany(
relays,
[
{
'#p': [ccnPubkey],
'#p': ccnPubkeys,
kinds: [1059],
},
],
{
async onevent(event: nostrTools.Event) {
if (timer) {
timerCleaned = true;
clearTimeout(timer);
}
if (knownOriginalEvents.indexOf(event.id) >= 0) return;
if (!nostrTools.verifyEvent(event)) {
log.warn('Invalid event received');
return;
}
if (encryptedEventIsInDb(event)) return;
try {
const decryptedEvent = await decryptEvent(db, event);
addEventToDb(decryptedEvent, event);
} catch (e) {
if (e instanceof EventAlreadyExistsException) return;
if (e instanceof ChunkedEventReceived) {
return;
}
}
},
onevent: createSubscriptionEventHandler(db),
},
);
let timerCleaned = false;
const knownOriginalEvents = sql`SELECT original_id FROM events`(db).flatMap(
(row) => row.original_id,
);
const timer = setTimeout(async () => {
// if nothing is found in 10 seconds, create a new CCN, TODO: change logic
const ccnCreationEventTemplate = {
kind: 0,
content: JSON.stringify({
display_name: 'New CCN',
name: 'New CCN',
bot: true,
}),
created_at: Math.floor(Date.now() / 1000),
tags: [['p', ccnPubkey]],
};
const ccnCreationEvent = nostrTools.finalizeEvent(
ccnCreationEventTemplate,
await getCCNPrivateKey(),
);
const encryptedCCNCreationEvent =
await createEncryptedEvent(ccnCreationEvent);
if (timerCleaned) return; // in case we get an event before the timer is cleaned
if (Array.isArray(encryptedCCNCreationEvent)) {
for (const event of encryptedCCNCreationEvent)
await Promise.any(pool.publish(relays, event));
} else {
await Promise.any(pool.publish(relays, encryptedCCNCreationEvent));
}
}, 10000);
updateKnownEventsCache();
setInterval(cleanupOldChunks, CHUNK_CLEANUP_INTERVAL);
}
@ -326,6 +332,49 @@ class UserConnection {
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(
@ -370,7 +419,13 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) {
)}`,
);
let query = sqlPartial`SELECT * FROM events WHERE replaced = 0`;
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) => {
@ -593,9 +648,9 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) {
sig: events[i].sig,
};
connection.socket.send(JSON.stringify(['EVENT', subscriptionId, event]));
connection.sendEvent(subscriptionId, event);
}
connection.socket.send(JSON.stringify(['EOSE', subscriptionId]));
connection.sendEOSE(subscriptionId);
connection.subscriptions.set(subscriptionId, filters);
}
@ -606,20 +661,24 @@ async function handleEvent(
) {
const valid = nostrTools.verifyEvent(event);
if (!valid) {
connection.socket.send(JSON.stringify(['NOTICE', 'Invalid event']));
connection.sendNotice('Invalid event');
return log.warn('Invalid event');
}
const encryptedEvent = await createEncryptedEvent(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 Promise.all(
encryptedEvent.map((chunk) => Promise.any(pool.publish(relays, chunk))),
);
addEventToDb(event, encryptedEvent[0]);
await publishToRelays(encryptedEvent);
addEventToDb(event, encryptedEvent[0], activeCCN.pubkey);
} else {
addEventToDb(event, encryptedEvent);
await Promise.any(pool.publish(relays, encryptedEvent));
addEventToDb(event, encryptedEvent, activeCCN.pubkey);
await publishToRelays(encryptedEvent);
}
} catch (e) {
if (e instanceof EventAlreadyExistsException) {
@ -628,13 +687,13 @@ async function handleEvent(
}
}
connection.socket.send(JSON.stringify(['OK', event.id, true, 'Event added']));
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.socket.send(JSON.stringify(['EVENT', filter, event]));
connection.sendEvent(filter, event);
}
}
@ -648,6 +707,153 @@ function handleClose(connection: UserConnection, subscriptionId: string) {
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 },
): Promise<void> {
try {
if (!data.name || typeof data.name !== 'string') {
connection.sendNotice('Name is required');
return;
}
const newCcn = await createNewCCN(connection.db, data.name, 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);
}
}
function handleCCNCommands(
connection: UserConnection,
command: string,
data: unknown,
) {
switch (command) {
case 'CREATE':
return handleCreateCCN(
connection,
data as { name: string; seed?: 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) => {
@ -672,14 +878,16 @@ Deno.serve({
const data = JSON.parse(event.data);
if (!isArray(data)) return log.warn('Invalid request');
const msg = n.clientMsg().parse(data);
switch (msg[0]) {
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');
}
@ -688,6 +896,11 @@ Deno.serve({
return response;
}
return new Response('Eve Relay');
return new Response(
Deno.readTextFileSync(`${import.meta.dirname}/public/landing.html`),
{
headers: { 'Content-Type': 'text/html' },
},
);
},
});

21
migrations/5-multiCCN.sql Normal file
View file

@ -0,0 +1,21 @@
CREATE TABLE ccns (
ccn_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
pubkey TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
is_active INTEGER NOT NULL DEFAULT 1
);
ALTER TABLE events
ADD COLUMN ccn_pubkey TEXT;
CREATE INDEX idx_events_ccn_pubkey ON events(ccn_pubkey);
ALTER TABLE event_chunks RENAME COLUMN chunk_data TO content;
ALTER TABLE event_chunks ADD COLUMN ccn_pubkey TEXT;
ALTER TABLE event_chunks DROP COLUMN conversation_key;
CREATE INDEX idx_event_chunks_ccn_pubkey ON event_chunks(ccn_pubkey);
UPDATE ccns SET is_active = 0;
UPDATE ccns SET is_active = 1
WHERE pubkey = (SELECT pubkey FROM ccns LIMIT 1);

212
public/landing.html Normal file
View file

@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eve Relay - Secure Nostr Relay with CCN</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<style>
:root {
--primary: #3498db;
--primary-dark: #2980b9;
--text: #2c3e50;
--text-light: #7f8c8d;
--background: #ffffff;
--background-alt: #f8f9fa;
--border: #e9ecef;
--success: #2ecc71;
--warning: #f1c40f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.6;
color: var(--text);
background: var(--background);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
padding: 4rem 0;
background: var(--background-alt);
border-bottom: 1px solid var(--border);
}
.logo {
width: 120px;
height: 120px;
margin-bottom: 1.5rem;
background: var(--primary);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
}
.logo-text {
font-size: 2.5rem;
font-weight: 700;
color: white;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--text);
}
.subtitle {
font-size: 1.25rem;
color: var(--text-light);
max-width: 600px;
margin: 0 auto 2rem;
}
h2 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text);
margin: 2.5rem 0 1rem;
}
h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 0.75rem;
}
code {
background: var(--background-alt);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
pre {
background: var(--background-alt);
padding: 1.25rem;
border-radius: 8px;
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border);
}
.note {
background: #fffde7;
border-left: 4px solid var(--warning);
padding: 1.25rem;
margin: 1.5rem 0;
border-radius: 4px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin: 2rem 0;
}
.card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.command-list {
list-style: none;
}
.command-list li {
margin-bottom: 0.75rem;
padding: 0.75rem;
background: var(--background-alt);
border-radius: 4px;
}
.command-list code {
color: var(--primary);
}
.cta-button {
display: inline-block;
background: var(--primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
transition: background 0.2s;
}
.cta-button:hover {
background: var(--primary-dark);
}
</style>
</head>
<body>
<header>
<div class="logo">
<span class="logo-text">E</span>
</div>
<h1>Eve Relay</h1>
<p class="subtitle">A secure and efficient Nostr relay with Closed Community Network (CCN) functionality</p>
</header>
<div class="container">
<div class="note">
<strong>Important:</strong> This relay is designed for WebSocket connections only. HTTP requests are not supported for data operations.
</div>
<h2>Connection Details</h2>
<p>Connect to the relay using WebSocket:</p>
<pre>ws://localhost:6942</pre>
<div class="grid">
<div class="card">
<h3>Nostr Commands</h3>
<ul class="command-list">
<li><code>REQ</code> - Subscribe to events</li>
<li><code>EVENT</code> - Publish an event</li>
<li><code>CLOSE</code> - Close a subscription</li>
</ul>
</div>
<div class="card">
<h3>CCN Commands</h3>
<ul class="command-list">
<li><code>CCN CREATE</code> - Create a new CCN</li>
<li><code>CCN LIST</code> - List all active CCNs</li>
<li><code>CCN ACTIVATE</code> - Activate a specific CCN</li>
</ul>
</div>
</div>
<h2>Documentation</h2>
<p>For detailed information about Arx-CCN functionality and best practices, please refer to the official documentation.</p>
<a href="#" class="cta-button">View Documentation</a>
</div>
</body>
</html>

104
utils.ts
View file

@ -1,13 +1,15 @@
import { decodeBase64, encodeBase64 } from 'jsr:@std/encoding@0.224/base64';
import { exists } from 'jsr:@std/fs';
import * as nostrTools from '@nostr/tools';
import * as nip06 from '@nostr/tools/nip06';
import type { Database } from 'jsr:@db/sqlite';
import { decodeBase64, encodeBase64 } from 'jsr:@std/encoding@0.224/base64';
import { exists } from 'jsr:@std/fs';
import {
decryptUint8Array,
encryptUint8Array,
encryptionKey,
} from './utils/encryption.ts';
import { getEveFilePath } from './utils/files.ts';
import { sql } from './utils/queries.ts';
export function isLocalhost(req: Request): boolean {
const url = new URL(req.url);
@ -38,37 +40,68 @@ export function randomTimeUpTo2DaysInThePast() {
);
}
export async function getCCNPubkey(): Promise<string> {
const ccnPubPath = await getEveFilePath('ccn.pub');
const seedPath = await getEveFilePath('ccn.seed');
const doWeHaveKey = await exists(ccnPubPath);
if (doWeHaveKey) return Deno.readTextFileSync(ccnPubPath);
const ccnSeed =
Deno.env.get('CCN_SEED') ||
((await exists(seedPath))
? Deno.readTextFileSync(seedPath)
: nip06.generateSeedWords());
const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed);
const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey);
const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey);
Deno.writeTextFileSync(ccnPubPath, ccnPublicKey);
Deno.writeTextFileSync(
await getEveFilePath('ccn.priv'),
encodeBase64(encryptedPrivateKey),
);
Deno.writeTextFileSync(seedPath, ccnSeed);
return ccnPublicKey;
/**
* Get all CCNs from the database
*/
export function getAllCCNs(db: Database): { pubkey: string; name: string }[] {
return sql`SELECT pubkey, name FROM ccns`(db) as {
pubkey: string;
name: string;
}[];
}
export async function getCCNPrivateKey(): Promise<Uint8Array> {
const encryptedPrivateKey = Deno.readTextFileSync(
await getEveFilePath('ccn.priv'),
);
/**
* Create a new CCN and store it in the database
*
* @param db - The database instance
* @param name - The name of the CCN
* @param seed - The seed words for the CCN
* @returns The public key and private key of the CCN
*/
export async function createNewCCN(
db: Database,
name: string,
seed?: string,
): Promise<{ pubkey: string; privkey: Uint8Array }> {
const ccnSeed = seed || nip06.generateSeedWords();
const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed);
const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey);
const ccnSeedPath = await getEveFilePath(`ccn_seeds/${ccnPublicKey}`);
const ccnPrivPath = await getEveFilePath(`ccn_keys/${ccnPublicKey}`);
await Deno.mkdir(await getEveFilePath('ccn_seeds'), { recursive: true });
await Deno.mkdir(await getEveFilePath('ccn_keys'), { recursive: true });
const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey);
Deno.writeTextFileSync(ccnSeedPath, ccnSeed);
Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey));
sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${name})`(db);
return {
pubkey: ccnPublicKey,
privkey: ccnPrivateKey,
};
}
/**
* Get the private key for a specific CCN
*/
export async function getCCNPrivateKeyByPubkey(
pubkey: string,
): Promise<Uint8Array> {
const ccnPrivPath = await getEveFilePath(`ccn_keys/${pubkey}`);
if (await exists(ccnPrivPath)) {
const encryptedPrivateKey = Deno.readTextFileSync(ccnPrivPath);
return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey);
}
throw new Error(`CCN private key for ${pubkey} not found`);
}
export function isReplaceableEvent(kind: number): boolean {
return (kind >= 10000 && kind < 20000) || kind === 0 || kind === 3;
}
@ -104,3 +137,18 @@ export function parseATagQuery(aTagValue: string): {
dTag: parts.length > 2 ? parts[2] : undefined,
};
}
/**
* Get the single active CCN from the database
* @returns The active CCN or null if none is active
*/
export function getActiveCCN(
db: Database,
): { pubkey: string; name: string } | null {
const result = sql`SELECT pubkey, name FROM ccns WHERE is_active = 1 LIMIT 1`(
db,
);
return result.length > 0
? (result[0] as { pubkey: string; name: string })
: null;
}

40
utils/option.ts Normal file
View file

@ -0,0 +1,40 @@
export type Option<T> =
| {
value: T;
isSome: true;
}
| {
value: undefined;
isSome: false;
};
export function Some<T>(value: T): Option<T> {
return { value, isSome: true };
}
export function None<T>(): Option<T> {
return { value: undefined, isSome: false };
}
export function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
return option.isSome ? Some(fn(option.value)) : None();
}
export function flatMap<T, U>(
option: Option<T>,
fn: (value: T) => Option<U>,
): Option<U> {
return option.isSome ? fn(option.value) : None();
}
export function getOrElse<T>(option: Option<T>, defaultValue: T): T {
return option.isSome ? option.value : defaultValue;
}
export function fold<T, U>(
option: Option<T>,
onNone: () => U,
onSome: (value: T) => U,
): U {
return option.isSome ? onSome(option.value) : onNone();
}