Skip to content

Commit 8dc050c

Browse files
cuteWarmFrogclaude
andcommitted
feat(host-api)!: scheduled push notifications (RFC 0019)
Implements the JS SDK side of paritytech/truapi RFC 0019 (scheduled push notifications with idempotent cancellation). BREAKING CHANGE: `host_push_notification` wire format changes — request gains `scheduled_at: Option<u64>` (ms UTC), response becomes `Result<NotificationId, PushNotificationError>` with `ScheduleLimitReached` and `Unknown { reason }` variants. The prior wire format is replaced rather than versioned alongside; hosts and products must upgrade together. Protocol changes (v0.8 of host-api-protocol.md): - `host_push_notification` carries the new `PushNotification` struct - new `host_push_notification_cancel(NotificationId) -> Result<(), GenericErr>` - both methods gated by `DevicePermission::Notifications` SDK changes: - host-api: `PushNotification`, `NotificationId`, `PushNotificationError` - host-container: new `handlePushNotificationCancel`; existing `handlePushNotification` returns `NotificationId` and rejects with `PushNotificationError` - host-api-wrapper: `createNotificationManager()` exposing `.push({ text, deeplink?, scheduledAt? })` and `.cancel(id)` Host-side persistence, OS-scheduler integration, and the platform-wide queue cap remain host-application concerns; the SDK only transports the typed error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dcbca80 commit 8dc050c

10 files changed

Lines changed: 262 additions & 28 deletions

File tree

__tests__/hostApi/notification.spec.ts

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GenericError, createHostApi, createTransport, enumValue } from '@novasamatech/host-api';
1+
import { GenericError, PushNotificationError, createHostApi, createTransport, enumValue } from '@novasamatech/host-api';
22
import type { ContainerHandlerOf } from '@novasamatech/host-container';
33
import { createContainer } from '@novasamatech/host-container';
44

@@ -16,23 +16,23 @@ function setup() {
1616
}
1717

