Skip to content

Commit 2e36d6b

Browse files
cuteWarmFrogclaude
andcommitted
feat(host-api): scheduled push notifications (RFC 0019)
Adds wire support for scheduled notifications and idempotent cancellation, matching the host-side surface in paritytech/truapi#66 / RFC 0019. Protocol changes (v0.8 of host-api-protocol.md): - host_push_notification gains a V2 wire schema (adds scheduled_at: Option<u64>, returns Result<NotificationId, PushNotificationError>); V1 retained intact - new host_push_notification_cancel(NotificationId) -> Result<(), GenericErr> - both methods gated by DevicePermission::Notifications SDK changes: - host-api: V2 codecs, NotificationId, PushNotificationError ErrEnum - host-container: dual v1/v2 dispatch for host_push_notification sharing a single transport registration and the Notifications permission gate; new handlePushNotificationV2 + handlePushNotificationCancel; refactored handleV1/V2Request to share an implementation via handleVersionedRequest - host-api-wrapper: new createNotificationManager exposing push({ text, deeplink?, scheduledAt? }) and cancel(id), always emitting V2 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 2e36d6b

10 files changed

Lines changed: 454 additions & 16 deletions

File tree

__tests__/hostApi/notification.spec.ts

Lines changed: 174 additions & 1 deletion
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

@@ -124,3 +124,176 @@ describe('Host API: PushNotification', () => {
124124
expect(handler).not.toHaveBeenCalled();
125125
});
126126
});
127+
128+
describe('Host API: PushNotification (v2)', () => {
129+
it('should deliver an immediate notification and return a NotificationId', async () => {
130+
const { container, hostApi } = setup();
131+
const payload = { text: 'Hello v2', deeplink: 'https://example.com/x', scheduledAt: undefined };
132+
133+
container.handleDevicePermission((_, { ok }) => ok(true));
134+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationV2>>((_, { ok }) => ok(42));
135+
container.handlePushNotificationV2(handler);
136+
137+
const result = await hostApi.pushNotification(enumValue('v2', payload));
138+
139+
result.match(
140+
ok => {
141+
expect(ok.tag).toBe('v2');
142+
expect(ok.value).toBe(42);
143+
},
144+
() => {
145+
throw new Error('Expected success');
146+
},
147+
);
148+
expect(handler).toBeCalledWith(payload, { ok: expect.any(Function), err: expect.any(Function) });
149+
});
150+
151+
it('should deliver a scheduled notification carrying scheduledAt as a u64', async () => {
152+
const { container, hostApi } = setup();
153+
const scheduledAt = BigInt(Date.UTC(2027, 0, 1));
154+
const payload = { text: 'reminder', deeplink: undefined, scheduledAt };
155+
156+
container.handleDevicePermission((_, { ok }) => ok(true));
157+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationV2>>((_, { ok }) => ok(7));
158+
container.handlePushNotificationV2(handler);
159+
160+
const result = await hostApi.pushNotification(enumValue('v2', payload));
161+
162+
expect(result.isOk()).toBe(true);
163+
expect(handler).toBeCalledWith(payload, { ok: expect.any(Function), err: expect.any(Function) });
164+
});
165+
166+
it('should propagate ScheduleLimitReached', async () => {
167+
const { container, hostApi } = setup();
168+
const payload = { text: 'full queue', deeplink: undefined, scheduledAt: BigInt(2_000_000_000_000) };
169+
const error = new PushNotificationError.ScheduleLimitReached();
170+
171+
container.handleDevicePermission((_, { ok }) => ok(true));
172+
container.handlePushNotificationV2((_, { err }) => err(error));
173+
174+
const result = await hostApi.pushNotification(enumValue('v2', payload));
175+
176+
result.match(
177+
() => {
178+
throw new Error('Expected failure');
179+
},
180+
failure => {
181+
expect(failure.tag).toBe('v2');
182+
expect(failure.value).toEqual(error);
183+
},
184+
);
185+
});
186+
187+
it('should propagate Unknown { reason } round-trip', async () => {
188+
const { container, hostApi } = setup();
189+
const payload = { text: 'boom', deeplink: undefined, scheduledAt: undefined };
190+
const error = new PushNotificationError.Unknown({ reason: 'OS rejected' });
191+
192+
container.handleDevicePermission((_, { ok }) => ok(true));
193+
container.handlePushNotificationV2((_, { err }) => err(error));
194+
195+
const result = await hostApi.pushNotification(enumValue('v2', payload));
196+
197+
result.match(
198+
() => {
199+
throw new Error('Expected failure');
200+
},
201+
failure => {
202+
expect(failure.tag).toBe('v2');
203+
expect(failure.value).toEqual(error);
204+
},
205+
);
206+
});
207+
208+
it('should reject v2 when Notifications permission is denied', async () => {
209+
const { container, hostApi } = setup();
210+
const payload = { text: 'blocked v2', deeplink: undefined, scheduledAt: undefined };
211+
212+
container.handleDevicePermission((_, { ok }) => ok(false));
213+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationV2>>((_, { ok }) => ok(1));
214+
container.handlePushNotificationV2(handler);
215+
216+
const result = await hostApi.pushNotification(enumValue('v2', payload));
217+
218+
expect(result.isErr()).toBe(true);
219+
result.match(
220+
() => {
221+
throw new Error('Expected failure');
222+
},
223+
failure => {
224+
expect(failure.tag).toBe('v2');
225+
expect(failure.value).toBeInstanceOf(PushNotificationError);
226+
},
227+
);
228+
expect(handler).not.toHaveBeenCalled();
229+
});
230+
});
231+
232+
describe('Host API: PushNotificationCancel', () => {
233+
it('should cancel a pending notification by id', async () => {
234+
const { container, hostApi } = setup();
235+
236+
container.handleDevicePermission((_, { ok }) => ok(true));
237+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
238+
ok(undefined),
239+
);
240+
container.handlePushNotificationCancel(handler);
241+
242+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 42));
243+
244+
expect(result.isOk()).toBe(true);
245+
expect(handler).toBeCalledWith(42, { ok: expect.any(Function), err: expect.any(Function) });
246+
});
247+
248+
it('should transport ok for any id without inspecting it (host enforces RFC §5 idempotency)', async () => {
249+
const { container, hostApi } = setup();
250+
251+
container.handleDevicePermission((_, { ok }) => ok(true));
252+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
253+
ok(undefined),
254+
);
255+
container.handlePushNotificationCancel(handler);
256+
257+
// The SDK does NOT enforce idempotency itself — that's the host's responsibility per RFC §5.
258+
// This test only verifies the wire faithfully carries an arbitrary id and the host's Ok response.
259+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 99999));
260+
261+
expect(result.isOk()).toBe(true);
262+
expect(handler).toBeCalledWith(99999, { ok: expect.any(Function), err: expect.any(Function) });
263+
});
264+
265+
it('should propagate GenericError when the host returns one', async () => {
266+
const { container, hostApi } = setup();
267+
const error = new GenericError({ reason: 'cancel failed' });
268+
269+
container.handleDevicePermission((_, { ok }) => ok(true));
270+
container.handlePushNotificationCancel((_, { err }) => err(error));
271+
272+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 1));
273+
274+
result.match(
275+
() => {
276+
throw new Error('Expected failure');
277+
},
278+
failure => {
279+
expect(failure.tag).toBe('v1');
280+
expect(failure.value).toEqual(error);
281+
},
282+
);
283+
});
284+
285+
it('should reject cancel when Notifications permission is denied', async () => {
286+
const { container, hostApi } = setup();
287+
288+
container.handleDevicePermission((_, { ok }) => ok(false));
289+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
290+
ok(undefined),
291+
);
292+
container.handlePushNotificationCancel(handler);
293+
294+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 5));
295+
296+
expect(result.isErr()).toBe(true);
297+
expect(handler).not.toHaveBeenCalled();
298+
});
299+
});

docs/design/host-api-protocol.md

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

1010
## Changelog
1111

12+
### v0.8 - 2026-05-13
13+
14+
- Extended `host_push_notification` with optional `scheduled_at: u64` (Unix ms UTC) for deferred delivery; return type changed from `Result<(), GenericErr>` to `Result<NotificationId, PushNotificationError>` (RFC-0019).
15+
- Added `host_push_notification_cancel(NotificationId) -> Result<(), GenericErr>` for retracting a pending scheduled notification.
16+
- Wire-format break gated behind a new `PushNotificationV2` payload variant of `host_push_notification`; `PushNotificationV1` (text + deeplink, returning `()`) MUST remain accepted for legacy clients.
17+
- Both methods are gated by `DevicePermission::Notifications`. No new permission variant.
18+
- `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.
19+
1220
### v0.7 - 2026-04-13
1321

1422
- 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".
@@ -86,8 +94,14 @@ fn host_feature_supported(
8694
feature: Feature
8795
) -> Result<bool, GenericErr>;
8896

97+
// Hosts that advertise v0.8+ MUST accept both PushNotificationV1 (legacy)
98+
// and PushNotificationV2 (current) wire payloads on this method.
8999
fn host_push_notification(
90-
text: str
100+
notification: PushNotificationV2
101+
) -> Result<NotificationId, PushNotificationError>;
102+
103+
fn host_push_notification_cancel(
104+
identifier: NotificationId
91105
) -> Result<(), GenericErr>;
92106

93107
fn host_navigate_to(
@@ -484,6 +498,52 @@ fn host_derive_entropy(
484498
) -> Result<Entropy, DeriveEntropyErr>;
485499
```
486500

