diff --git a/__tests__/hostApi/pushSubscription.spec.ts b/__tests__/hostApi/pushSubscription.spec.ts new file mode 100644 index 00000000..4ac2114e --- /dev/null +++ b/__tests__/hostApi/pushSubscription.spec.ts @@ -0,0 +1,496 @@ +import { + PushAddRulesErr, + PushBroadcastErr, + PushListRulesErr, + PushRemoveRulesErr, + PushSetRulesErr, + createHostApi, + createTransport, + enumValue, +} from '@novasamatech/host-api'; +import { createPushSubscriptionManager } from '@novasamatech/host-api-wrapper'; +import type { ContainerHandlerOf } from '@novasamatech/host-container'; +import { createContainer } from '@novasamatech/host-container'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createHostApiProviders } from './__mocks__/hostApiProviders.js'; + +function setup() { + const providers = createHostApiProviders(); + const container = createContainer(providers.host); + const sdkTransport = createTransport(providers.sdk); + const hostApi = createHostApi(sdkTransport); + const pushSubscription = createPushSubscriptionManager(sdkTransport); + + return { container, hostApi, pushSubscription }; +} + +const signerA = new Uint8Array(32).fill(0xa1); +const signerB = new Uint8Array(32).fill(0xb2); +const topicX = new Uint8Array(32).fill(0x01); +const topicY = new Uint8Array(32).fill(0x02); + +const ruleA = { signer: signerA, topics: [topicX, topicY] }; +const ruleB = { signer: signerB, topics: [topicX] }; + +describe('Host API: PushAddRules', () => { + it('should round-trip a list of rules to the handler', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + + const handler = vi.fn>((_, { ok }) => ok(undefined)); + container.handlePushAddRules(handler); + + const result = await hostApi.pushAddRules(enumValue('v1', { rules: [ruleA, ruleB] })); + + expect(result.isOk()).toBe(true); + expect(handler).toBeCalledWith({ rules: [ruleA, ruleB] }, { ok: expect.any(Function), err: expect.any(Function) }); + }); + + it('should be idempotent: repeated calls with the same rule converge', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + + const store = new Map(); + const key = (signer: Uint8Array) => Array.from(signer).join(','); + + container.handlePushAddRules((params, { ok }) => { + for (const rule of params.rules) { + const existing = store.get(key(rule.signer)) ?? []; + const merged = [...existing]; + for (const topic of rule.topics) { + if (!merged.some(t => Array.from(t).join(',') === Array.from(topic).join(','))) { + merged.push(topic); + } + } + store.set(key(rule.signer), merged); + } + return ok(undefined); + }); + + await hostApi.pushAddRules(enumValue('v1', { rules: [ruleA] })); + await hostApi.pushAddRules(enumValue('v1', { rules: [ruleA] })); + + expect(store.size).toBe(1); + expect(store.get(key(signerA))).toHaveLength(2); + }); + + it('should return PermissionDenied when Notifications permission is denied', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(false)); + + const handler = vi.fn>((_, { ok }) => ok(undefined)); + container.handlePushAddRules(handler); + + const result = await hostApi.pushAddRules(enumValue('v1', { rules: [ruleA] })); + + expect(result.isErr()).toBe(true); + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.tag).toBe('v1'); + expect(failure.value).toBeInstanceOf(PushAddRulesErr.PermissionDenied); + }, + ); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should propagate NotificationSystemUnavailable from the host', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + + const error = new PushAddRulesErr.NotificationSystemUnavailable({ reason: 'backend offline' }); + container.handlePushAddRules((_, { err }) => err(error)); + + const result = await hostApi.pushAddRules(enumValue('v1', { rules: [ruleA] })); + + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.tag).toBe('v1'); + expect(failure.value).toEqual(error); + }, + ); + }); +}); + +describe('Host API: PushRemoveRules', () => { + it('should round-trip a list of rules to the handler', async () => { + const { container, hostApi } = setup(); + + const handler = vi.fn>((_, { ok }) => ok(undefined)); + container.handlePushRemoveRules(handler); + + const result = await hostApi.pushRemoveRules(enumValue('v1', { rules: [ruleA] })); + + expect(result.isOk()).toBe(true); + expect(handler).toBeCalledWith({ rules: [ruleA] }, { ok: expect.any(Function), err: expect.any(Function) }); + }); + + it('should not require Notifications permission', async () => { + const { container, hostApi } = setup(); + const devicePermissionHandler = vi.fn(); + container.handleDevicePermission(devicePermissionHandler); + + container.handlePushRemoveRules((_, { ok }) => ok(undefined)); + + const result = await hostApi.pushRemoveRules(enumValue('v1', { rules: [ruleA] })); + + expect(result.isOk()).toBe(true); + expect(devicePermissionHandler).not.toHaveBeenCalled(); + }); + + it('should propagate NotificationSystemUnavailable', async () => { + const { container, hostApi } = setup(); + const error = new PushRemoveRulesErr.NotificationSystemUnavailable({ reason: 'backend offline' }); + container.handlePushRemoveRules((_, { err }) => err(error)); + + const result = await hostApi.pushRemoveRules(enumValue('v1', { rules: [ruleA] })); + + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.tag).toBe('v1'); + expect(failure.value).toEqual(error); + }, + ); + }); +}); + +describe('Host API: PushListRules', () => { + it('should return the active rule set', async () => { + const { container, hostApi } = setup(); + container.handlePushListRules((_, { ok }) => ok({ rules: [ruleA, ruleB] })); + + const result = await hostApi.pushListRules(enumValue('v1', undefined)); + + result.match( + ok => { + expect(ok.tag).toBe('v1'); + expect(ok.value).toEqual({ rules: [ruleA, ruleB] }); + }, + () => { + throw new Error('Expected success'); + }, + ); + }); + + it('should not require Notifications permission', async () => { + const { container, hostApi } = setup(); + const devicePermissionHandler = vi.fn(); + container.handleDevicePermission(devicePermissionHandler); + + container.handlePushListRules((_, { ok }) => ok({ rules: [] })); + + const result = await hostApi.pushListRules(enumValue('v1', undefined)); + + expect(result.isOk()).toBe(true); + expect(devicePermissionHandler).not.toHaveBeenCalled(); + }); + + it('should return an empty rule set when the subscription is empty', async () => { + const { container, hostApi } = setup(); + container.handlePushListRules((_, { ok }) => ok({ rules: [] })); + + const result = await hostApi.pushListRules(enumValue('v1', undefined)); + + result.match( + ok => { + expect(ok.value.rules).toEqual([]); + }, + () => { + throw new Error('Expected success'); + }, + ); + }); + + it('should propagate NotificationSystemUnavailable', async () => { + const { container, hostApi } = setup(); + const error = new PushListRulesErr.NotificationSystemUnavailable({ reason: 'backend offline' }); + container.handlePushListRules((_, { err }) => err(error)); + + const result = await hostApi.pushListRules(enumValue('v1', undefined)); + + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.tag).toBe('v1'); + expect(failure.value).toEqual(error); + }, + ); + }); +}); + +describe('Host API: PushSetRules', () => { + it('should atomically replace the entire rule set', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + + const store: { rules: { signer: Uint8Array; topics: Uint8Array[] }[] } = { rules: [ruleA, ruleB] }; + container.handlePushSetRules((params, { ok }) => { + store.rules = params.rules; + return ok(undefined); + }); + + const result = await hostApi.pushSetRules(enumValue('v1', { rules: [ruleB] })); + + expect(result.isOk()).toBe(true); + expect(store.rules).toEqual([ruleB]); + }); + + it('should return PermissionDenied when Notifications permission is denied', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(false)); + + const handler = vi.fn>((_, { ok }) => ok(undefined)); + container.handlePushSetRules(handler); + + const result = await hostApi.pushSetRules(enumValue('v1', { rules: [ruleA] })); + + expect(result.isErr()).toBe(true); + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.tag).toBe('v1'); + expect(failure.value).toBeInstanceOf(PushSetRulesErr.PermissionDenied); + }, + ); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('Host API: PushBroadcast', () => { + it('should return a messageHash from the host', async () => { + const { container, hostApi } = setup(); + const messageHash = new Uint8Array(32).fill(0xff); + + const handler = vi.fn>((_, { ok }) => ok({ messageHash })); + container.handlePushBroadcast(handler); + + const content = { title: 'Doors open at 19:00', body: 'Welcome to the festival', deeplink: undefined }; + const result = await hostApi.pushBroadcast(enumValue('v1', { topics: [topicX], content })); + + result.match( + ok => { + expect(ok.tag).toBe('v1'); + expect(ok.value.messageHash).toEqual(messageHash); + }, + () => { + throw new Error('Expected success'); + }, + ); + // signer is host-attested and absent from the request the product sends. + expect(handler).toHaveBeenCalledWith( + { topics: [topicX], content }, + { ok: expect.any(Function), err: expect.any(Function) }, + ); + const [received] = handler.mock.calls[0]!; + expect(received).not.toHaveProperty('signer'); + }); + + it('should not require Notifications permission (host attests signer)', async () => { + const { container, hostApi } = setup(); + const devicePermissionHandler = vi.fn(); + container.handleDevicePermission(devicePermissionHandler); + + container.handlePushBroadcast((_, { ok }) => ok({ messageHash: new Uint8Array(32) })); + + const content = { title: 't', body: 'b', deeplink: undefined }; + const result = await hostApi.pushBroadcast(enumValue('v1', { topics: [topicX], content })); + + expect(result.isOk()).toBe(true); + expect(devicePermissionHandler).not.toHaveBeenCalled(); + }); + + it('should propagate NotificationSystemUnavailable', async () => { + const { container, hostApi } = setup(); + const error = new PushBroadcastErr.NotificationSystemUnavailable({ reason: 'backend offline' }); + container.handlePushBroadcast((_, { err }) => err(error)); + + const content = { title: 't', body: 'b', deeplink: undefined }; + const result = await hostApi.pushBroadcast(enumValue('v1', { topics: [topicX], content })); + + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.tag).toBe('v1'); + expect(failure.value).toEqual(error); + }, + ); + }); +}); + +describe('Wrapper: createPushSubscriptionManager', () => { + it('addRules resolves on success', async () => { + const { container, pushSubscription } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + container.handlePushAddRules((_, { ok }) => ok(undefined)); + + await expect(pushSubscription.addRules([ruleA])).resolves.toBeUndefined(); + }); + + it('addRules rejects with PermissionDenied error class when permission denied', async () => { + const { container, pushSubscription } = setup(); + container.handleDevicePermission((_, { ok }) => ok(false)); + + await expect(pushSubscription.addRules([ruleA])).rejects.toBeInstanceOf(PushAddRulesErr.PermissionDenied); + }); + + it('listRules resolves with the rule array', async () => { + const { container, pushSubscription } = setup(); + container.handlePushListRules((_, { ok }) => ok({ rules: [ruleA, ruleB] })); + + await expect(pushSubscription.listRules()).resolves.toEqual([ruleA, ruleB]); + }); + + it('setRules atomically replaces the set', async () => { + const { container, pushSubscription } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + + const store: { rules: { signer: Uint8Array; topics: Uint8Array[] }[] } = { rules: [ruleA, ruleB] }; + container.handlePushSetRules((params, { ok }) => { + store.rules = params.rules; + return ok(undefined); + }); + + await pushSubscription.setRules([ruleB]); + expect(store.rules).toEqual([ruleB]); + }); + + it('removeRules resolves without requiring permission', async () => { + const { container, pushSubscription } = setup(); + const devicePermissionHandler = vi.fn(); + container.handleDevicePermission(devicePermissionHandler); + container.handlePushRemoveRules((_, { ok }) => ok(undefined)); + + await expect(pushSubscription.removeRules([ruleA])).resolves.toBeUndefined(); + expect(devicePermissionHandler).not.toHaveBeenCalled(); + }); +}); + +describe('Wrapper: broadcast (via createPushSubscriptionManager)', () => { + it('broadcast resolves with the messageHash', async () => { + const { container, pushSubscription } = setup(); + const messageHash = new Uint8Array(32).fill(0x7f); + container.handlePushBroadcast((_, { ok }) => ok({ messageHash })); + + const result = await pushSubscription.broadcast({ + topics: [topicX], + content: { title: 't', body: 'b', deeplink: undefined }, + }); + + expect(result.messageHash).toEqual(messageHash); + }); + + it('broadcast rejects with NotificationSystemUnavailable when host returns one', async () => { + const { container, pushSubscription } = setup(); + container.handlePushBroadcast((_, { err }) => + err(new PushBroadcastErr.NotificationSystemUnavailable({ reason: 'backend offline' })), + ); + + await expect( + pushSubscription.broadcast({ + topics: [topicX], + content: { title: 't', body: 'b', deeplink: undefined }, + }), + ).rejects.toBeInstanceOf(PushBroadcastErr.NotificationSystemUnavailable); + }); +}); + +describe('Container defaults: not implemented', () => { + it('pushAddRules returns Unknown(not implemented) when no handler is registered and permission is granted', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + + const result = await hostApi.pushAddRules(enumValue('v1', { rules: [ruleA] })); + + expect(result.isErr()).toBe(true); + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.value).toBeInstanceOf(PushAddRulesErr.Unknown); + }, + ); + }); + + it('pushSetRules returns Unknown(not implemented) when no handler is registered and permission is granted', async () => { + const { container, hostApi } = setup(); + container.handleDevicePermission((_, { ok }) => ok(true)); + + const result = await hostApi.pushSetRules(enumValue('v1', { rules: [ruleA] })); + + expect(result.isErr()).toBe(true); + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.value).toBeInstanceOf(PushSetRulesErr.Unknown); + }, + ); + }); + + it('pushRemoveRules returns Unknown(not implemented) when no handler is registered', async () => { + const { hostApi } = setup(); + + const result = await hostApi.pushRemoveRules(enumValue('v1', { rules: [ruleA] })); + + expect(result.isErr()).toBe(true); + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.value).toBeInstanceOf(PushRemoveRulesErr.Unknown); + }, + ); + }); + + it('pushListRules returns Unknown(not implemented) when no handler is registered', async () => { + const { hostApi } = setup(); + + const result = await hostApi.pushListRules(enumValue('v1', undefined)); + + expect(result.isErr()).toBe(true); + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.value).toBeInstanceOf(PushListRulesErr.Unknown); + }, + ); + }); + + it('pushBroadcast returns Unknown(not implemented) when no handler is registered', async () => { + const { hostApi } = setup(); + + const content = { title: 't', body: 'b', deeplink: undefined }; + const result = await hostApi.pushBroadcast(enumValue('v1', { topics: [topicX], content })); + + expect(result.isErr()).toBe(true); + result.match( + () => { + throw new Error('Expected failure'); + }, + failure => { + expect(failure.value).toBeInstanceOf(PushBroadcastErr.Unknown); + }, + ); + }); +}); diff --git a/packages/host-api-wrapper/src/index.ts b/packages/host-api-wrapper/src/index.ts index 63be7ace..89e47ab5 100644 --- a/packages/host-api-wrapper/src/index.ts +++ b/packages/host-api-wrapper/src/index.ts @@ -41,6 +41,9 @@ export { createLocalStorage, hostLocalStorage } from './localStorage.js'; export type { NotificationId, PushNotificationInput } from './notification.js'; export { createNotificationManager, notificationManager } from './notification.js'; +export type { PushBroadcastContent, PushBroadcastInput, PushBroadcastResult, PushRule } from './pushSubscription.js'; +export { createPushSubscriptionManager, pushSubscriptionManager } from './pushSubscription.js'; + export { createPreimageManager, preimageManager } from './preimage.js'; export type { PaymentBalance, PaymentStatus, TopUpSource } from './payments.js'; diff --git a/packages/host-api-wrapper/src/pushSubscription.ts b/packages/host-api-wrapper/src/pushSubscription.ts new file mode 100644 index 00000000..735c708a --- /dev/null +++ b/packages/host-api-wrapper/src/pushSubscription.ts @@ -0,0 +1,70 @@ +import type { + CodecType, + PushBroadcastContent as PushBroadcastContentCodec, + PushRule as PushRuleCodec, + Topic as TopicCodec, + Transport, +} from '@novasamatech/host-api'; +import { createHostApi, enumValue } from '@novasamatech/host-api'; + +import { resultToPromise, unwrapVersionedResult } from './helpers.js'; +import { sandboxTransport } from './sandboxTransport.js'; + +export type PushRule = CodecType; +export type Topic = CodecType; +export type PushBroadcastContent = CodecType; + +export type PushBroadcastInput = { + topics: Topic[]; + content: PushBroadcastContent; +}; + +export type PushBroadcastResult = { + /** Blake2b-256 of the broadcast, for dedup and audit. */ + messageHash: Uint8Array; +}; + +export const createPushSubscriptionManager = (transport: Transport = sandboxTransport) => { + const supportedVersion = 'v1'; + const hostApi = createHostApi(transport); + + return { + addRules(rules: PushRule[]): Promise { + return resultToPromise( + unwrapVersionedResult(supportedVersion, hostApi.pushAddRules(enumValue(supportedVersion, { rules }))), + ); + }, + + removeRules(rules: PushRule[]): Promise { + return resultToPromise( + unwrapVersionedResult(supportedVersion, hostApi.pushRemoveRules(enumValue(supportedVersion, { rules }))), + ); + }, + + async listRules(): Promise { + const response = await resultToPromise( + unwrapVersionedResult(supportedVersion, hostApi.pushListRules(enumValue(supportedVersion, undefined))), + ); + return response.rules; + }, + + setRules(rules: PushRule[]): Promise { + return resultToPromise( + unwrapVersionedResult(supportedVersion, hostApi.pushSetRules(enumValue(supportedVersion, { rules }))), + ); + }, + + /** + * Interim publish path. The host sets `signer` to the calling product's + * identity, so it is absent from the request. Replaced once Statement Store + * 1-to-many encryption ships. + */ + broadcast(input: PushBroadcastInput): Promise { + return resultToPromise( + unwrapVersionedResult(supportedVersion, hostApi.pushBroadcast(enumValue(supportedVersion, input))), + ); + }, + }; +}; + +export const pushSubscriptionManager = createPushSubscriptionManager(); diff --git a/packages/host-api/src/hostApi.ts b/packages/host-api/src/hostApi.ts index 6c9dee3a..a4ef3e01 100644 --- a/packages/host-api/src/hostApi.ts +++ b/packages/host-api/src/hostApi.ts @@ -15,6 +15,13 @@ import { NavigateToErr } from './protocol/v1/navigation.js'; import { PushNotificationError } from './protocol/v1/notification.js'; import { PaymentRequestErr, PaymentTopUpErr } from './protocol/v1/payments.js'; import { PreimageSubmitErr } from './protocol/v1/preimage.js'; +import { + PushAddRulesErr, + PushBroadcastErr, + PushListRulesErr, + PushRemoveRulesErr, + PushSetRulesErr, +} from './protocol/v1/pushSubscription.js'; import { ResourceAllocationErr } from './protocol/v1/resourceAllocation.js'; import { SigningErr } from './protocol/v1/sign.js'; import { StatementProofErr } from './protocol/v1/statementStore.js'; @@ -116,6 +123,41 @@ export function createHostApi(transport: Transport): HostApi { })); }, + pushAddRules(payload) { + return makeRequest(transport.request('host_push_add_rules', payload), reason => ({ + tag: payload.tag, + value: new PushAddRulesErr.Unknown({ reason }), + })); + }, + + pushRemoveRules(payload) { + return makeRequest(transport.request('host_push_remove_rules', payload), reason => ({ + tag: payload.tag, + value: new PushRemoveRulesErr.Unknown({ reason }), + })); + }, + + pushListRules(payload) { + return makeRequest(transport.request('host_push_list_rules', payload), reason => ({ + tag: payload.tag, + value: new PushListRulesErr.Unknown({ reason }), + })); + }, + + pushSetRules(payload) { + return makeRequest(transport.request('host_push_set_rules', payload), reason => ({ + tag: payload.tag, + value: new PushSetRulesErr.Unknown({ reason }), + })); + }, + + pushBroadcast(payload) { + return makeRequest(transport.request('host_push_broadcast', payload), reason => ({ + tag: payload.tag, + value: new PushBroadcastErr.Unknown({ reason }), + })); + }, + navigateTo(payload) { return makeRequest(transport.request('host_navigate_to', payload), reason => ({ tag: payload.tag, diff --git a/packages/host-api/src/index.ts b/packages/host-api/src/index.ts index 6df692ca..236e8ccc 100644 --- a/packages/host-api/src/index.ts +++ b/packages/host-api/src/index.ts @@ -90,6 +90,15 @@ export { StorageErr } from './protocol/v1/localStorage.js'; export { DevicePermission } from './protocol/v1/devicePermission.js'; export { RemotePermission } from './protocol/v1/remotePermission.js'; export { NotificationId, PushNotification, PushNotificationError } from './protocol/v1/notification.js'; +export { + PushAddRulesErr, + PushBroadcastContent, + PushBroadcastErr, + PushListRulesErr, + PushRemoveRulesErr, + PushRule, + PushSetRulesErr, +} from './protocol/v1/pushSubscription.js'; export { NavigateToErr } from './protocol/v1/navigation.js'; export { PreimageKey, PreimageSubmitErr, PreimageValue } from './protocol/v1/preimage.js'; export { AllocatableResource, AllocationOutcome, ResourceAllocationErr } from './protocol/v1/resourceAllocation.js'; diff --git a/packages/host-api/src/protocol/impl.ts b/packages/host-api/src/protocol/impl.ts index f3962a8d..8d7e7da2 100644 --- a/packages/host-api/src/protocol/impl.ts +++ b/packages/host-api/src/protocol/impl.ts @@ -110,6 +110,18 @@ import { PreimageSubmitV1_request, PreimageSubmitV1_response, } from './v1/preimage.js'; +import { + PushAddRulesV1_request, + PushAddRulesV1_response, + PushBroadcastV1_request, + PushBroadcastV1_response, + PushListRulesV1_request, + PushListRulesV1_response, + PushRemoveRulesV1_request, + PushRemoveRulesV1_response, + PushSetRulesV1_request, + PushSetRulesV1_response, +} from './v1/pushSubscription.js'; import { RemotePermissionV1_request, RemotePermissionV1_response } from './v1/remotePermission.js'; import { RequestResourceAllocationV1_request, RequestResourceAllocationV1_response } from './v1/resourceAllocation.js'; import { @@ -430,4 +442,24 @@ export const hostApiProtocol = { host_push_notification_cancel: versionedRequest({ v1: [PushNotificationCancelV1_request, PushNotificationCancelV1_response], }), + + host_push_add_rules: versionedRequest({ + v1: [PushAddRulesV1_request, PushAddRulesV1_response], + }), + + host_push_remove_rules: versionedRequest({ + v1: [PushRemoveRulesV1_request, PushRemoveRulesV1_response], + }), + + host_push_list_rules: versionedRequest({ + v1: [PushListRulesV1_request, PushListRulesV1_response], + }), + + host_push_set_rules: versionedRequest({ + v1: [PushSetRulesV1_request, PushSetRulesV1_response], + }), + + host_push_broadcast: versionedRequest({ + v1: [PushBroadcastV1_request, PushBroadcastV1_response], + }), } as const; diff --git a/packages/host-api/src/protocol/v1/pushSubscription.ts b/packages/host-api/src/protocol/v1/pushSubscription.ts new file mode 100644 index 00000000..3eb7af3b --- /dev/null +++ b/packages/host-api/src/protocol/v1/pushSubscription.ts @@ -0,0 +1,82 @@ +import { ErrEnum } from '@novasamatech/scale'; +import { Bytes, Option, Result, Struct, Vector, _void, str } from 'scale-ts'; + +import { GenericErr } from '../commonCodecs.js'; + +import { AccountId } from './accounts.js'; +import { Topic } from './statementStore.js'; + +// One or more topics the subscriber wants to hear about from a single +// publisher. Equivalent to a flat set of (signer, topic) pairs. +export const PushRule = Struct({ + signer: AccountId, + topics: Vector(Topic), +}); + +const RulesContainer = Struct({ + rules: Vector(PushRule), +}); + +// errors + +export const PushAddRulesErr = ErrEnum('PushAddRulesErr', { + PermissionDenied: [_void, 'PushAddRules: permission denied'], + NotificationSystemUnavailable: [GenericErr, 'PushAddRules: notification system unavailable'], + Unknown: [GenericErr, 'PushAddRules: unknown error'], +}); + +export const PushRemoveRulesErr = ErrEnum('PushRemoveRulesErr', { + NotificationSystemUnavailable: [GenericErr, 'PushRemoveRules: notification system unavailable'], + Unknown: [GenericErr, 'PushRemoveRules: unknown error'], +}); + +export const PushListRulesErr = ErrEnum('PushListRulesErr', { + NotificationSystemUnavailable: [GenericErr, 'PushListRules: notification system unavailable'], + Unknown: [GenericErr, 'PushListRules: unknown error'], +}); + +export const PushSetRulesErr = ErrEnum('PushSetRulesErr', { + PermissionDenied: [_void, 'PushSetRules: permission denied'], + NotificationSystemUnavailable: [GenericErr, 'PushSetRules: notification system unavailable'], + Unknown: [GenericErr, 'PushSetRules: unknown error'], +}); + +export const PushBroadcastErr = ErrEnum('PushBroadcastErr', { + NotificationSystemUnavailable: [GenericErr, 'PushBroadcast: notification system unavailable'], + Unknown: [GenericErr, 'PushBroadcast: unknown error'], +}); + +// rule management + +export const PushAddRulesV1_request = RulesContainer; +export const PushAddRulesV1_response = Result(_void, PushAddRulesErr); + +export const PushRemoveRulesV1_request = RulesContainer; +export const PushRemoveRulesV1_response = Result(_void, PushRemoveRulesErr); + +export const PushListRulesV1_request = _void; +export const PushListRulesV1_response = Result(RulesContainer, PushListRulesErr); + +export const PushSetRulesV1_request = RulesContainer; +export const PushSetRulesV1_response = Result(_void, PushSetRulesErr); + +// interim direct broadcast: the host sets `signer` to the calling product's +// identity; the product never sets it, which is why it is absent here. + +export const PushBroadcastContent = Struct({ + title: str, + body: str, + deeplink: Option(str), +}); + +export const PushBroadcastV1_request = Struct({ + topics: Vector(Topic), + content: PushBroadcastContent, +}); + +export const PushBroadcastV1_response = Result( + Struct({ + messageHash: Bytes(32), + }), + PushBroadcastErr, +); diff --git a/packages/host-container/src/createContainer.ts b/packages/host-container/src/createContainer.ts index 5b6dac8e..4fa9a99b 100644 --- a/packages/host-container/src/createContainer.ts +++ b/packages/host-container/src/createContainer.ts @@ -27,7 +27,12 @@ import { PaymentStatusErr, PaymentTopUpErr, PreimageSubmitErr, + PushAddRulesErr, + PushBroadcastErr, + PushListRulesErr, PushNotificationError, + PushRemoveRulesErr, + PushSetRulesErr, RemotePermission, RequestCredentialsErr, ResourceAllocationErr, @@ -222,6 +227,10 @@ export function createContainer(provider: Provider, options: CreateContainerOpti method: Method, permissionVariant: CodecType, makeError: () => ErrorResponse, + // Returned when the user actually denied the device permission. Defaults + // to `makeError` (the not-implemented response) for callers whose error + // enum has no dedicated PermissionDenied variant. + makePermissionDeniedError: () => ErrorResponse = makeError, ): RequestSlot { const defaultHandler: RequestHandler = async () => enumValue('v1', resultErr(makeError())) as unknown as Awaited>>; @@ -235,7 +244,9 @@ export function createContainer(provider: Provider, options: CreateContainerOpti permissionResponse.value.success === true && permissionResponse.value.value === true; if (!permissionGranted) { - return enumValue('v1', resultErr(makeError())) as unknown as Awaited>>; + return enumValue('v1', resultErr(makePermissionDeniedError())) as unknown as Awaited< + ReturnType> + >; } return current(params); }); @@ -405,6 +416,35 @@ export function createContainer(provider: Provider, options: CreateContainerOpti () => new GenericError({ reason: NOT_IMPLEMENTED }), ); + const handlePushAddRulesSlot = makeDevicePermissionGatedRequestSlot( + 'host_push_add_rules', + 'Notifications', + () => new PushAddRulesErr.Unknown({ reason: NOT_IMPLEMENTED }), + () => new PushAddRulesErr.PermissionDenied(), + ); + + const handlePushRemoveRulesSlot = makeNotImplementedSlot( + 'host_push_remove_rules', + () => new PushRemoveRulesErr.Unknown({ reason: NOT_IMPLEMENTED }), + ); + + const handlePushListRulesSlot = makeNotImplementedSlot( + 'host_push_list_rules', + () => new PushListRulesErr.Unknown({ reason: NOT_IMPLEMENTED }), + ); + + const handlePushSetRulesSlot = makeDevicePermissionGatedRequestSlot( + 'host_push_set_rules', + 'Notifications', + () => new PushSetRulesErr.Unknown({ reason: NOT_IMPLEMENTED }), + () => new PushSetRulesErr.PermissionDenied(), + ); + + const handlePushBroadcastSlot = makeNotImplementedSlot( + 'host_push_broadcast', + () => new PushBroadcastErr.Unknown({ reason: NOT_IMPLEMENTED }), + ); + const handleNavigateToSlot = makeNotImplementedSlot( 'host_navigate_to', () => new NavigateToErr.Unknown({ reason: NOT_IMPLEMENTED }), @@ -528,6 +568,46 @@ export function createContainer(provider: Provider, options: CreateContainerOpti ); }, + handlePushAddRules(handler) { + return handleV1Request( + handlePushAddRulesSlot, + () => new PushAddRulesErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), + handler, + ); + }, + + handlePushRemoveRules(handler) { + return handleV1Request( + handlePushRemoveRulesSlot, + () => new PushRemoveRulesErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), + handler, + ); + }, + + handlePushListRules(handler) { + return handleV1Request( + handlePushListRulesSlot, + () => new PushListRulesErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), + handler, + ); + }, + + handlePushSetRules(handler) { + return handleV1Request( + handlePushSetRulesSlot, + () => new PushSetRulesErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), + handler, + ); + }, + + handlePushBroadcast(handler) { + return handleV1Request( + handlePushBroadcastSlot, + () => new PushBroadcastErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), + handler, + ); + }, + handleNavigateTo(handler) { return handleV1Request( handleNavigateToSlot, diff --git a/packages/host-container/src/types.ts b/packages/host-container/src/types.ts index cb5c1153..fd46bdea 100644 --- a/packages/host-container/src/types.ts +++ b/packages/host-container/src/types.ts @@ -98,6 +98,11 @@ export type Container = { handlePermission: InferHandler<'v1', HostApiProtocol['remote_permission']>; handlePushNotification: InferHandler<'v1', HostApiProtocol['host_push_notification']>; handlePushNotificationCancel: InferHandler<'v1', HostApiProtocol['host_push_notification_cancel']>; + handlePushAddRules: InferHandler<'v1', HostApiProtocol['host_push_add_rules']>; + handlePushRemoveRules: InferHandler<'v1', HostApiProtocol['host_push_remove_rules']>; + handlePushListRules: InferHandler<'v1', HostApiProtocol['host_push_list_rules']>; + handlePushSetRules: InferHandler<'v1', HostApiProtocol['host_push_set_rules']>; + handlePushBroadcast: InferHandler<'v1', HostApiProtocol['host_push_broadcast']>; handleNavigateTo: InferHandler<'v1', HostApiProtocol['host_navigate_to']>; // entropy derivation