1818
describe('Host API: PushNotification', () => {
19-
it('should deliver a notification and gate on Notifications device permission', async () => {
19+
it('should deliver an immediate notification and return a NotificationId', async () => {
2020
const { container, hostApi } = setup();
21-
const payload = { text: 'Hello, world!', deeplink: 'https://example.com/deep' };
21+
const payload = { text: 'Hello, world!', deeplink: 'https://example.com/deep', scheduledAt: undefined };
2222

2323
const devicePermissionHandler = vi.fn<ContainerHandlerOf<typeof container.handleDevicePermission>>(
2424
(_params, { ok }) => ok(true),
2525
);
2626
container.handleDevicePermission(devicePermissionHandler);
27-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
27+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(42));
2828
container.handlePushNotification(handler);
2929

3030
const result = await hostApi.pushNotification(enumValue('v1', payload));
3131

3232
result.match(
3333
ok => {
3434
expect(ok.tag).toBe('v1');
35-
expect(ok.value).toBeUndefined();
35+
expect(ok.value).toBe(42);
3636
},
3737
() => {
3838
throw new Error('Expected success');
@@ -47,10 +47,10 @@ describe('Host API: PushNotification', () => {
4747

4848
it('should deliver a notification without a deeplink', async () => {
4949
const { container, hostApi } = setup();
50-
const payload = { text: 'Notification body', deeplink: undefined };
50+
const payload = { text: 'Notification body', deeplink: undefined, scheduledAt: undefined };
5151

5252
container.handleDevicePermission((_, { ok }) => ok(true));
53-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
53+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(1));
5454
container.handlePushNotification(handler);
5555

5656
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -59,15 +59,27 @@ describe('Host API: PushNotification', () => {
5959
expect(handler).toBeCalledWith(payload, { ok: expect.any(Function), err: expect.any(Function) });
6060
});
6161

62-
it('should propagate handler errors and still gate on Notifications permission', async () => {
62+
it('should deliver a scheduled notification carrying scheduledAt as a u64', async () => {
6363
const { container, hostApi } = setup();
64-
const payload = { text: 'will fail', deeplink: undefined };
65-
const error = new GenericError({ reason: 'Delivery failed' });
64+
const scheduledAt = BigInt(Date.UTC(2027, 0, 1));
65+
const payload = { text: 'reminder', deeplink: undefined, scheduledAt };
6666

67-
const devicePermissionHandler = vi.fn<ContainerHandlerOf<typeof container.handleDevicePermission>>(
68-
(_params, { ok }) => ok(true),
69-
);
70-
container.handleDevicePermission(devicePermissionHandler);
67+
container.handleDevicePermission((_, { ok }) => ok(true));
68+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(7));
69+
container.handlePushNotification(handler);
70+
71+
const result = await hostApi.pushNotification(enumValue('v1', payload));
72+
73+
expect(result.isOk()).toBe(true);
74+
expect(handler).toBeCalledWith(payload, { ok: expect.any(Function), err: expect.any(Function) });
75+
});
76+
77+
it('should propagate ScheduleLimitReached', async () => {
78+
const { container, hostApi } = setup();
79+
const payload = { text: 'full queue', deeplink: undefined, scheduledAt: BigInt(2_000_000_000_000) };
80+
const error = new PushNotificationError.ScheduleLimitReached();
81+
82+
container.handleDevicePermission((_, { ok }) => ok(true));
7183
container.handlePushNotification((_, { err }) => err(error));
7284

7385
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -81,18 +93,35 @@ describe('Host API: PushNotification', () => {
8193
expect(failure.value).toEqual(error);
8294
},
8395
);
96+
});
8497

85-
expect(devicePermissionHandler).toHaveBeenCalledOnce();
86-
const [receivedPermissionParams] = devicePermissionHandler.mock.calls[0]!;
87-
expect(receivedPermissionParams).toBe('Notifications');
98+
it('should propagate Unknown { reason } round-trip', async () => {
99+
const { container, hostApi } = setup();
100+
const payload = { text: 'boom', deeplink: undefined, scheduledAt: undefined };
101+
const error = new PushNotificationError.Unknown({ reason: 'OS rejected' });
102+
103+
container.handleDevicePermission((_, { ok }) => ok(true));
104+
container.handlePushNotification((_, { err }) => err(error));
105+
106+
const result = await hostApi.pushNotification(enumValue('v1', payload));
107+
108+
result.match(
109+
() => {
110+
throw new Error('Expected failure');
111+
},
112+
failure => {
113+
expect(failure.tag).toBe('v1');
114+
expect(failure.value).toEqual(error);
115+
},
116+
);
88117
});
89118

90119
it('should reject and skip the handler when Notifications permission is denied', async () => {
91120
const { container, hostApi } = setup();
92-
const payload = { text: 'blocked', deeplink: undefined };
121+
const payload = { text: 'blocked', deeplink: undefined, scheduledAt: undefined };
93122

94123
container.handleDevicePermission((_, { ok }) => ok(false));
95-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
124+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(1));
96125
container.handlePushNotification(handler);
97126

98127
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -104,18 +133,18 @@ describe('Host API: PushNotification', () => {
104133
},
105134
failure => {
106135
expect(failure.tag).toBe('v1');
107-
expect(failure.value).toBeInstanceOf(GenericError);
136+
expect(failure.value).toBeInstanceOf(PushNotificationError);
108137
},
109138
);
110139
expect(handler).not.toHaveBeenCalled();
111140
});
112141

113142
it('should reject and skip the handler when the device permission handler errors', async () => {
114143
const { container, hostApi } = setup();
115-
const payload = { text: 'blocked', deeplink: undefined };
144+
const payload = { text: 'blocked', deeplink: undefined, scheduledAt: undefined };
116145

117146
container.handleDevicePermission((_, { err }) => err(new GenericError({ reason: 'permission lookup failed' })));
118-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
147+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(1));
119148
container.handlePushNotification(handler);
120149

121150
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -124,3 +153,72 @@ describe('Host API: PushNotification', () => {
124153
expect(handler).not.toHaveBeenCalled();
125154
});
126155
});
156+
157+
describe('Host API: PushNotificationCancel', () => {
158+
it('should cancel a pending notification by id', async () => {
159+
const { container, hostApi } = setup();
160+
161+
container.handleDevicePermission((_, { ok }) => ok(true));
162+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
163+
ok(undefined),
164+
);
165+
container.handlePushNotificationCancel(handler);
166+
167+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 42));
168+
169+
expect(result.isOk()).toBe(true);
170+
expect(handler).toBeCalledWith(42, { ok: expect.any(Function), err: expect.any(Function) });
171+
});
172+
173+
it('should transport ok for any id without inspecting it (host enforces RFC §5 idempotency)', async () => {
174+
const { container, hostApi } = setup();
175+
176+
container.handleDevicePermission((_, { ok }) => ok(true));
177+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
178+
ok(undefined),
179+
);
180+
container.handlePushNotificationCancel(handler);
181+
182+
// The SDK does NOT enforce idempotency itself — that's the host's responsibility per RFC §5.
183+
// This test only verifies the wire faithfully carries an arbitrary id and the host's Ok response.
184+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 99999));
185+
186+
expect(result.isOk()).toBe(true);
187+
expect(handler).toBeCalledWith(99999, { ok: expect.any(Function), err: expect.any(Function) });
188+
});
189+
190+
it('should propagate GenericError when the host returns one', async () => {
191+
const { container, hostApi } = setup();
192+
const error = new GenericError({ reason: 'cancel failed' });
193+
194+
container.handleDevicePermission((_, { ok }) => ok(true));
195+
container.handlePushNotificationCancel((_, { err }) => err(error));
196+
197+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 1));
198+
199+
result.match(
200+
() => {
201+
throw new Error('Expected failure');
202+
},
203+
failure => {
204+
expect(failure.tag).toBe('v1');
205+
expect(failure.value).toEqual(error);
206+
},
207+
);
208+
});
209+
210+
it('should reject cancel when Notifications permission is denied', async () => {
211+
const { container, hostApi } = setup();
212+
213+
container.handleDevicePermission((_, { ok }) => ok(false));
214+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
215+
ok(undefined),
216+
);
217+
container.handlePushNotificationCancel(handler);
218+
219+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 5));
220+
221+
expect(result.isErr()).toBe(true);
222+
expect(handler).not.toHaveBeenCalled();
223+
});
224+
});

