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
6 changes: 3 additions & 3 deletions docs/opendating/CLIENT-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(), {
Expand Down
54 changes: 43 additions & 11 deletions src/protocols/opendating/services/block/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -38,9 +45,8 @@ export class BlockService implements OpenDatingService {
}

private async removeBlock(memberId: string, request: OpenDatingEnvelope): Promise<ServiceResult> {
const payload = request.payload as Record<string, unknown>;
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,
Expand Down Expand Up @@ -68,8 +74,16 @@ export class BlockService implements OpenDatingService {
}

private async createBlock(memberId: string, request: OpenDatingEnvelope, ctx: OpenDatingServiceContext): Promise<ServiceResult> {
const payload = request.payload as Record<string, any>;
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');
Expand Down Expand Up @@ -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<ServiceResult> {
const payload = request.payload as Record<string, any>;
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');
Expand Down
33 changes: 27 additions & 6 deletions src/protocols/opendating/services/matcher/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}];
}),
}),
};
}
Expand Down
61 changes: 60 additions & 1 deletion tests/opendating/integration/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading