From 70630db6429f1faa5d539d6e0a57e8767c424dc7 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 17 Jul 2024 15:56:52 -0500 Subject: [PATCH 1/2] Add allow/deny of groups and inboxes --- packages/js-sdk/src/Contacts.ts | 196 ++++++++++++++++++++++---- packages/js-sdk/test/Contacts.test.ts | 97 +++++++++++-- 2 files changed, 250 insertions(+), 43 deletions(-) diff --git a/packages/js-sdk/src/Contacts.ts b/packages/js-sdk/src/Contacts.ts index 0d8cf9c22..f0f9a8824 100644 --- a/packages/js-sdk/src/Contacts.ts +++ b/packages/js-sdk/src/Contacts.ts @@ -13,11 +13,19 @@ import Stream from './Stream' export type ConsentState = 'allowed' | 'denied' | 'unknown' -export type ConsentListEntryType = 'address' +export type ConsentListEntryType = 'address' | 'groupId' | 'inboxId' export type PrivatePreferencesAction = privatePreferences.PrivatePreferencesAction +type PrivatePreferencesActionKey = keyof PrivatePreferencesAction + +type PrivatePreferencesActionValueKey = { + [K in PrivatePreferencesActionKey]: keyof NonNullable< + PrivatePreferencesAction[K] + > +}[PrivatePreferencesActionKey] + export class ConsentListEntry { value: string entryType: ConsentListEntryType @@ -43,6 +51,20 @@ export class ConsentListEntry { ): ConsentListEntry { return new ConsentListEntry(address, 'address', permissionType) } + + static fromGroupId( + groupId: string, + permissionType: ConsentState = 'unknown' + ): ConsentListEntry { + return new ConsentListEntry(groupId, 'groupId', permissionType) + } + + static fromInboxId( + inboxId: string, + permissionType: ConsentState = 'unknown' + ): ConsentListEntry { + return new ConsentListEntry(inboxId, 'inboxId', permissionType) + } } export class ConsentList { @@ -68,11 +90,45 @@ export class ConsentList { return entry } + allowGroup(groupId: string) { + const entry = ConsentListEntry.fromGroupId(groupId, 'allowed') + this.entries.set(entry.key, 'allowed') + return entry + } + + denyGroup(groupId: string) { + const entry = ConsentListEntry.fromGroupId(groupId, 'denied') + this.entries.set(entry.key, 'denied') + return entry + } + + allowInboxId(inboxId: string) { + const entry = ConsentListEntry.fromInboxId(inboxId, 'allowed') + this.entries.set(entry.key, 'allowed') + return entry + } + + denyInboxId(inboxId: string) { + const entry = ConsentListEntry.fromInboxId(inboxId, 'denied') + this.entries.set(entry.key, 'denied') + return entry + } + state(address: string) { const entry = ConsentListEntry.fromAddress(address) return this.entries.get(entry.key) ?? 'unknown' } + groupState(groupId: string) { + const entry = ConsentListEntry.fromGroupId(groupId) + return this.entries.get(entry.key) ?? 'unknown' + } + + inboxIdState(inboxId: string) { + const entry = ConsentListEntry.fromInboxId(inboxId) + return this.entries.get(entry.key) ?? 'unknown' + } + async getIdentifier(): Promise { if (!this._identifier) { const { identifier } = @@ -114,6 +170,18 @@ export class ConsentList { action.denyAddress?.walletAddresses.forEach((address) => { entries.push(this.deny(address)) }) + action.allowGroup?.groupIds.forEach((groupId) => { + entries.push(this.allowGroup(groupId)) + }) + action.denyGroup?.groupIds.forEach((groupId) => { + entries.push(this.denyGroup(groupId)) + }) + action.allowInboxId?.inboxIds.forEach((inboxId) => { + entries.push(this.allowInboxId(inboxId)) + }) + action.denyInboxId?.inboxIds.forEach((inboxId) => { + entries.push(this.denyInboxId(inboxId)) + }) }) if (lastTimestampNs) { @@ -179,37 +247,55 @@ export class ConsentList { async publish(entries: ConsentListEntry[]) { const identifier = await this.getIdentifier() - // encoded actions - const actions = entries.reduce((result, entry) => { - // only handle address entries for now - if (entry.entryType === 'address') { - const action: PrivatePreferencesAction = { - allowAddress: - entry.permissionType === 'allowed' - ? { - walletAddresses: [entry.value], - } - : undefined, - denyAddress: - entry.permissionType === 'denied' - ? { - walletAddresses: [entry.value], - } - : undefined, - allowGroup: undefined, - denyGroup: undefined, - allowInboxId: undefined, - denyInboxId: undefined, + // this reduce is purposefully verbose for type safety + const action = entries.reduce((result, entry) => { + let actionKey: PrivatePreferencesActionKey + let valueKey: PrivatePreferencesActionValueKey + let values: string[] + // ignore unknown permission types + if (entry.permissionType === 'unknown') { + return result + } + switch (entry.entryType) { + case 'address': { + actionKey = + entry.permissionType === 'allowed' ? 'allowAddress' : 'denyAddress' + valueKey = 'walletAddresses' + values = result[actionKey]?.[valueKey] ?? [] + break } - return result.concat( - privatePreferences.PrivatePreferencesAction.encode(action).finish() - ) + case 'groupId': { + actionKey = + entry.permissionType === 'allowed' ? 'allowGroup' : 'denyGroup' + valueKey = 'groupIds' + values = result[actionKey]?.[valueKey] ?? [] + break + } + case 'inboxId': { + actionKey = + entry.permissionType === 'allowed' ? 'allowInboxId' : 'denyInboxId' + valueKey = 'inboxIds' + values = result[actionKey]?.[valueKey] ?? [] + break + } + default: + return result } - return result - }, [] as Uint8Array[]) + return { + ...result, + [actionKey]: { + [valueKey]: [...values, entry.value], + }, + } + }, {} as PrivatePreferencesAction) + // encoded action + const payload = + privatePreferences.PrivatePreferencesAction.encode(action).finish() + + // encrypt payload const { responses } = await this.client.keystore.selfEncrypt({ - requests: actions.map((action) => ({ payload: action })), + requests: [{ payload }], }) // encrypted messages @@ -229,7 +315,7 @@ export class ConsentList { timestamp, })) - // publish entries + // publish private preferences update await this.client.publishEnvelopes(envelopes) // update local entries after publishing @@ -361,10 +447,34 @@ export class Contacts { return this.consentList.state(address) === 'denied' } + isGroupAllowed(groupId: string) { + return this.consentList.groupState(groupId) === 'allowed' + } + + isGroupDenied(groupId: string) { + return this.consentList.groupState(groupId) === 'denied' + } + + isInboxAllowed(inboxId: string) { + return this.consentList.inboxIdState(inboxId) === 'allowed' + } + + isInboxDenied(inboxId: string) { + return this.consentList.inboxIdState(inboxId) === 'denied' + } + consentState(address: string) { return this.consentList.state(address) } + groupConsentState(groupId: string) { + return this.consentList.groupState(groupId) + } + + inboxConsentState(inboxId: string) { + return this.consentList.inboxIdState(inboxId) + } + async allow(addresses: string[]) { await this.consentList.publish( addresses.map((address) => @@ -380,4 +490,32 @@ export class Contacts { ) ) } + + async allowGroups(groupIds: string[]) { + await this.consentList.publish( + groupIds.map((groupId) => + ConsentListEntry.fromGroupId(groupId, 'allowed') + ) + ) + } + + async denyGroups(groupIds: string[]) { + await this.consentList.publish( + groupIds.map((groupId) => ConsentListEntry.fromGroupId(groupId, 'denied')) + ) + } + + async allowInboxes(inboxIds: string[]) { + await this.consentList.publish( + inboxIds.map((inboxId) => + ConsentListEntry.fromInboxId(inboxId, 'allowed') + ) + ) + } + + async denyInboxes(inboxIds: string[]) { + await this.consentList.publish( + inboxIds.map((inboxId) => ConsentListEntry.fromInboxId(inboxId, 'denied')) + ) + } } diff --git a/packages/js-sdk/test/Contacts.test.ts b/packages/js-sdk/test/Contacts.test.ts index 1e2d28b2c..b768a5deb 100644 --- a/packages/js-sdk/test/Contacts.test.ts +++ b/packages/js-sdk/test/Contacts.test.ts @@ -44,6 +44,30 @@ describe('Contacts', () => { expect(aliceClient.contacts.isDenied(bob.address)).toBe(true) }) + it('should allow and deny groups', async () => { + await aliceClient.contacts.allowGroups(['foo']) + expect(aliceClient.contacts.groupConsentState('foo')).toBe('allowed') + expect(aliceClient.contacts.isGroupAllowed('foo')).toBe(true) + expect(aliceClient.contacts.isGroupDenied('foo')).toBe(false) + + await aliceClient.contacts.denyGroups(['foo']) + expect(aliceClient.contacts.groupConsentState('foo')).toBe('denied') + expect(aliceClient.contacts.isGroupAllowed('foo')).toBe(false) + expect(aliceClient.contacts.isGroupDenied('foo')).toBe(true) + }) + + it('should allow and deny inboxes', async () => { + await aliceClient.contacts.allowInboxes(['foo']) + expect(aliceClient.contacts.inboxConsentState('foo')).toBe('allowed') + expect(aliceClient.contacts.isInboxAllowed('foo')).toBe(true) + expect(aliceClient.contacts.isInboxDenied('foo')).toBe(false) + + await aliceClient.contacts.denyInboxes(['foo']) + expect(aliceClient.contacts.inboxConsentState('foo')).toBe('denied') + expect(aliceClient.contacts.isInboxAllowed('foo')).toBe(false) + expect(aliceClient.contacts.isInboxDenied('foo')).toBe(true) + }) + it('should allow an address when a conversation is started', async () => { const conversation = await aliceClient.conversations.newConversation( carol.address @@ -116,14 +140,12 @@ describe('Contacts', () => { await bobClient.contacts.deny([carol.address]) await bobClient.contacts.deny([alice.address]) await bobClient.contacts.allow([carol.address]) - - expect(bobClient.contacts.consentState(alice.address)).toBe('denied') - expect(bobClient.contacts.isAllowed(alice.address)).toBe(false) - expect(bobClient.contacts.isDenied(alice.address)).toBe(true) - - expect(bobClient.contacts.consentState(carol.address)).toBe('allowed') - expect(bobClient.contacts.isAllowed(carol.address)).toBe(true) - expect(bobClient.contacts.isDenied(carol.address)).toBe(false) + await bobClient.contacts.allowGroups(['foo', 'bar']) + await bobClient.contacts.denyGroups(['foo']) + await bobClient.contacts.allowGroups(['foo']) + await bobClient.contacts.allowInboxes(['baz', 'qux']) + await bobClient.contacts.denyInboxes(['baz']) + await bobClient.contacts.allowInboxes(['baz']) bobClient = await Client.create(bob, { env: 'local', @@ -131,10 +153,14 @@ describe('Contacts', () => { expect(bobClient.contacts.consentState(alice.address)).toBe('unknown') expect(bobClient.contacts.consentState(carol.address)).toBe('unknown') + expect(bobClient.contacts.groupConsentState('foo')).toBe('unknown') + expect(bobClient.contacts.groupConsentState('bar')).toBe('unknown') + expect(bobClient.contacts.inboxConsentState('baz')).toBe('unknown') + expect(bobClient.contacts.inboxConsentState('qux')).toBe('unknown') const latestEntries = await bobClient.contacts.refreshConsentList() - expect(latestEntries.length).toBe(6) + expect(latestEntries.length).toBe(14) expect(latestEntries).toEqual([ { entryType: 'address', @@ -166,15 +192,54 @@ describe('Contacts', () => { permissionType: 'allowed', value: carol.address, }, + { + entryType: 'groupId', + permissionType: 'allowed', + value: 'foo', + }, + { + entryType: 'groupId', + permissionType: 'allowed', + value: 'bar', + }, + { + entryType: 'groupId', + permissionType: 'denied', + value: 'foo', + }, + { + entryType: 'groupId', + permissionType: 'allowed', + value: 'foo', + }, + { + entryType: 'inboxId', + permissionType: 'allowed', + value: 'baz', + }, + { + entryType: 'inboxId', + permissionType: 'allowed', + value: 'qux', + }, + { + entryType: 'inboxId', + permissionType: 'denied', + value: 'baz', + }, + { + entryType: 'inboxId', + permissionType: 'allowed', + value: 'baz', + }, ]) expect(bobClient.contacts.consentState(alice.address)).toBe('denied') - expect(bobClient.contacts.isAllowed(alice.address)).toBe(false) - expect(bobClient.contacts.isDenied(alice.address)).toBe(true) - expect(bobClient.contacts.consentState(carol.address)).toBe('allowed') - expect(bobClient.contacts.isAllowed(carol.address)).toBe(true) - expect(bobClient.contacts.isDenied(carol.address)).toBe(false) + expect(bobClient.contacts.groupConsentState('foo')).toBe('allowed') + expect(bobClient.contacts.groupConsentState('bar')).toBe('allowed') + expect(bobClient.contacts.inboxConsentState('baz')).toBe('allowed') + expect(bobClient.contacts.inboxConsentState('qux')).toBe('allowed') }) it('should stream consent updates', async () => { @@ -185,6 +250,10 @@ describe('Contacts', () => { // eslint-disable-next-line no-unreachable-loop for await (const action of aliceStream) { numActions++ + expect(action.allowGroup).toBeUndefined() + expect(action.denyGroup).toBeUndefined() + expect(action.allowInboxId).toBeUndefined() + expect(action.denyInboxId).toBeUndefined() expect(action.denyAddress).toBeUndefined() expect(action.allowAddress?.walletAddresses).toEqual([bob.address]) break From 9958ff117f1aa07da13a20b9bb1dea714d731199 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 17 Jul 2024 16:15:39 -0500 Subject: [PATCH 2/2] Create strange-forks-tie.md --- .changeset/strange-forks-tie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/strange-forks-tie.md diff --git a/.changeset/strange-forks-tie.md b/.changeset/strange-forks-tie.md new file mode 100644 index 000000000..26ee10ec8 --- /dev/null +++ b/.changeset/strange-forks-tie.md @@ -0,0 +1,5 @@ +--- +"@xmtp/xmtp-js": minor +--- + +Add allow/deny of groups and inboxes