Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -1407,4 +1432,4 @@ export class RelayWebSocket implements DurableObject {
console.error('Error sending EVENT:', error);
}
}
}
}
46 changes: 46 additions & 0 deletions src/protocols/opendating/services/dm-policy.ts
Original file line number Diff line number Diff line change
@@ -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<DirectMessageDecision> {
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';
}
37 changes: 37 additions & 0 deletions tests/opendating/integration/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
Loading