diff --git a/src/durable-object.ts b/src/durable-object.ts index 14e0ff9..2fdd007 100644 --- a/src/durable-object.ts +++ b/src/durable-object.ts @@ -15,6 +15,7 @@ import { verifyEventSignature, hasPaidForRelay, processEvent, queryEvents } from import { extensionRegistry } from './relay/services/registry.js'; import { initOpenDating } from './protocols/opendating/index.js'; import { runHousekeeperTick } from './cloudflare/housekeeper.js'; +import { checkDirectMessagePolicy } from './protocols/opendating/services/dm-policy.js'; // Session attachment data structure (minimal - auth state stored in session) interface SessionAttachment { @@ -930,6 +931,30 @@ export class RelayWebSocket implements DurableObject { } } + if (event.kind === 1059) { + const recipientPubkey = event.tags.find((tag) => tag[0] === 'p')?.[1]; + try { + const decision = await checkDirectMessagePolicy( + this.env.RELAY_DATABASE, + relayCtx.authenticatedPubkey || '', + recipientPubkey, + ); + if (decision !== 'allowed') { + const reason = + decision === 'blocked' + ? 'blocked: od:blocked' + : decision === 'not-matched' + ? 'restricted: od:not-matched' + : 'invalid: gift wrap recipient required'; + this.sendOK(session.webSocket, event.id, false, reason); + return; + } + } catch { + this.sendOK(session.webSocket, event.id, false, 'blocked: dm policy unavailable'); + return; + } + } + // Process the event (save to database) const result = await processEvent(event, session.id, this.env); @@ -1407,4 +1432,4 @@ export class RelayWebSocket implements DurableObject { console.error('Error sending EVENT:', error); } } -} \ No newline at end of file +} diff --git a/src/protocols/opendating/services/dm-policy.ts b/src/protocols/opendating/services/dm-policy.ts new file mode 100644 index 0000000..ea29246 --- /dev/null +++ b/src/protocols/opendating/services/dm-policy.ts @@ -0,0 +1,46 @@ +import { deriveMemberId } from '../storage/d1/membership.js'; + +export type DirectMessageDecision = + | 'allowed' + | 'blocked' + | 'not-matched' + | 'invalid-recipient'; + +export async function checkDirectMessagePolicy( + db: D1Database, + senderPubkey: string, + recipientPubkey: string | undefined, +): Promise { + if (!recipientPubkey || !/^[0-9a-f]{64}$/i.test(recipientPubkey)) { + return 'invalid-recipient'; + } + if (senderPubkey === recipientPubkey) return 'allowed'; + + const senderId = deriveMemberId(senderPubkey); + const recipientId = deriveMemberId(recipientPubkey); + const row = await db.withSession('first-primary').prepare( + `SELECT + EXISTS( + SELECT 1 FROM od_blocks + WHERE (blocker_member_id = ? AND blocked_member_id = ?) + OR (blocker_member_id = ? AND blocked_member_id = ?) + ) AS blocked, + EXISTS( + SELECT 1 FROM od_matches + WHERE state = 'active' + AND ((member_a = ? AND member_b = ?) OR (member_a = ? AND member_b = ?)) + ) AS matched`, + ).bind( + senderId, + recipientId, + recipientId, + senderId, + senderId, + recipientId, + recipientId, + senderId, + ).first<{ blocked: number; matched: number }>(); + + if (row?.blocked) return 'blocked'; + return row?.matched ? 'allowed' : 'not-matched'; +} diff --git a/tests/opendating/integration/services.test.ts b/tests/opendating/integration/services.test.ts index b464a66..e0a9cc4 100644 --- a/tests/opendating/integration/services.test.ts +++ b/tests/opendating/integration/services.test.ts @@ -14,6 +14,7 @@ import { initOpenDatingExtension } from '../../../src/protocols/opendating/exten import { grantToken, clampAge, publicProfile } from '../../../src/protocols/opendating/services/discovery/service.js'; import { validateProfileContent } from '../../../src/protocols/opendating/services/profile/service.js'; import { BlockService } from '../../../src/protocols/opendating/services/block/service.js'; +import { checkDirectMessagePolicy } from '../../../src/protocols/opendating/services/dm-policy.js'; import { MatcherService } from '../../../src/protocols/opendating/services/matcher/service.js'; import { createEnvelope } from '../../../src/protocols/opendating/protocol/envelope.js'; import type { OpenDatingServiceContext } from '../../../src/protocols/opendating/services/interface.js'; @@ -469,3 +470,39 @@ describe('Block service (D1)', () => { }); }); }); + +describe('Direct-message policy (D1)', () => { + const alicePubkey = 'a'.repeat(64); + const bobPubkey = 'b'.repeat(64); + + it('allows only active, unblocked matches and self archive copies', async () => { + const membership = new D1MembershipStore(db as unknown as D1Database); + await membership.ensureMember(alicePubkey); + await membership.ensureMember(bobPubkey); + const aliceId = deriveMemberId(alicePubkey); + const bobId = deriveMemberId(bobPubkey); + + await expect( + checkDirectMessagePolicy(db as unknown as D1Database, alicePubkey, bobPubkey) + ).resolves.toBe('not-matched'); + + await db.prepare( + `INSERT INTO od_matches (match_id, member_a, member_b, state, created_at, updated_at) + VALUES ('dm-match', ?, ?, 'active', 1000, 1000)` + ).bind(aliceId, bobId).run(); + await expect( + checkDirectMessagePolicy(db as unknown as D1Database, alicePubkey, bobPubkey) + ).resolves.toBe('allowed'); + + await db.prepare( + `INSERT INTO od_blocks (blocker_member_id, blocked_member_id, created_at) + VALUES (?, ?, 1001)` + ).bind(bobId, aliceId).run(); + await expect( + checkDirectMessagePolicy(db as unknown as D1Database, alicePubkey, bobPubkey) + ).resolves.toBe('blocked'); + await expect( + checkDirectMessagePolicy(db as unknown as D1Database, alicePubkey, alicePubkey) + ).resolves.toBe('allowed'); + }); +});