Skip to content
Draft
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
496 changes: 496 additions & 0 deletions __tests__/hostApi/pushSubscription.spec.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/host-api-wrapper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
70 changes: 70 additions & 0 deletions packages/host-api-wrapper/src/pushSubscription.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PushRuleCodec>;
export type Topic = CodecType<typeof TopicCodec>;
export type PushBroadcastContent = CodecType<typeof PushBroadcastContentCodec>;

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<void> {
return resultToPromise(
unwrapVersionedResult(supportedVersion, hostApi.pushAddRules(enumValue(supportedVersion, { rules }))),
);
},

removeRules(rules: PushRule[]): Promise<void> {
return resultToPromise(
unwrapVersionedResult(supportedVersion, hostApi.pushRemoveRules(enumValue(supportedVersion, { rules }))),
);
},

async listRules(): Promise<PushRule[]> {
const response = await resultToPromise(
unwrapVersionedResult(supportedVersion, hostApi.pushListRules(enumValue(supportedVersion, undefined))),
);
return response.rules;
},

setRules(rules: PushRule[]): Promise<void> {
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<PushBroadcastResult> {
return resultToPromise(
unwrapVersionedResult(supportedVersion, hostApi.pushBroadcast(enumValue(supportedVersion, input))),
);
},
};
};

export const pushSubscriptionManager = createPushSubscriptionManager();
42 changes: 42 additions & 0 deletions packages/host-api/src/hostApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/host-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions packages/host-api/src/protocol/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
82 changes: 82 additions & 0 deletions packages/host-api/src/protocol/v1/pushSubscription.ts
Original file line number Diff line number Diff line change
@@ -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,
);
82 changes: 81 additions & 1 deletion packages/host-container/src/createContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import {
PaymentStatusErr,
PaymentTopUpErr,
PreimageSubmitErr,
PushAddRulesErr,
PushBroadcastErr,
PushListRulesErr,
PushNotificationError,
PushRemoveRulesErr,
PushSetRulesErr,
RemotePermission,
RequestCredentialsErr,
ResourceAllocationErr,
Expand Down Expand Up @@ -222,6 +227,10 @@ export function createContainer(provider: Provider, options: CreateContainerOpti
method: Method,
permissionVariant: CodecType<typeof DevicePermission>,
makeError: () => ErrorResponse<HostApiProtocol[Method]>,
// 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<HostApiProtocol[Method]> = makeError,
): RequestSlot<Method> {
const defaultHandler: RequestHandler<Method> = async () =>
enumValue('v1', resultErr(makeError())) as unknown as Awaited<ReturnType<RequestHandler<Method>>>;
Expand All @@ -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<ReturnType<RequestHandler<Method>>>;
return enumValue('v1', resultErr(makePermissionDeniedError())) as unknown as Awaited<
ReturnType<RequestHandler<Method>>
>;
}
return current(params);
});
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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,
Expand Down
Loading