501+
#### Push Notifications
502+
503+
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).
504+
505+
```rust
506+
type NotificationId = u32;
507+
508+
// Legacy wire payload, retained for products that have not yet migrated.
509+
// Response is Result<(), GenericErr>.
510+
struct PushNotificationV1 {
511+
// Notification body text.
512+
text: String,
513+
// Optional URL to open when the user taps the notification.
514+
deeplink: Option<String>,
515+
}
516+
517+
// Current wire payload. Response is Result<NotificationId, PushNotificationError>.
518+
struct PushNotificationV2 {
519+
text: String,
520+
deeplink: Option<String>,
521+
// Optional Unix timestamp in milliseconds (UTC) at which the notification should fire.
522+
// `None` fires immediately, preserving prior behaviour. Past timestamps fire immediately.
523+
scheduled_at: Option<u64>,
524+
}
525+
526+
enum PushNotificationError {
527+
// The host has reached its platform-wide cap on pending scheduled notifications.
528+
// Only returned for scheduled (non-immediate) calls.
529+
ScheduleLimitReached,
530+
Unknown(GenericErr)
531+
}
532+
533+
fn host_push_notification(
534+
// V1 and V2 payloads are both accepted; response shape follows the input version.
535+
notification: PushNotificationV2
536+
) -> Result<NotificationId, PushNotificationError>;
537+
538+
fn host_push_notification_cancel(
539+
identifier: NotificationId
540+
) -> Result<(), GenericErr>;
541+
```
542+
543+
`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.
544+
545+
Hosts that advertise v0.8+ MUST send and accept `PushNotificationV2`; `PushNotificationV1` remains supported indefinitely for products that have not migrated.
546+
487547
#### Device permissions request
488548

489549
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 hostApi = createHostApi(transport);
17+
18+
return {
19+
push({ text, deeplink, scheduledAt }: PushNotificationInput): Promise<NotificationId> {
20+
const result = hostApi.pushNotification(
21+
enumValue('v2', {
22+
text,
23+
deeplink,
24+
scheduledAt: scheduledAt === undefined ? undefined : BigInt(scheduledAt),
25+
}),
26+
) as never;
27+
return resultToPromise(unwrapVersionedResult('v2', result));
28+
},
29+
cancel(id: NotificationId): Promise<void> {
30+
return resultToPromise(unwrapVersionedResult('v1', hostApi.pushNotificationCancel(enumValue('v1', id))));
31+
},
32+
};
33+
};
34+
35+
export const notificationManager = createNotificationManager();

packages/host-api/src/hostApi.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ 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 {
16+
PushNotificationError,
17+
PushNotificationV1_response,
18+
PushNotificationV2_response,
19+
} from './protocol/v1/notification.js';
1520
import { PaymentRequestErr, PaymentTopUpErr } from './protocol/v1/payments.js';
1621
import { PreimageSubmitErr } from './protocol/v1/preimage.js';
1722
import { ResourceAllocationErr } from './protocol/v1/resourceAllocation.js';
@@ -102,7 +107,26 @@ export function createHostApi(transport: Transport): HostApi {
102107
},
103108

104109
pushNotification(payload) {
105-
return makeRequest(transport.request('host_push_notification', payload), reason => ({
110+
if (payload.tag === 'v2') {
111+
return makeRequest(
112+
transport.request('host_push_notification', payload) as Promise<{
113+
tag: 'v2';
114+
value: CodecType<typeof PushNotificationV2_response>;
115+
}>,
116+
reason => ({ tag: 'v2' as const, value: new PushNotificationError.Unknown({ reason }) }),
117+
) as ReturnType<HostApi['pushNotification']>;
118+
}
119+
return makeRequest(
120+
transport.request('host_push_notification', payload) as Promise<{
121+
tag: 'v1';
122+
value: CodecType<typeof PushNotificationV1_response>;
123+
}>,
124+
reason => ({ tag: 'v1' as const, value: new GenericError({ reason }) }),
125+
) as ReturnType<HostApi['pushNotification']>;
126+
},
127+
128+
pushNotificationCancel(payload) {
129+
return makeRequest(transport.request('host_push_notification_cancel', payload), reason => ({
106130
tag: payload.tag,
107131
value: new GenericError({ reason }),
108132
}));

packages/host-api/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ 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 {
91+
NotificationId,
92+
PushNotificationError,
93+
PushNotificationV1,
94+
PushNotificationV2,
95+
} from './protocol/v1/notification.js';
9196
export { NavigateToErr } from './protocol/v1/navigation.js';
9297
export { PreimageKey, PreimageSubmitErr, PreimageValue } from './protocol/v1/preimage.js';
9398
export { AllocatableResource, AllocationOutcome, ResourceAllocationErr } from './protocol/v1/resourceAllocation.js';

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,14 @@ 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+
PushNotificationV2_request,
94+
PushNotificationV2_response,
95+
} from './v1/notification.js';
8996
import {
9097
PaymentBalanceSubscribeV1_interrupt,
9198
PaymentBalanceSubscribeV1_receive,
@@ -202,6 +209,11 @@ export const hostApiProtocol = {
202209

203210
host_push_notification: versionedRequest({
204211
v1: [PushNotificationV1_request, PushNotificationV1_response],
212+
v2: [PushNotificationV2_request, PushNotificationV2_response],
213+
}),
214+
215+
host_push_notification_cancel: versionedRequest({
216+
v1: [PushNotificationCancelV1_request, PushNotificationCancelV1_response],
205217
}),
206218

207219
host_navigate_to: versionedRequest({

0 commit comments

Comments
 (0)