CCN invitation and write permissions to CCN

This commit is contained in:
Danny Morabito 2025-04-23 18:13:29 +02:00
parent a8ffce918e
commit 36c7401fa8
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
5 changed files with 395 additions and 107 deletions

211
index.ts
View file

@ -1,5 +1,8 @@
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 {
@ -8,17 +11,23 @@ import type {
NostrFilter,
} from 'jsr:@nostrify/types';
import { encodeBase64 } from 'jsr:@std/encoding@0.224/base64';
import { CHUNK_CLEANUP_INTERVAL, CHUNK_MAX_AGE } from './consts.ts';
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,
@ -42,6 +51,8 @@ if (!Deno.env.has('ENCRYPTION_KEY')) {
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 = [
@ -123,12 +134,106 @@ function addEventToDb(
`(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
UPDATE events
SET replaced = 1
WHERE kind = ${decryptedEvent.kind}
AND pubkey = ${decryptedEvent.pubkey}
@ -141,15 +246,15 @@ function addEventToDb(
const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1];
if (dTag) {
sql`
UPDATE events
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'
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
@ -163,14 +268,14 @@ function addEventToDb(
if (isCCNReplaceableEvent(decryptedEvent.kind)) {
const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1];
sql`
UPDATE events
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'
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
@ -561,7 +666,7 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) {
FROM event_tags t
WHERE t.tag_name = ${tag}
AND t.tag_id IN (
SELECT v.tag_id
SELECT v.tag_id
FROM event_tags_values v
WHERE v.value_position = 1
AND v.value = ${tagValue}
@ -735,7 +840,7 @@ function handleSocketError(
async function handleCreateCCN(
connection: UserConnection,
data: { name: string; seed?: string },
data: { name: string; seed?: string; creator: string },
): Promise<void> {
try {
if (!data.name || typeof data.name !== 'string') {
@ -743,7 +848,17 @@ async function handleCreateCCN(
return;
}
const newCcn = await createNewCCN(connection.db, data.name, data.seed);
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);
@ -834,6 +949,73 @@ function handleActivateCCN(
}
}
async function handleAddCCN(
connection: UserConnection,
data: { name: string; allowedPubkeys: string[]; privateKey: string },
): Promise<void> {
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}`);
Deno.writeTextFileSync(ccnPrivPath, encodeBase64(privateKeyBytes));
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,
@ -843,7 +1025,12 @@ function handleCCNCommands(
case 'CREATE':
return handleCreateCCN(
connection,
data as { name: string; seed?: string },
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);