docs/design/host-api-protocol.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ created: 2026-03-13
99

1010
## Changelog
1111

12+
### v0.8 - 2026-05-14
13+
14+
- **Breaking change.** Extended `host_push_notification`: request gains `scheduled_at: Option<u64>` (Unix ms UTC) for deferred delivery; response changed from `Result<(), GenericErr>` to `Result<NotificationId, PushNotificationError>` (RFC-0019). The prior wire format is replaced rather than versioned alongside.
15+
- Added `host_push_notification_cancel(NotificationId) -> Result<(), GenericErr>` for retracting a pending scheduled notification.
16+
- Both methods are gated by `DevicePermission::Notifications`. No new permission variant.
17+
- `NotificationId` is opaque per-product; cancellation is idempotent. Persistence, OS-scheduler integration, and the platform-wide queue cap are host-application concerns, not SDK concerns.
18+
1219
### v0.7 - 2026-04-13
1320

1421
- Renamed all `*_with_non_product_account` methods to `*_with_legacy_account`; renamed `host_get_non_product_accounts` to `host_get_legacy_accounts`; updated glossary term "Non-product account (NPA)" to "Legacy account".
@@ -87,7 +94,11 @@ fn host_feature_supported(
8794
) -> Result<bool, GenericErr>;
8895

8996
fn host_push_notification(
90-
text: str
97+
notification: PushNotification
98+
) -> Result<NotificationId, PushNotificationError>;
99+
100+
fn host_push_notification_cancel(
101+
identifier: NotificationId
91102
) -> Result<(), GenericErr>;
92103

