Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ vitest.config.*.timestamp*
packages/*/dist

storybook-static

docs/superpowers/
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
## 0.7.9 (2026-05-15)

### 🚀 Features

- **host-api-wrapper:** the product SDK has been renamed — `@novasamatech/product-sdk` is now published as `@novasamatech/host-api-wrapper`. The name better reflects what the package actually is: a thin, ergonomic wrapper around the Host API for products to call. The public API is otherwise unchanged; the only thing consumers need to do is update their `package.json` dependency and their imports.
- **host-papp:** user attestation has moved off the Host and onto the paired Polkadot Mobile app. The Host no longer drives the attestation flow during sign-in — the mobile app handles it end-to-end as part of pairing.
- **host-papp:** `UserSession` gains a `createTransaction(payload)` method. The Host can now delegate product-account transaction signing to the paired Polkadot Mobile app via the new `CreateTransactionRequest` / `CreateTransactionResponse` SSO message pair (legacy-account signing stays Host-local).
- **host-api-wrapper:** new top-level `accounts` singleton (`createAccountsProvider()` with the default sandbox transport) for products that don't need a custom transport.
- **host-api-wrapper:** export `ProductAccountId` and `LegacyAccount` types.
- **host-api-wrapper:** `getProductAccountSigner` accepts an optional second argument selecting how the returned signer should sign transactions — `'createTransaction'` (default, new behavior) routes through `host_create_transaction` and returns the full signed extrinsic; `'signPayload'` keeps the legacy path through `host_sign_payload`, giving products that haven't migrated yet a way to opt back into the old behavior without pinning the previous SDK version.
- **host-api / host-api-wrapper:** products can now schedule a push notification for a future time, not just send one right away. Pass `scheduledAt` (a UTC timestamp in milliseconds) when calling `notificationManager.push(...)`, and the host will deliver it at that moment. Leave it out to deliver immediately as before.
- **host-api / host-api-wrapper:** `push(...)` now returns an id you can hold onto, and the new `notificationManager.cancel(id)` lets a product cancel a notification it scheduled earlier — handy for "remind me in an hour" style flows where the user changes their mind.
- **host-api / host-api-wrapper:** if the host can't accept any more scheduled notifications, the product now gets a clear `ScheduleLimitReached` error instead of a generic failure, so it can tell the user what happened.
- **host-api / host-container / host-papp:** experimental debug hooks for observing host ↔ product traffic and internal SSO state. `onHostApiDebugMessage` (from `host-container`) emits every decoded message across all containers in the process, annotated with `productId`. `onHostPappDebugMessage` (from `host-papp/debug`) emits attestation, auth, and session-lifecycle events. Both are lazy — when no subscriber is attached, the underlying decode/emit work is skipped, so leaving the hooks in code costs nothing in production.

### 🩹 Fixes

- **host-papp-react-ui:** pairing QR code now renders on `<canvas>` via the lighter `qrcode` dep (replacing `qr-code-styling`), with pixel-snapped modules and circular finder eyes — scans reliably across phone cameras and stays crisp on hi-DPI displays.
- **host-papp-react-ui:** pairing modal/popover is responsive — shrinks to fit narrow viewports instead of being pinned at 350px.

### ⚠️ Breaking Changes

- **host-api-wrapper:** package was renamed from `@novasamatech/product-sdk`. Update your dependency and imports — there is no compatibility re-export under the old name.
- **host-api:** `host_create_transaction` no longer takes a separate `account_id` parameter — the account is now part of the payload as a typed `signer` field.
- **host-api:** `TxPayloadV1` got new structure that is not compatable with old one.
- **host-api-wrapper:** `getProductAccountSigner` now returns a `PolkadotSigner` whose `signTx` routes through `host_create_transaction` and returns the full signed extrinsic; `signBytes` routes through `host_sign_raw`. Previously `signTx` called `host_sign_payload` and returned a detached signature via `getPolkadotSignerFromPjs`. Callers no longer need to assemble the extrinsic themselves. (Pass `'signPayload'` as the second argument to opt back into the old behavior — see the Features section.)

> No compatibility shim. `host_create_transaction` had no production consumers and `host_create_transaction_with_legacy_account` is only reachable via `host-api-wrapper`, which is bumped in lockstep.
- **host-api:** the push-notification format changed to support scheduling and cancellation. Hosts and products must upgrade together — older clients won't be able to send notifications to a newer host (or vice versa).

### ❤️ Thank You

- Sergey Zhuravlev @johnthecat
- Filippo
- Yanaty
- Vitya Livshits @cuteWarmFrog

## 0.7.8 (2026-05-08)

### 🚀 Features
Expand Down
27 changes: 15 additions & 12 deletions __tests__/hostApi/accounts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
createTransport,
toHex,
} from '@novasamatech/host-api';
import type { AccountConnectionStatus, ProductAccount } from '@novasamatech/host-api-wrapper';
import type { AccountConnectionStatus, LegacyAccount, ProductAccount } from '@novasamatech/host-api-wrapper';
import { createAccountsProvider } from '@novasamatech/host-api-wrapper';
import type { ContainerHandlerOf } from '@novasamatech/host-container';
import { createContainer } from '@novasamatech/host-container';
Expand All @@ -29,11 +29,15 @@ function setup() {
}

const mockPublicKey = new Uint8Array(32).fill(1);
const mockAccount: ProductAccount = {
const mockProductAccount: ProductAccount = {
dotNsIdentifier: 'product.dot',
derivationIndex: 0,
publicKey: mockPublicKey,
};
const mockLegacyAccount: LegacyAccount = {
publicKey: mockPublicKey,
name: 'Test Account',
};

const mockRingLocation: CodecType<typeof RingLocation> = {
genesisHash: toHex(new Uint8Array(32).fill(0x22)),
Expand Down Expand Up @@ -95,14 +99,13 @@ describe('Host API: Accounts', () => {
describe('getProductAccount', () => {
it('should return account on success', async () => {
const { container, accountsProvider } = setup();
const expected = { publicKey: mockPublicKey };

container.handleAccountGet((_, { ok }) => ok(expected));
container.handleAccountGet((_, { ok }) => ok({ publicKey: mockPublicKey }));

const result = await accountsProvider.getProductAccount('product.dot', 0);

expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()).toEqual(expected);
expect(result._unsafeUnwrap()).toEqual(mockProductAccount);
});

it('should pass dotNsIdentifier and derivationIndex to handler', async () => {
Expand Down Expand Up @@ -304,7 +307,7 @@ describe('Host API: Accounts', () => {
describe('getProductAccountSigner', () => {
it('should expose the correct public key', () => {
const { accountsProvider } = setup();
const signer = accountsProvider.getProductAccountSigner(mockAccount);
const signer = accountsProvider.getProductAccountSigner(mockProductAccount);

expect(signer.publicKey).toEqual(mockPublicKey);
});
Expand All @@ -320,11 +323,11 @@ describe('Host API: Accounts', () => {
return ok({ signature: toHex(signatureBytes), signedTransaction: undefined });
});

const signer = accountsProvider.getProductAccountSigner(mockAccount);
const signer = accountsProvider.getProductAccountSigner(mockProductAccount);
const result = await signer.signBytes(rawData);

expect(capturedParams).toEqual({
account: [mockAccount.dotNsIdentifier, mockAccount.derivationIndex],
account: [mockProductAccount.dotNsIdentifier, mockProductAccount.derivationIndex],
payload: { tag: 'Bytes', value: rawData },
});
expect(result).toEqual(signatureBytes);
Expand All @@ -336,7 +339,7 @@ describe('Host API: Accounts', () => {

container.handleSignRaw((_, { err }) => err(error));

const signer = accountsProvider.getProductAccountSigner(mockAccount);
const signer = accountsProvider.getProductAccountSigner(mockProductAccount);

await expect(signer.signBytes(new Uint8Array([1, 2, 3]))).rejects.toEqual(error);
});
Expand All @@ -345,7 +348,7 @@ describe('Host API: Accounts', () => {
describe('getLegacyAccountSigner', () => {
it('should expose the correct public key', () => {
const { accountsProvider } = setup();
const signer = accountsProvider.getLegacyAccountSigner(mockAccount);
const signer = accountsProvider.getLegacyAccountSigner(mockLegacyAccount);

expect(signer.publicKey).toEqual(mockPublicKey);
});
Expand All @@ -361,7 +364,7 @@ describe('Host API: Accounts', () => {
return ok({ signature: toHex(signatureBytes), signedTransaction: undefined });
});

const signer = accountsProvider.getLegacyAccountSigner(mockAccount);
const signer = accountsProvider.getLegacyAccountSigner(mockLegacyAccount);
const result = await signer.signBytes(rawData);

expect(capturedParams).toMatchObject({ payload: { tag: 'Bytes', value: rawData } });
Expand All @@ -374,7 +377,7 @@ describe('Host API: Accounts', () => {

container.handleSignRawWithLegacyAccount((_, { err }) => err(error));

const signer = accountsProvider.getLegacyAccountSigner(mockAccount);
const signer = accountsProvider.getLegacyAccountSigner(mockLegacyAccount);

await expect(signer.signBytes(new Uint8Array([1, 2, 3]))).rejects.toEqual(error);
});
Expand Down
12 changes: 6 additions & 6 deletions __tests__/hostApi/injectedWeb3Provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,12 @@ describe('Host API: injected web3 provider', () => {
const response = new Uint8Array([0, 0, 1, 1]);
const payload = {
version: 1 as const,
signer: 'test',
signer: AccountId().dec(new Uint8Array(32)),
callData: '0x0002' as const,
extensions: [
{
id: 'test',
additionalSigned: '0x0000' as const,
id: 'CheckGenesis',
additionalSigned: toHex(new Uint8Array(32)),
extra: '0x0000' as const,
},
],
Expand Down Expand Up @@ -191,12 +191,12 @@ describe('Host API: injected web3 provider', () => {

const payload = {
version: 1 as const,
signer: 'test',
signer: AccountId().dec(new Uint8Array(32)),
callData: '0x0002' as const,
extensions: [
{
id: 'test',
additionalSigned: '0x0000' as const,
id: 'CheckGenesis',
additionalSigned: toHex(new Uint8Array(32)),
extra: '0x0000' as const,
},
],
Expand Down
125 changes: 103 additions & 22 deletions __tests__/hostApi/notification.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GenericError, createHostApi, createTransport, enumValue } from '@novasamatech/host-api';
import { GenericError, PushNotificationError, createHostApi, createTransport, enumValue } from '@novasamatech/host-api';
import type { ContainerHandlerOf } from '@novasamatech/host-container';
import { createContainer } from '@novasamatech/host-container';

Expand All @@ -16,23 +16,23 @@ function setup() {
}

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

const devicePermissionHandler = vi.fn<ContainerHandlerOf<typeof container.handleDevicePermission>>(
(_params, { ok }) => ok(true),
);
container.handleDevicePermission(devicePermissionHandler);
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(42));
container.handlePushNotification(handler);

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

result.match(
ok => {
expect(ok.tag).toBe('v1');
expect(ok.value).toBeUndefined();
expect(ok.value).toBe(42);
},
() => {
throw new Error('Expected success');
Expand All @@ -47,10 +47,10 @@ describe('Host API: PushNotification', () => {

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

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

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

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

const devicePermissionHandler = vi.fn<ContainerHandlerOf<typeof container.handleDevicePermission>>(
(_params, { ok }) => ok(true),
);
container.handleDevicePermission(devicePermissionHandler);
container.handleDevicePermission((_, { ok }) => ok(true));
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(7));
container.handlePushNotification(handler);

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

expect(result.isOk()).toBe(true);
expect(handler).toBeCalledWith(payload, { ok: expect.any(Function), err: expect.any(Function) });
});

it('should propagate ScheduleLimitReached', async () => {
const { container, hostApi } = setup();
const payload = { text: 'full queue', deeplink: undefined, scheduledAt: BigInt(2_000_000_000_000) };
const error = new PushNotificationError.ScheduleLimitReached();

container.handleDevicePermission((_, { ok }) => ok(true));
container.handlePushNotification((_, { err }) => err(error));

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

expect(devicePermissionHandler).toHaveBeenCalledOnce();
const [receivedPermissionParams] = devicePermissionHandler.mock.calls[0]!;
expect(receivedPermissionParams).toBe('Notifications');
it('should propagate Unknown { reason } round-trip', async () => {
const { container, hostApi } = setup();
const payload = { text: 'boom', deeplink: undefined, scheduledAt: undefined };
const error = new PushNotificationError.Unknown({ reason: 'OS rejected' });

container.handleDevicePermission((_, { ok }) => ok(true));
container.handlePushNotification((_, { err }) => err(error));

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

result.match(
() => {
throw new Error('Expected failure');
},
failure => {
expect(failure.tag).toBe('v1');
expect(failure.value).toEqual(error);
},
);
});

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

container.handleDevicePermission((_, { ok }) => ok(false));
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(1));
container.handlePushNotification(handler);

const result = await hostApi.pushNotification(enumValue('v1', payload));
Expand All @@ -104,18 +133,18 @@ describe('Host API: PushNotification', () => {
},
failure => {
expect(failure.tag).toBe('v1');
expect(failure.value).toBeInstanceOf(GenericError);
expect(failure.value).toBeInstanceOf(PushNotificationError);
},
);
expect(handler).not.toHaveBeenCalled();
});

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

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

const result = await hostApi.pushNotification(enumValue('v1', payload));
Expand All @@ -124,3 +153,55 @@ describe('Host API: PushNotification', () => {
expect(handler).not.toHaveBeenCalled();
});
});

describe('Host API: PushNotificationCancel', () => {
it('should cancel a pending notification by id', async () => {
const { container, hostApi } = setup();

container.handleDevicePermission((_, { ok }) => ok(true));
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
ok(undefined),
);
container.handlePushNotificationCancel(handler);

const result = await hostApi.pushNotificationCancel(enumValue('v1', 42));

expect(result.isOk()).toBe(true);
expect(handler).toBeCalledWith(42, { ok: expect.any(Function), err: expect.any(Function) });
});

it('should propagate GenericError when the host returns one', async () => {
const { container, hostApi } = setup();
const error = new GenericError({ reason: 'cancel failed' });

container.handleDevicePermission((_, { ok }) => ok(true));
container.handlePushNotificationCancel((_, { err }) => err(error));

const result = await hostApi.pushNotificationCancel(enumValue('v1', 1));

result.match(
() => {
throw new Error('Expected failure');
},
failure => {
expect(failure.tag).toBe('v1');
expect(failure.value).toEqual(error);
},
);
});

it('should reject cancel when Notifications permission is denied', async () => {
const { container, hostApi } = setup();

container.handleDevicePermission((_, { ok }) => ok(false));
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
ok(undefined),
);
container.handlePushNotificationCancel(handler);

const result = await hostApi.pushNotificationCancel(enumValue('v1', 5));

expect(result.isErr()).toBe(true);
expect(handler).not.toHaveBeenCalled();
});
});
Loading