From 37b32cd832908d6ffbfe6085e667675832156a98 Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Sat, 29 Aug 2026 12:59:49 -0400 Subject: [PATCH] Return usable match and block records --- docs/opendating/CLIENT-INTEGRATION.md | 6 +- .../opendating/services/block/service.ts | 54 ++++++++++++---- .../opendating/services/matcher/service.ts | 33 ++++++++-- tests/opendating/integration/services.test.ts | 61 ++++++++++++++++++- 4 files changed, 133 insertions(+), 21 deletions(-) diff --git a/docs/opendating/CLIENT-INTEGRATION.md b/docs/opendating/CLIENT-INTEGRATION.md index 255b1af..dda2a08 100644 --- a/docs/opendating/CLIENT-INTEGRATION.md +++ b/docs/opendating/CLIENT-INTEGRATION.md @@ -241,12 +241,11 @@ const env = createEnvelope('intent.revoke', crypto.randomUUID(), { ## 9. Matches -When both parties like each other, the matcher sends `match.created` notifications -to each party via NIP-59 gift wraps. +When both parties like each other, the match appears in each member's match list. ```typescript const env = createEnvelope('match.list', crypto.randomUUID(), {}); -// Response contains match_id, other_member, state, created_at +// Response contains match_id, pubkey, profile, state, created_at ``` Match IDs are deterministic from the two pubkeys. @@ -279,6 +278,7 @@ const env = createEnvelope('block.create', crypto.randomUUID(), { // List blocks const env = createEnvelope('block.list', crypto.randomUUID(), {}); +// Response contains blocks: [{ target_pubkey, created_at }] // Remove block const env = createEnvelope('block.remove', crypto.randomUUID(), { diff --git a/src/protocols/opendating/services/block/service.ts b/src/protocols/opendating/services/block/service.ts index 1aaed17..c36e18b 100644 --- a/src/protocols/opendating/services/block/service.ts +++ b/src/protocols/opendating/services/block/service.ts @@ -9,6 +9,13 @@ import type { OpenDatingEnvelope } from '../../protocol/envelope.js'; import { createEnvelope, createErrorEnvelope } from '../../protocol/envelope.js'; import { D1MembershipStore } from '../../storage/d1/membership.js'; +function readTargetPubkey(request: OpenDatingEnvelope): string | null { + const target = request.payload.target_pubkey; + return typeof target === 'string' && /^[0-9a-f]{64}$/i.test(target) + ? target.toLowerCase() + : null; +} + export class BlockService implements OpenDatingService { private membership: D1MembershipStore; @@ -38,9 +45,8 @@ export class BlockService implements OpenDatingService { } private async removeBlock(memberId: string, request: OpenDatingEnvelope): Promise { - const payload = request.payload as Record; - const targetPubkey = payload.target_pubkey; - if (typeof targetPubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(targetPubkey)) { + const targetPubkey = readTargetPubkey(request); + if (!targetPubkey) { return { response: createErrorEnvelope( request.request_id, @@ -68,8 +74,16 @@ export class BlockService implements OpenDatingService { } private async createBlock(memberId: string, request: OpenDatingEnvelope, ctx: OpenDatingServiceContext): Promise { - const payload = request.payload as Record; - const targetPubkey = payload.target_pubkey as string; + const targetPubkey = readTargetPubkey(request); + if (!targetPubkey || targetPubkey === ctx.senderPubkey) { + return { + response: createErrorEnvelope( + request.request_id, + 'invalid_envelope', + 'Invalid target_pubkey', + ), + }; + } const targetMemberId = this.membership.getMemberId(targetPubkey); const now = Math.floor(Date.now() / 1000); const session = this.db.withSession('first-primary'); @@ -107,19 +121,37 @@ export class BlockService implements OpenDatingService { `SELECT blocked_member_id, created_at FROM od_blocks WHERE blocker_member_id = ? ORDER BY created_at DESC` ).bind(memberId).all(); + const rows = (blocks.results ?? []) as unknown as Array<{ + blocked_member_id: string; + created_at: number; + }>; + const pubkeys = await this.membership.getPubkeysByMemberIds( + rows.map((row) => row.blocked_member_id), + ); + return { response: createEnvelope('block.list.result', request.request_id, { - blocked: blocks.results.map((r: any) => ({ - member_id: r.blocked_member_id, - created_at: r.created_at, - })), + blocks: rows.flatMap((row) => { + const targetPubkey = pubkeys.get(row.blocked_member_id); + return targetPubkey + ? [{ target_pubkey: targetPubkey, created_at: row.created_at }] + : []; + }), }), }; } private async createUnmatch(memberId: string, request: OpenDatingEnvelope, ctx: OpenDatingServiceContext): Promise { - const payload = request.payload as Record; - const targetPubkey = payload.target_pubkey as string; + const targetPubkey = readTargetPubkey(request); + if (!targetPubkey || targetPubkey === ctx.senderPubkey) { + return { + response: createErrorEnvelope( + request.request_id, + 'invalid_envelope', + 'Invalid target_pubkey', + ), + }; + } const targetMemberId = this.membership.getMemberId(targetPubkey); const now = Math.floor(Date.now() / 1000); const session = this.db.withSession('first-primary'); diff --git a/src/protocols/opendating/services/matcher/service.ts b/src/protocols/opendating/services/matcher/service.ts index b1a97a3..e305699 100644 --- a/src/protocols/opendating/services/matcher/service.ts +++ b/src/protocols/opendating/services/matcher/service.ts @@ -175,14 +175,35 @@ export class MatcherService implements OpenDatingService { ORDER BY created_at DESC LIMIT 50` ).bind(memberId, memberId).all(); + const rows = (matches.results ?? []) as unknown as Array<{ + match_id: string; + member_a: string; + member_b: string; + state: string; + created_at: number; + }>; + const otherMemberIds = rows.map((row) => + row.member_a === memberId ? row.member_b : row.member_a + ); + const [pubkeys, profiles] = await Promise.all([ + this.membership.getPubkeysByMemberIds(otherMemberIds), + this.membership.getProfileContentsByMemberIds(otherMemberIds), + ]); + return { response: createEnvelope('match.list.result', request.request_id, { - matches: matches.results.map((r: any) => ({ - match_id: r.match_id, - other_member: r.member_a === memberId ? r.member_b : r.member_a, - state: r.state, - created_at: r.created_at, - })), + matches: rows.flatMap((row) => { + const otherMemberId = row.member_a === memberId ? row.member_b : row.member_a; + const pubkey = pubkeys.get(otherMemberId); + if (!pubkey) return []; + return [{ + match_id: row.match_id, + pubkey, + profile: profiles.get(otherMemberId), + state: row.state, + created_at: row.created_at, + }]; + }), }), }; } diff --git a/tests/opendating/integration/services.test.ts b/tests/opendating/integration/services.test.ts index 9324517..b464a66 100644 --- a/tests/opendating/integration/services.test.ts +++ b/tests/opendating/integration/services.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { readFileSync } from 'fs'; import { createTestDb, D1Adapter } from '../../harness/d1-adapter.js'; -import { initMembershipKeys, resetMembershipKeys, deriveMemberId } from '../../../src/protocols/opendating/storage/d1/membership.js'; +import { D1MembershipStore, initMembershipKeys, resetMembershipKeys, deriveMemberId } from '../../../src/protocols/opendating/storage/d1/membership.js'; import { initOpenDatingExtension } from '../../../src/protocols/opendating/extension.js'; import { grantToken, clampAge, publicProfile } from '../../../src/protocols/opendating/services/discovery/service.js'; import { validateProfileContent } from '../../../src/protocols/opendating/services/profile/service.js'; @@ -178,6 +178,42 @@ describe('Matcher SQL (D1)', () => { }; } + it('returns the other member public key and profile in match lists', async () => { + const membership = new D1MembershipStore(db as unknown as D1Database); + const alice = await membership.ensureMember(alicePubkey); + const bob = await membership.ensureMember(bobPubkey); + await membership.updateProfileContent(bobPubkey, { + display_name: 'Bob', + age: 31, + gender: 'man', + relationship_intent: 'long_term', + }); + await db.prepare( + `INSERT INTO od_matches (match_id, member_a, member_b, state, created_at, updated_at) + VALUES ('match-1', ?, ?, 'active', 1000, 1000)`, + ).bind(alice.memberId, bob.memberId).run(); + + const service = new MatcherService( + 'matcher', + servicePubkey, + db as unknown as D1Database, + ); + const requestId = 'match-list-contract'; + const result = await service.handle( + createEnvelope('match.list', requestId, {}), + context(requestId), + ); + + expect(result.response.payload).toMatchObject({ + matches: [{ + match_id: 'match-1', + pubkey: bobPubkey, + profile: { display_name: 'Bob', age: 31 }, + created_at: 1000, + }], + }); + }); + it('consumes a candidate grant exactly once before like side effects', async () => { const service = new MatcherService( 'matcher', @@ -392,6 +428,29 @@ describe('Block service (D1)', () => { }); }); + it('returns actionable public keys from the block list', async () => { + const membership = new D1MembershipStore(db as unknown as D1Database); + await membership.ensureMember(bobPubkey); + const service = new BlockService( + 'dm_policy', + servicePubkey, + db as unknown as D1Database, + ); + + await service.handle( + createEnvelope('block.create', 'block-create-list', { target_pubkey: bobPubkey }), + context('block-create-list'), + ); + const result = await service.handle( + createEnvelope('block.list', 'block-list-contract', {}), + context('block-list-contract'), + ); + + expect(result.response.payload).toMatchObject({ + blocks: [{ target_pubkey: bobPubkey }], + }); + }); + it('rejects malformed pubkeys before deriving a member ID', async () => { const service = new BlockService( 'dm_policy',