93104
fn host_navigate_to(
@@ -484,6 +495,41 @@ fn host_derive_entropy(
484495
) -> Result<Entropy, DeriveEntropyErr>;
485496
```
486497

498+
#### Push Notifications
499+
500+
Products can request the host to display a notification, either immediately or scheduled for a future wall-clock instant. Both methods are gated by `DevicePermission::Notifications`. See [RFC-0019](https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md) for the full motivation and host-side behavioural requirements (persistence, OS-scheduler integration, platform-wide queue cap).
501+
502+
```rust
503+
type NotificationId = u32;
504+
505+
struct PushNotification {
506+
// Notification body text.
507+
text: String,
508+
// Optional URL to open when the user taps the notification.
509+
deeplink: Option<String>,
510+
// Optional Unix timestamp in milliseconds (UTC) at which the notification should fire.
511+
// `None` fires immediately, preserving prior behaviour. Past timestamps fire immediately.
512+
scheduled_at: Option<u64>,
513+
}
514+
515+
enum PushNotificationError {
516+
// The host has reached its platform-wide cap on pending scheduled notifications.
517+
// Only returned for scheduled (non-immediate) calls.
518+
ScheduleLimitReached,
519+
Unknown(GenericErr)
520+
}
521+
522+
fn host_push_notification(
523+
notification: PushNotification
524+
) -> Result<NotificationId, PushNotificationError>;
525+
526+
fn host_push_notification_cancel(
527+
identifier: NotificationId
528+
) -> Result<(), GenericErr>;
529+
```
530+
531+
`NotificationId` is opaque and unique per product; the host MUST NOT leak ids across products. An id is returned for every call — immediate or scheduled — for shape uniformity. Cancellation is idempotent: `host_push_notification_cancel` MUST return `Ok(())` regardless of whether the id refers to a pending, already-fired, never-issued, or other-product's notification. This is a breaking change relative to the pre-v0.8 wire format; products and hosts must upgrade together.
532+
487533
#### Device permissions request
488534

489535
Products can request additional device permissions. This check is layered on top of platform permissions (web, iOS, Android) and adds a product-level security gate.

packages/host-api-wrapper/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export { createThemeProvider } from './theme.js';
3838

3939
export { createLocalStorage, hostLocalStorage } from './localStorage.js';
4040

41+
export type { NotificationId, PushNotificationInput } from './notification.js';
42+
export { createNotificationManager, notificationManager } from './notification.js';
43+
4144
export { createPreimageManager, preimageManager } from './preimage.js';
4245

4346
export type { PaymentBalance, PaymentStatus, TopUpSource } from './payments.js';
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { createHostApi, enumValue } from '@novasamatech/host-api';
2+
3+
import { resultToPromise, unwrapVersionedResult } from './helpers.js';
4+
import { sandboxTransport } from './sandboxTransport.js';
5+
6+
export type NotificationId = number;
7+
8+
export type PushNotificationInput = {
9+
text: string;
10+
deeplink?: string;
11+
/** Unix timestamp in milliseconds (UTC). Omit for immediate delivery. Past values fire immediately. */
12+
scheduledAt?: number;
13+
};
14+
15+
export const createNotificationManager = (transport = sandboxTransport) => {
16+
const supportedVersion = 'v1';
17+
const hostApi = createHostApi(transport);
18+
19+
return {
20+
push({ text, deeplink, scheduledAt }: PushNotificationInput): Promise<NotificationId> {
21+
return resultToPromise(
22+
unwrapVersionedResult(
23+
supportedVersion,
24+
hostApi.pushNotification(
25+
enumValue(supportedVersion, {
26+
text,
27+
deeplink,
28+
scheduledAt: scheduledAt === undefined ? undefined : BigInt(scheduledAt),
29+
}),
30+
),
31+
),
32+
);
33+
},
34+
cancel(id: NotificationId): Promise<void> {
35+
return resultToPromise(
36+
unwrapVersionedResult(supportedVersion, hostApi.pushNotificationCancel(enumValue(supportedVersion, id))),
37+
);
38+
},
39+
};
40+
};
41+
42+
export const notificationManager = createNotificationManager();

packages/host-api/src/hostApi.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { DeriveEntropyErr } from './protocol/v1/deriveEntropy.js';
1212
import { HandshakeErr } from './protocol/v1/handshake.js';
1313
import { StorageErr } from './protocol/v1/localStorage.js';
1414
import { NavigateToErr } from './protocol/v1/navigation.js';
15+
import { PushNotificationError } from './protocol/v1/notification.js';
1516
import { PaymentRequestErr, PaymentTopUpErr } from './protocol/v1/payments.js';
1617
import { PreimageSubmitErr } from './protocol/v1/preimage.js';
1718
import { ResourceAllocationErr } from './protocol/v1/resourceAllocation.js';
@@ -103,6 +104,13 @@ export function createHostApi(transport: Transport): HostApi {
103104

104105
pushNotification(payload) {
105106
return makeRequest(transport.request('host_push_notification', payload), reason => ({
107+
tag: payload.tag,
108+
value: new PushNotificationError.Unknown({ reason }),
109+
}));
110+
},
111+
112+
pushNotificationCancel(payload) {
113+
return makeRequest(transport.request('host_push_notification_cancel', payload), reason => ({
106114
tag: payload.tag,
107115
value: new GenericError({ reason }),
108116
}));

packages/host-api/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export {
8787
export { StorageErr } from './protocol/v1/localStorage.js';
8888
export { DevicePermission } from './protocol/v1/devicePermission.js';
8989
export { RemotePermission } from './protocol/v1/remotePermission.js';
90-
export { PushNotification } from './protocol/v1/notification.js';
90+
export { NotificationId, PushNotification, PushNotificationError } from './protocol/v1/notification.js';
9191
export { NavigateToErr } from './protocol/v1/navigation.js';
9292
export { PreimageKey, PreimageSubmitErr, PreimageValue } from './protocol/v1/preimage.js';
9393
export { AllocatableResource, AllocationOutcome, ResourceAllocationErr } from './protocol/v1/resourceAllocation.js';

packages/host-api/src/protocol/impl.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ import {
8585
StorageWriteV1_response,
8686
} from './v1/localStorage.js';
8787
import { NavigateToV1_request, NavigateToV1_response } from './v1/navigation.js';
88-
import { PushNotificationV1_request, PushNotificationV1_response } from './v1/notification.js';
88+
import {
89+
PushNotificationCancelV1_request,
90+
PushNotificationCancelV1_response,
91+
PushNotificationV1_request,
92+
PushNotificationV1_response,
93+
} from './v1/notification.js';
8994
import {
9095
PaymentBalanceSubscribeV1_interrupt,
9196
PaymentBalanceSubscribeV1_receive,
@@ -204,6 +209,10 @@ export const hostApiProtocol = {
204209
v1: [PushNotificationV1_request, PushNotificationV1_response],
205210
}),
206211

212+
host_push_notification_cancel: versionedRequest({
213+
v1: [PushNotificationCancelV1_request, PushNotificationCancelV1_response],
214+
}),
215+
207216
host_navigate_to: versionedRequest({
208217
v1: [NavigateToV1_request, NavigateToV1_response],
209218
}),

0 commit comments

Comments
 (0)