From 4367076ba5b777ff174c186b5146b7c1a693e407 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 28 May 2026 11:12:30 +0200 Subject: [PATCH 1/2] feat: add push notification subscription TrUAPI methods Adds five new TrUAPI methods exposing the v2 push backend's rule-management endpoints: push_add_rules, push_remove_rules, push_list_rules, push_set_rules, plus the interim push_broadcast direct submit path. Rules are (signer, topic) pairs grouped per publisher; add/set require the Notifications device permission, remove/list/broadcast do not. The host attests the broadcast signer, so the product cannot supply it. Also extends makeDevicePermissionGatedRequestSlot with an optional makePermissionDeniedError so callers whose error enum has a PermissionDenied variant can return the correct error when permission is denied vs when the host has not implemented the method. --- __tests__/hostApi/pushSubscription.spec.ts | 497 ++++++++++++++++++ packages/host-api-wrapper/src/index.ts | 6 + .../host-api-wrapper/src/pushBroadcast.ts | 43 ++ .../host-api-wrapper/src/pushSubscription.ts | 41 ++ packages/host-api/src/hostApi.ts | 42 ++ packages/host-api/src/index.ts | 9 + packages/host-api/src/protocol/impl.ts | 32 ++ .../src/protocol/v1/pushSubscription.ts | 82 +++ .../host-container/src/createContainer.ts | 82 ++- packages/host-container/src/types.ts | 5 + 10 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 __tests__/hostApi/pushSubscription.spec.ts create mode 100644 packages/host-api-wrapper/src/pushBroadcast.ts create mode 100644 packages/host-api-wrapper/src/pushSubscription.ts create mode 100644 packages/host-api/src/protocol/v1/pushSubscription.ts diff --git a/__tests__/hostApi/pushSubscription.spec.ts b/__tests__/hostApi/pushSubscription.spec.ts new file mode 100644 index 00000000..040f876c --- /dev/null +++ b/__tests__/hostApi/pushSubscription.spec.ts @@ -0,0 +1,497 @@ +import { + PushAddRulesErr, + PushBroadcastErr, + PushListRulesErr, + PushRemoveRulesErr, + PushSetRulesErr, + createHostApi, + createTransport, + enumValue, +} from '@novasamatech/host-api'; +import { createPushBroadcaster, 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); + const pushBroadcaster = createPushBroadcaster(sdkTransport); + + return { container, hostApi, pushSubscription, pushBroadcaster }; +} + +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: createPushBroadcaster', () => { + it('broadcast resolves with the messageHash', async () => { + const { container, pushBroadcaster } = setup(); + const messageHash = new Uint8Array(32).fill(0x7f); + container.handlePushBroadcast((_, { ok }) => ok({ messageHash })); + + const result = await pushBroadcaster.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, pushBroadcaster } = setup(); + container.handlePushBroadcast((_, { err }) => + err(new PushBroadcastErr.NotificationSystemUnavailable({ reason: 'backend offline' })), + ); + + await expect( + pushBroadcaster.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..3a156efe 100644 --- a/packages/host-api-wrapper/src/index.ts +++ b/packages/host-api-wrapper/src/index.ts @@ -41,6 +41,12 @@ export { createLocalStorage, hostLocalStorage } from './localStorage.js'; export type { NotificationId, PushNotificationInput } from './notification.js'; export { createNotificationManager, notificationManager } from './notification.js'; +export type { PushRule } from './pushSubscription.js'; +export { createPushSubscriptionManager, pushSubscriptionManager } from './pushSubscription.js'; + +export type { PushBroadcastContent, PushBroadcastInput, PushBroadcastResult } from './pushBroadcast.js'; +export { createPushBroadcaster, pushBroadcaster } from './pushBroadcast.js'; + export { createPreimageManager, preimageManager } from './preimage.js'; export type { PaymentBalance, PaymentStatus, TopUpSource } from './payments.js'; diff --git a/packages/host-api-wrapper/src/pushBroadcast.ts b/packages/host-api-wrapper/src/pushBroadcast.ts new file mode 100644 index 00000000..477cbbe9 --- /dev/null +++ b/packages/host-api-wrapper/src/pushBroadcast.ts @@ -0,0 +1,43 @@ +import type { + CodecType, + PushBroadcastContent as PushBroadcastContentCodec, + 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 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; +}; + +/** + * 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. + */ +export const createPushBroadcaster = (transport: Transport = sandboxTransport) => { + const supportedVersion = 'v1'; + const hostApi = createHostApi(transport); + + return { + broadcast(input: PushBroadcastInput): Promise { + return resultToPromise( + unwrapVersionedResult(supportedVersion, hostApi.pushBroadcast(enumValue(supportedVersion, input))), + ); + }, + }; +}; + +export const pushBroadcaster = createPushBroadcaster(); diff --git a/packages/host-api-wrapper/src/pushSubscription.ts b/packages/host-api-wrapper/src/pushSubscription.ts new file mode 100644 index 00000000..8e65c5b0 --- /dev/null +++ b/packages/host-api-wrapper/src/pushSubscription.ts @@ -0,0 +1,41 @@ +import type { CodecType, PushRule as PushRuleCodec, 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 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 }))), + ); + }, + }; +}; + +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 From 4f5c4b298e50f1d5fddde3518daaea02cbe75395 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 28 May 2026 12:01:12 +0200 Subject: [PATCH 2/2] refactor: collapse pushBroadcast wrapper into pushSubscription --- __tests__/hostApi/pushSubscription.spec.ts | 15 +++---- packages/host-api-wrapper/src/index.ts | 5 +-- .../host-api-wrapper/src/pushBroadcast.ts | 43 ------------------- .../host-api-wrapper/src/pushSubscription.ts | 31 ++++++++++++- 4 files changed, 38 insertions(+), 56 deletions(-) delete mode 100644 packages/host-api-wrapper/src/pushBroadcast.ts diff --git a/__tests__/hostApi/pushSubscription.spec.ts b/__tests__/hostApi/pushSubscription.spec.ts index 040f876c..4ac2114e 100644 --- a/__tests__/hostApi/pushSubscription.spec.ts +++ b/__tests__/hostApi/pushSubscription.spec.ts @@ -8,7 +8,7 @@ import { createTransport, enumValue, } from '@novasamatech/host-api'; -import { createPushBroadcaster, createPushSubscriptionManager } from '@novasamatech/host-api-wrapper'; +import { createPushSubscriptionManager } from '@novasamatech/host-api-wrapper'; import type { ContainerHandlerOf } from '@novasamatech/host-container'; import { createContainer } from '@novasamatech/host-container'; @@ -22,9 +22,8 @@ function setup() { const sdkTransport = createTransport(providers.sdk); const hostApi = createHostApi(sdkTransport); const pushSubscription = createPushSubscriptionManager(sdkTransport); - const pushBroadcaster = createPushBroadcaster(sdkTransport); - return { container, hostApi, pushSubscription, pushBroadcaster }; + return { container, hostApi, pushSubscription }; } const signerA = new Uint8Array(32).fill(0xa1); @@ -382,13 +381,13 @@ describe('Wrapper: createPushSubscriptionManager', () => { }); }); -describe('Wrapper: createPushBroadcaster', () => { +describe('Wrapper: broadcast (via createPushSubscriptionManager)', () => { it('broadcast resolves with the messageHash', async () => { - const { container, pushBroadcaster } = setup(); + const { container, pushSubscription } = setup(); const messageHash = new Uint8Array(32).fill(0x7f); container.handlePushBroadcast((_, { ok }) => ok({ messageHash })); - const result = await pushBroadcaster.broadcast({ + const result = await pushSubscription.broadcast({ topics: [topicX], content: { title: 't', body: 'b', deeplink: undefined }, }); @@ -397,13 +396,13 @@ describe('Wrapper: createPushBroadcaster', () => { }); it('broadcast rejects with NotificationSystemUnavailable when host returns one', async () => { - const { container, pushBroadcaster } = setup(); + const { container, pushSubscription } = setup(); container.handlePushBroadcast((_, { err }) => err(new PushBroadcastErr.NotificationSystemUnavailable({ reason: 'backend offline' })), ); await expect( - pushBroadcaster.broadcast({ + pushSubscription.broadcast({ topics: [topicX], content: { title: 't', body: 'b', deeplink: undefined }, }), diff --git a/packages/host-api-wrapper/src/index.ts b/packages/host-api-wrapper/src/index.ts index 3a156efe..89e47ab5 100644 --- a/packages/host-api-wrapper/src/index.ts +++ b/packages/host-api-wrapper/src/index.ts @@ -41,12 +41,9 @@ export { createLocalStorage, hostLocalStorage } from './localStorage.js'; export type { NotificationId, PushNotificationInput } from './notification.js'; export { createNotificationManager, notificationManager } from './notification.js'; -export type { PushRule } from './pushSubscription.js'; +export type { PushBroadcastContent, PushBroadcastInput, PushBroadcastResult, PushRule } from './pushSubscription.js'; export { createPushSubscriptionManager, pushSubscriptionManager } from './pushSubscription.js'; -export type { PushBroadcastContent, PushBroadcastInput, PushBroadcastResult } from './pushBroadcast.js'; -export { createPushBroadcaster, pushBroadcaster } from './pushBroadcast.js'; - export { createPreimageManager, preimageManager } from './preimage.js'; export type { PaymentBalance, PaymentStatus, TopUpSource } from './payments.js'; diff --git a/packages/host-api-wrapper/src/pushBroadcast.ts b/packages/host-api-wrapper/src/pushBroadcast.ts deleted file mode 100644 index 477cbbe9..00000000 --- a/packages/host-api-wrapper/src/pushBroadcast.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { - CodecType, - PushBroadcastContent as PushBroadcastContentCodec, - 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 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; -}; - -/** - * 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. - */ -export const createPushBroadcaster = (transport: Transport = sandboxTransport) => { - const supportedVersion = 'v1'; - const hostApi = createHostApi(transport); - - return { - broadcast(input: PushBroadcastInput): Promise { - return resultToPromise( - unwrapVersionedResult(supportedVersion, hostApi.pushBroadcast(enumValue(supportedVersion, input))), - ); - }, - }; -}; - -export const pushBroadcaster = createPushBroadcaster(); diff --git a/packages/host-api-wrapper/src/pushSubscription.ts b/packages/host-api-wrapper/src/pushSubscription.ts index 8e65c5b0..735c708a 100644 --- a/packages/host-api-wrapper/src/pushSubscription.ts +++ b/packages/host-api-wrapper/src/pushSubscription.ts @@ -1,10 +1,28 @@ -import type { CodecType, PushRule as PushRuleCodec, Transport } from '@novasamatech/host-api'; +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'; @@ -35,6 +53,17 @@ export const createPushSubscriptionManager = (transport: Transport = sandboxTran 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))), + ); + }, }; };