@novasamatech/host-papp 0.8.11 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,10 +35,6 @@ const papp = createPappAdapter({
35
35
  // otherwise existing pairings will be lost.
36
36
  appId: 'my-host-app',
37
37
 
38
- // URL to a JSON document describing the host: { name: string, icon: string }.
39
- // The icon should be a rasterized image at least 256x256 px.
40
- metadata: 'https://my-host-app.example/papp-metadata.json',
41
-
42
38
  // Optional environment metadata shown on the wallet's confirmation screen.
43
39
  hostMetadata: {
44
40
  hostVersion: '1.4.0',
@@ -48,14 +44,15 @@ const papp = createPappAdapter({
48
44
  });
49
45
  ```
50
46
 
51
- `createPappAdapter` returns four sub-modules:
47
+ `createPappAdapter` returns five sub-modules:
52
48
 
53
- | Module | Purpose |
54
- | --------------- | ---------------------------------------------------------------------- |
55
- | `papp.sso` | Authentication / pairing flow with a remote wallet. |
56
- | `papp.sessions` | List of paired user sessions and per-session messaging (sign, etc.). |
57
- | `papp.secrets` | Local secret storage for the derived guest accounts. |
58
- | `papp.identity` | On-chain identity lookups for arbitrary account ids. |
49
+ | Module | Purpose |
50
+ | ---------------- | ------------------------------------------------------------------- |
51
+ | `papp.sso` | Authentication / pairing flow with a remote wallet. |
52
+ | `papp.sessions` | List of paired user sessions and per-session messaging (sign, etc.).|
53
+ | `papp.secrets` | Local secret storage for the derived guest accounts. |
54
+ | `papp.identity` | On-chain identity lookups for arbitrary account ids. |
55
+ | `papp.allowance` | Resource allowances (bulletin / statement-store signers) per product.|
59
56
 
60
57
  Custom adapters (statement store, identity RPC, storage, lazy chain client) can be supplied
61
58
  via the `adapters` option for testing or non-browser environments.
@@ -200,6 +197,34 @@ await currentSession.signRaw({
200
197
  });
201
198
  ```
202
199
 
200
+ `signVrf` asks the wallet for an sr25519 (schnorrkel) VRF signature from a product account
201
+ (RFC-0023). The transcript travels as a recipe — a root domain-separation label plus an
202
+ ordered list of `(label, value)` items — which the wallet replays verbatim into a Merlin
203
+ transcript and signs. Callers that need a `signer` item must supply their own public key;
204
+ the host never injects it.
205
+
206
+ ```ts
207
+ const encoder = new TextEncoder();
208
+
209
+ const vrf = await currentSession.signVrf({
210
+ productAccountId: ['product.dot', 0],
211
+ productId: 'product.dot',
212
+ transcriptLabel: encoder.encode('pop:airdrop'),
213
+ items: [
214
+ { label: encoder.encode('domain'), value: domainBytes },
215
+ { label: encoder.encode('signer'), value: accountPublicKey },
216
+ ],
217
+ });
218
+
219
+ vrf.match(
220
+ ({ preOutput, proof }) => submitLotteryTicket(preOutput, proof),
221
+ error => console.error('VRF signing failed:', error),
222
+ );
223
+ ```
224
+
225
+ This is the non-`AutoSigning` path only: when `AutoSigning` covers the account the host
226
+ signs locally and never round-trips to the wallet.
227
+
203
228
  ## Ring VRF proofs and aliases
204
229
 
205
230
  A `UserSession` can ask the paired device for a privacy-preserving contextual alias, or a
@@ -246,6 +271,13 @@ const lookup = async (accountId: string) => {
246
271
  await papp.identity.getIdentities([accountIdA, accountIdB]);
247
272
  ```
248
273
 
274
+ A paired `UserSession` also exposes `getIdentity()` as a shortcut that looks up the identity
275
+ of its own user identity account — no account id to pass:
276
+
277
+ ```ts
278
+ const identity = await session.getIdentity(); // Result<Identity | null, Error>
279
+ ```
280
+
249
281
  ## V2 SSO handshake
250
282
 
251
283
  V2 is a redesign of the SSO pairing flow that supports the same user identity across
@@ -258,38 +290,15 @@ identity, so contacts, chats, and roster events are shared between them.
258
290
  V2 is **not interoperable with V1**: a V1-only peer can't decode a V2 proposal QR and vice
259
291
  versa. Hosts that want to support both should branch on which protocol the peer advertises.
260
292
 
261
- ### Shape of the flow
293
+ ### The flow
262
294
 
263
- ```
264
- host peer (authorising device)
265
- ────────────────────────────────────────────────────────────────────────
266
- buildPairingDeeplink(device, metadata)
267
- → polkadotapp://pair?handshake=<hex>
268
- scan QR, decode proposal
269
- compute pairing topic from
270
- the host's pubkeys
271
- ECDH-encrypt + post:
272
- Pending(AllowanceAllocation)
273
- Success { encryptionKey,
274
- accountId,
275
- identitySignature }
276
- Failed(reason)
277
- service.subscribeStatements(topic) +
278
- poll the topic every 2s
279
-
280
- decode VersionedHandshakeResponse::V2
281
- → ECDH-decrypt envelope with the
282
- device encryption private key
283
- → SCALE-decode inner payload
284
- → state machine: Submitted → Pending →
285
- Success | Failed
286
- → on Success persist user identity
287
- ```
288
-
289
- The user identity carried in `Success` is the chat encryption pubkey + the user's identity
290
- sr25519 accountId. The host verifies the 64-byte sr25519 `identitySignature` against the
291
- canonical 97 bytes `statementAccountId || encryptionPublicKey`
292
- (see `IDENTITY_SIGNATURE_PAYLOAD_BYTES`).
295
+ 1. The host builds a pairing deeplink from its device keypair and shows it as a QR code.
296
+ 2. The authorising device scans it and posts its response to the Statement Store: first a
297
+ `Pending` acknowledgement, then either `Success` — carrying the user's identity keys,
298
+ signed to authorise this device — or `Failed`.
299
+ 3. The host polls the pairing topic, decrypts and verifies each response, and drives a
300
+ `Submitted Pending → Success | Failed` state machine. On `Success` it persists the
301
+ user identity.
293
302
 
294
303
  ### Building and rendering the QR
295
304
 
@@ -318,7 +327,7 @@ renderQrCode(deeplink); // 'polkadotapp://pair?handshake=<hex>'
318
327
  import { startPairingV2 } from '@novasamatech/host-papp';
319
328
 
320
329
  const pairing = startPairingV2({
321
- statementStore: papp.adapters.statementStore, // any StatementStoreAdapter
330
+ statementStore, // any StatementStoreAdapter
322
331
  deviceIdentity: {
323
332
  statementAccountPublicKey: device.statementAccountPublicKey,
324
333
  encryptionPublicKey: device.encryptionPublicKey,
@@ -380,33 +389,14 @@ The service skips any incoming statement whose bytes match `initialProcessedData
380
389
  re-encrypts every Success with a fresh ephemeral key + AES-GCM nonce, so a genuine re-pair
381
390
  always produces different bytes and passes the dedupe.
382
391
 
383
- ### Pairing topic / channel
392
+ ## Reading allowances
384
393
 
385
- If the host needs to derive the pairing topic or channel itself (for example to subscribe
386
- in-line, or to verify a statement source):
394
+ Each `UserSession` can read its own persisted allowance slot-account key for a given
395
+ product and resource. The session id is implicit — you only pass the product and resource:
387
396
 
388
397
  ```ts
389
- import { computePairingTopic, computePairingChannel } from '@novasamatech/host-papp';
398
+ const session = papp.sessions.sessions.read().at(0);
390
399
 
391
- const topic = computePairingTopic(statementAccountId, encryptionPublicKey);
392
- const channel = computePairingChannel(statementAccountId, encryptionPublicKey);
393
- // topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId)
394
- // channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId)
400
+ // resource: 'bulletin' | 'statementStore'
401
+ const key = await session.readAllowance(productId, 'statementStore'); // Result<Uint8Array | null, Error>
395
402
  ```
396
-
397
- ### Codec exports
398
-
399
- The SCALE codecs are exported as plain `Codec<T>` values for callers that need to
400
- encode/decode statements outside the orchestrator:
401
-
402
- | Export | Description |
403
- | --------------------------------- | -------------------------------------------------------------------------------------------- |
404
- | `VersionedHandshakeProposal` | Outer enum; V2 at SCALE discriminant 1, with `_v1Reserved` at 0. |
405
- | `HandshakeProposalV2` | `{ device, metadata }` — what the QR encodes. |
406
- | `Device` | `{ statementAccountId(32), encryptionPublicKey(65) }`. |
407
- | `MetadataKey`, `MetadataEntry` | Metadata enum + `(MetadataKey, str)` tuple. |
408
- | `VersionedHandshakeResponse` | Outer enum for the answer; `V1` legacy + `V2`. |
409
- | `HandshakeResponseV2` | `{ encrypted, tmpKey(65) }` — the ECDH-wrapped envelope. |
410
- | `EncryptedHandshakeResponseV2` | Inner payload after envelope decrypt: `Pending` (1 byte), `Success` (161 bytes), `Failed`. |
411
- | `HandshakeSuccessV2` | `{ encryptionKey(65), accountId(32), identitySignature(64) }`. |
412
- | `IDENTITY_SIGNATURE_PAYLOAD_BYTES`| `97` — the bytes the user identity sr25519 signs over. |
@@ -1,5 +1,5 @@
1
1
  import { setTimeout } from 'node:timers/promises';
2
- import { err, fromPromise, ok, okAsync } from 'neverthrow';
2
+ import { fromPromise, okAsync } from 'neverthrow';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import { createAsyncTaskPool } from './createAsyncTaskPool.js';
5
5
  import { toError } from './utils.js';
@@ -8,7 +8,7 @@ describe('asyncTaskPool', () => {
8
8
  it('should exec async task', async () => {
9
9
  const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: () => 0 });
10
10
  const result = await pool.call(() => fromPromise(delay().then(() => 'test'), toError));
11
- expect(result).toEqual(ok('test'));
11
+ await expect(result).toBeOkWith('test');
12
12
  });
13
13
  it('should handle sync errors', async () => {
14
14
  const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: () => 0 });
@@ -16,13 +16,13 @@ describe('asyncTaskPool', () => {
16
16
  const result = await pool.call(() => {
17
17
  throw error;
18
18
  });
19
- expect(result).toEqual(err(error));
19
+ await expect(result).toBeErrWith(error);
20
20
  });
21
21
  it('should handle async errors', async () => {
22
22
  const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: () => 0 });
23
23
  const error = new Error('test');
24
24
  const result = await pool.call(() => fromPromise(Promise.reject(error), toError));
25
- return expect(result).toEqual(err(error));
25
+ await expect(result).toBeErrWith(error);
26
26
  });
27
27
  it('should handle queue', async () => {
28
28
  const pool = createAsyncTaskPool({ poolSize: 2, retryCount: 0, retryDelay: () => 0 });
@@ -52,7 +52,7 @@ describe('asyncTaskPool', () => {
52
52
  tries++;
53
53
  throw new Error();
54
54
  });
55
- expect(result).toEqual(ok('test'));
55
+ await expect(result).toBeOkWith('test');
56
56
  });
57
57
  it('should throw on retry limit exceeding', async () => {
58
58
  const spy = vi.fn(() => 0);
@@ -66,7 +66,7 @@ describe('asyncTaskPool', () => {
66
66
  throw new Error();
67
67
  });
68
68
  expect(spy).toBeCalledTimes(1);
69
- expect(result).toEqual(err(new Error()));
69
+ await expect(result).toBeErrWith(new Error());
70
70
  });
71
71
  it('should correctly calculate retry delay', async () => {
72
72
  const spy = vi.fn((retry) => retry * 10);
@@ -125,9 +125,9 @@ describe('asyncTaskPool', () => {
125
125
  const queued = pool.call(queuedSpy, { signal: controller.signal });
126
126
  controller.abort();
127
127
  const queuedResult = await queued;
128
- expect(queuedResult.isErr()).toBe(true);
128
+ await expect(queuedResult).toBeErr();
129
129
  expect(queuedSpy).not.toHaveBeenCalled();
130
- expect((await active).isOk()).toBe(true);
130
+ await expect(await active).toBeOk();
131
131
  });
132
132
  it('rejects the in-flight active task when the signal aborts', async () => {
133
133
  const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
@@ -136,7 +136,7 @@ describe('asyncTaskPool', () => {
136
136
  signal: controller.signal,
137
137
  });
138
138
  controller.abort();
139
- expect((await active).isErr()).toBe(true);
139
+ await expect(await active).toBeErr();
140
140
  });
141
141
  it('frees the slot for later tasks after an abort', async () => {
142
142
  const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
@@ -147,7 +147,7 @@ describe('asyncTaskPool', () => {
147
147
  controller.abort();
148
148
  await aborted;
149
149
  const next = await pool.call(() => okAsync('next'));
150
- expect(next).toEqual(ok('next'));
150
+ await expect(next).toBeOkWith('next');
151
151
  });
152
152
  it('rejects immediately when called with an already-aborted signal', async () => {
153
153
  const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
@@ -155,7 +155,7 @@ describe('asyncTaskPool', () => {
155
155
  controller.abort();
156
156
  const spy = vi.fn(() => okAsync('x'));
157
157
  const result = await pool.call(spy, { signal: controller.signal });
158
- expect(result.isErr()).toBe(true);
158
+ await expect(result).toBeErr();
159
159
  expect(spy).not.toHaveBeenCalled();
160
160
  });
161
161
  });
package/dist/index.d.ts CHANGED
@@ -6,9 +6,11 @@ export type { PairingStatus } from './sso/auth/types.js';
6
6
  export type { DeviceIdentityForPairing } from './sso/auth/v2/service.js';
7
7
  export type { AllowanceErrorReason, AllowanceService } from './sso/allowance/index.js';
8
8
  export { AllowanceError } from './sso/allowance/index.js';
9
+ export type { AllowanceResourceKind } from './sso/allowance/index.js';
9
10
  export type { UserSession } from './sso/sessionManager/userSession.js';
10
11
  export type { StoredUserSession } from './sso/userSessionRepository.js';
11
12
  export type { Identity } from './identity/types.js';
12
13
  export type { SignRawLegacyRequest, SignRawLegacyResponse, SigningPayloadRequest, SigningPayloadResponse, SigningRawRequest, SigningRequest, } from './sso/sessionManager/scale/signing.js';
13
14
  export type { RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, RingVrfProofResponse, } from './sso/sessionManager/scale/ringVrf.js';
15
+ export type { SignVrfErr, SignVrfRequest, SignVrfResponse } from './sso/sessionManager/scale/signVrf.js';
14
16
  export type { CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, } from './sso/sessionManager/scale/createTransaction.js';
package/dist/papp.js CHANGED
@@ -19,6 +19,7 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
19
19
  const ssoSessionRepository = createUserSessionRepository(storage);
20
20
  const userSecretRepository = createUserSecretRepository(appId, storage);
21
21
  const allowanceRepository = createAllowanceRepository(appId, storage);
22
+ const identityRepository = createIdentityRepository({ adapter: identities, storage });
22
23
  const deviceIdentityStore = createDeviceIdentityStore(appId, storage);
23
24
  const sessions = createSsoSessionManager({
24
25
  storage,
@@ -26,6 +27,7 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
26
27
  ssoSessionRepository,
27
28
  userSecretRepository,
28
29
  allowanceRepository,
30
+ identityRepository,
29
31
  });
30
32
  return {
31
33
  sso: createAuth({
@@ -39,7 +41,7 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
39
41
  }),
40
42
  sessions,
41
43
  secrets: userSecretRepository,
42
- identity: createIdentityRepository({ adapter: identities, storage }),
44
+ identity: identityRepository,
43
45
  allowance: createAllowanceService({ sessions: sessions.sessions, repository: allowanceRepository }),
44
46
  };
45
47
  }
@@ -1,5 +1,6 @@
1
1
  import type { StatementStoreAdapter } from '@novasamatech/statement-store';
2
2
  import type { StorageAdapter } from '@novasamatech/storage-adapter';
3
+ import type { IdentityRepository } from '../../identity/types.js';
3
4
  import type { Callback } from '../../types.js';
4
5
  import type { AllowanceRepository } from '../allowance/index.js';
5
6
  import type { UserSecretRepository } from '../userSecretRepository.js';
@@ -12,8 +13,9 @@ type Params = {
12
13
  ssoSessionRepository: UserSessionRepository;
13
14
  userSecretRepository: UserSecretRepository;
14
15
  allowanceRepository: AllowanceRepository;
16
+ identityRepository: IdentityRepository;
15
17
  };
16
- export declare function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, statementStore, storage, }: Params): {
18
+ export declare function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, identityRepository, statementStore, storage, }: Params): {
17
19
  sessions: {
18
20
  read: () => UserSession[];
19
21
  subscribe: (callback: Callback<UserSession[]>) => () => void;
@@ -4,7 +4,7 @@ import { emitHostPappDebugMessage } from '../../debugBus.js';
4
4
  import { createState } from '../../helpers/state.js';
5
5
  import { createSsoStatementProver } from '../ssoSessionProver.js';
6
6
  import { createUserSession } from './userSession.js';
7
- export function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, statementStore, storage, }) {
7
+ export function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, identityRepository, statementStore, storage, }) {
8
8
  const localSessions = createState({});
9
9
  const sessionUnsubscribes = new Map();
10
10
  const releaseSession = (id) => {
@@ -22,7 +22,7 @@ export function createSsoSessionManager({ ssoSessionRepository, userSecretReposi
22
22
  toRemove.delete(userSession.id);
23
23
  if (userSession.id in activeSessions)
24
24
  continue;
25
- const session = createSession(userSession, statementStore, storage, userSecretRepository);
25
+ const session = createSession(userSession, statementStore, storage, userSecretRepository, allowanceRepository, identityRepository);
26
26
  toAdd.add(session);
27
27
  emitHostPappDebugMessage({
28
28
  layer: 'session',
@@ -73,7 +73,7 @@ export function createSsoSessionManager({ ssoSessionRepository, userSecretReposi
73
73
  subscribe: (callback) => localSessions.subscribe(sessions => callback(Object.values(sessions))),
74
74
  },
75
75
  disconnect(userSession) {
76
- const session = createSession(userSession, statementStore, storage, userSecretRepository);
76
+ const session = createSession(userSession, statementStore, storage, userSecretRepository, allowanceRepository, identityRepository);
77
77
  return session
78
78
  .sendDisconnectMessage()
79
79
  .andThen(() => disconnect(userSession))
@@ -88,7 +88,7 @@ export function createSsoSessionManager({ ssoSessionRepository, userSecretReposi
88
88
  },
89
89
  };
90
90
  }
91
- function createSession(userSession, statementStore, storage, userSecretRepository) {
91
+ function createSession(userSession, statementStore, storage, userSecretRepository, allowanceRepository, identityRepository) {
92
92
  const encryption = createEncryption(userSession.remoteAccount.publicKey);
93
93
  const prover = createSsoStatementProver(userSession, userSecretRepository);
94
94
  return createUserSession({
@@ -97,5 +97,7 @@ function createSession(userSession, statementStore, storage, userSecretRepositor
97
97
  encryption,
98
98
  storage,
99
99
  prover,
100
+ allowanceRepository,
101
+ identityRepository,
100
102
  });
101
103
  }
@@ -0,0 +1,12 @@
1
+ import type { CodecType } from 'scale-ts';
2
+ export type ProductSubtreeRequest = CodecType<typeof ProductSubtreeRequestCodec>;
3
+ export declare const ProductSubtreeRequestCodec: import("scale-ts").Codec<{
4
+ productId: string;
5
+ }>;
6
+ export type ProductSubtreeResponse = CodecType<typeof ProductSubtreeResponseCodec>;
7
+ export declare const ProductSubtreeResponseCodec: import("scale-ts").Codec<{
8
+ respondingTo: string;
9
+ payload: import("scale-ts").ResultPayload<{
10
+ productPublicKey: Uint8Array<ArrayBufferLike>;
11
+ }, string>;
12
+ }>;
@@ -0,0 +1,21 @@
1
+ import { DotNsIdentifier } from '@novasamatech/host-api';
2
+ import { Bytes, Result, Struct, str } from 'scale-ts';
3
+ // RFC-0022 made `//product//{productId}` a hard junction, so the root public
4
+ // key alone no longer determines product account public keys. This request
5
+ // closes the gap: the Account Holder returns the product-subtree public key,
6
+ // from which the Host soft-derives account public keys locally.
7
+ //
8
+ // Consent-free — the response carries no secret material. Fetch once per
9
+ // product and cache; only `AutoSigning` (secret material) requires consent.
10
+ /** 32-byte sr25519 public key of `//product//{productId}`. */
11
+ const Sr25519PublicKey = Bytes(32);
12
+ export const ProductSubtreeRequestCodec = Struct({
13
+ productId: DotNsIdentifier,
14
+ });
15
+ export const ProductSubtreeResponseCodec = Struct({
16
+ // referencing to RemoteMessage.messageId
17
+ respondingTo: str,
18
+ payload: Result(Struct({
19
+ productPublicKey: Sr25519PublicKey,
20
+ }), str),
21
+ });
@@ -74,9 +74,9 @@ export declare const RemoteMessageCodec: import("scale-ts").Codec<{
74
74
  payload: import("scale-ts").ResultPayload<{
75
75
  context: Uint8Array<ArrayBufferLike>;
76
76
  alias: Uint8Array<ArrayBufferLike>;
77
- }, import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
77
+ }, import("@novasamatech/scale").CodecError<undefined, "GetAliasErr::Rejected"> | import("@novasamatech/scale").CodecError<{
78
78
  reason: string;
79
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>;
79
+ }, "GetAliasErr::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "GetAliasErr::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "GetAliasErr::NotMember">>;
80
80
  };
81
81
  } | {
82
82
  tag: "ResourceAllocationRequest";
@@ -220,9 +220,31 @@ export declare const RemoteMessageCodec: import("scale-ts").Codec<{
220
220
  };
221
221
  ringIndex: number;
222
222
  ringRevision: number;
223
- }, import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
223
+ }, import("@novasamatech/scale").CodecError<undefined, "CreateProofErr::Rejected"> | import("@novasamatech/scale").CodecError<{
224
224
  reason: string;
225
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>;
225
+ }, "CreateProofErr::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "CreateProofErr::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "CreateProofErr::NotMember">>;
226
+ };
227
+ } | {
228
+ tag: "SignVrfRequest";
229
+ value: {
230
+ productAccountId: [string, number];
231
+ productId: string;
232
+ transcriptLabel: Uint8Array<ArrayBufferLike>;
233
+ items: {
234
+ label: Uint8Array<ArrayBufferLike>;
235
+ value: Uint8Array<ArrayBufferLike>;
236
+ }[];
237
+ };
238
+ } | {
239
+ tag: "SignVrfResponse";
240
+ value: {
241
+ respondingTo: string;
242
+ payload: import("scale-ts").ResultPayload<{
243
+ preOutput: Uint8Array<ArrayBufferLike>;
244
+ proof: Uint8Array<ArrayBufferLike>;
245
+ }, import("@novasamatech/scale").CodecError<undefined, "SignVrfErr::Rejected"> | import("@novasamatech/scale").CodecError<{
246
+ reason: string;
247
+ }, "SignVrfErr::Unknown">>;
226
248
  };
227
249
  };
228
250
  };
@@ -1,11 +1,15 @@
1
- import { Enum, Struct, _void, str } from 'scale-ts';
1
+ import { Enum } from '@novasamatech/scale';
2
+ import { Struct, _void, str } from 'scale-ts';
2
3
  import { CreateTransactionLegacyRequestCodec, CreateTransactionRequestCodec, CreateTransactionResponseCodec, } from './createTransaction.js';
3
4
  import { ResourceAllocationRequestCodec, ResourceAllocationResponseCodec } from './resourceAllocation.js';
4
5
  import { RingVrfAliasRequestCodec, RingVrfAliasResponseCodec, RingVrfProofRequestCodec, RingVrfProofResponseCodec, } from './ringVrf.js';
6
+ import { SignVrfRequestCodec, SignVrfResponseCodec } from './signVrf.js';
5
7
  import { SignRawLegacyRequestCodec, SignRawLegacyResponseCodec, SigningRequestCodec, SigningResponseCodec, } from './signing.js';
6
8
  export const RemoteMessageCodec = Struct({
7
9
  messageId: str,
8
10
  data: Enum({
11
+ // Declaration order is the SCALE wire order and must stay in lockstep with the
12
+ // truapi `host_logic::sso::messages::v1::RemoteMessage` enum. Append only.
9
13
  v1: Enum({
10
14
  Disconnected: _void,
11
15
  SignRequest: SigningRequestCodec,
@@ -21,6 +25,8 @@ export const RemoteMessageCodec = Struct({
21
25
  SignRawLegacyResponse: SignRawLegacyResponseCodec,
22
26
  RingVrfProofRequest: RingVrfProofRequestCodec,
23
27
  RingVrfProofResponse: RingVrfProofResponseCodec,
28
+ SignVrfRequest: SignVrfRequestCodec,
29
+ SignVrfResponse: SignVrfResponseCodec,
24
30
  }),
25
31
  }),
26
32
  });
@@ -1,27 +1,4 @@
1
1
  import type { CodecType } from 'scale-ts';
2
- export declare const RingVrfError: [import("scale-ts").Encoder<import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
3
- reason: string;
4
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>, import("scale-ts").Decoder<import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
5
- reason: string;
6
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>] & {
7
- enc: import("scale-ts").Encoder<import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
8
- reason: string;
9
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>;
10
- dec: import("scale-ts").Decoder<import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
11
- reason: string;
12
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>;
13
- } & {
14
- readonly RingNotFound: import("@novasamatech/scale").ErrCodec<undefined, "RingVrfError::RingNotFound">;
15
- readonly NotMember: import("@novasamatech/scale").ErrCodec<undefined, "RingVrfError::NotMember">;
16
- readonly Rejected: import("@novasamatech/scale").ErrCodec<undefined, "RingVrfError::Rejected">;
17
- readonly Unknown: import("@novasamatech/scale").ErrCodec<{
18
- reason: string;
19
- }, "RingVrfError::Unknown">;
20
- } & {
21
- [Symbol.hasInstance](v: unknown): v is import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
22
- reason: string;
23
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">;
24
- };
25
2
  export type RingVrfAliasRequest = CodecType<typeof RingVrfAliasRequestCodec>;
26
3
  export declare const RingVrfAliasRequestCodec: import("scale-ts").Codec<{
27
4
  callingProductId: string;
@@ -43,9 +20,9 @@ export declare const RingVrfAliasResponseCodec: import("scale-ts").Codec<{
43
20
  payload: import("scale-ts").ResultPayload<{
44
21
  context: Uint8Array<ArrayBufferLike>;
45
22
  alias: Uint8Array<ArrayBufferLike>;
46
- }, import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
23
+ }, import("@novasamatech/scale").CodecError<undefined, "GetAliasErr::Rejected"> | import("@novasamatech/scale").CodecError<{
47
24
  reason: string;
48
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>;
25
+ }, "GetAliasErr::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "GetAliasErr::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "GetAliasErr::NotMember">>;
49
26
  }>;
50
27
  export type RingVrfProofRequest = CodecType<typeof RingVrfProofRequestCodec>;
51
28
  export declare const RingVrfProofRequestCodec: import("scale-ts").Codec<{
@@ -74,7 +51,7 @@ export declare const RingVrfProofResponseCodec: import("scale-ts").Codec<{
74
51
  };
75
52
  ringIndex: number;
76
53
  ringRevision: number;
77
- }, import("@novasamatech/scale").CodecError<undefined, "RingVrfError::Rejected"> | import("@novasamatech/scale").CodecError<{
54
+ }, import("@novasamatech/scale").CodecError<undefined, "CreateProofErr::Rejected"> | import("@novasamatech/scale").CodecError<{
78
55
  reason: string;
79
- }, "RingVrfError::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "RingVrfError::NotMember">>;
56
+ }, "CreateProofErr::Unknown"> | import("@novasamatech/scale").CodecError<undefined, "CreateProofErr::RingNotFound"> | import("@novasamatech/scale").CodecError<undefined, "CreateProofErr::NotMember">>;
80
57
  }>;
@@ -1,14 +1,5 @@
1
- import { ContextualAlias, DotNsIdentifier, ProductProofContext, RingLocation, RingVrfProof, } from '@novasamatech/host-api';
2
- import { ErrEnum } from '@novasamatech/scale';
3
- import { Bytes, Result, Struct, _void, str } from 'scale-ts';
4
- // Shared failure set for both alias and proof requests over the SSO channel;
5
- // mirrors the Account Holder's `RingVrfError` (RFC 0004).
6
- export const RingVrfError = ErrEnum('RingVrfError', {
7
- RingNotFound: [_void, 'RingVrf: ring not found'],
8
- NotMember: [_void, 'RingVrf: selected member key is not a member of the ring'],
9
- Rejected: [_void, 'RingVrf: rejected'],
10
- Unknown: [Struct({ reason: str }), 'RingVrf: unknown error'],
11
- });
1
+ import { ContextualAlias, CreateProofErr, DotNsIdentifier, GetAliasErr, ProductProofContext, RingLocation, RingVrfProof, } from '@novasamatech/host-api';
2
+ import { Bytes, Result, Struct, str } from 'scale-ts';
12
3
  export const RingVrfAliasRequestCodec = Struct({
13
4
  callingProductId: DotNsIdentifier,
14
5
  context: ProductProofContext,
@@ -16,7 +7,7 @@ export const RingVrfAliasRequestCodec = Struct({
16
7
  });
17
8
  export const RingVrfAliasResponseCodec = Struct({
18
9
  respondingTo: str,
19
- payload: Result(ContextualAlias, RingVrfError),
10
+ payload: Result(ContextualAlias, GetAliasErr),
20
11
  });
21
12
  export const RingVrfProofRequestCodec = Struct({
22
13
  callingProductId: DotNsIdentifier,
@@ -26,5 +17,5 @@ export const RingVrfProofRequestCodec = Struct({
26
17
  });
27
18
  export const RingVrfProofResponseCodec = Struct({
28
19
  respondingTo: str,
29
- payload: Result(RingVrfProof, RingVrfError),
20
+ payload: Result(RingVrfProof, CreateProofErr),
30
21
  });
@@ -0,0 +1,56 @@
1
+ import type { CodecType } from 'scale-ts';
2
+ /**
3
+ * Host → Account Holder request for an sr25519 (schnorrkel) VRF signature over a
4
+ * caller-supplied Merlin transcript (RFC-0023, "Accounts Protocol companion").
5
+ *
6
+ * The Account Holder derives `productAccountId`, presents the signing confirmation,
7
+ * replays the transcript verbatim — `Transcript::new(transcriptLabel)` then one
8
+ * `append_message(label, value)` per item, in order — and signs it. It performs no
9
+ * interpretation of labels or values.
10
+ *
11
+ * This is the non-`AutoSigning` path; when `AutoSigning` covers the account the host
12
+ * signs locally and never sends this message.
13
+ */
14
+ export type SignVrfRequest = CodecType<typeof SignVrfRequestCodec>;
15
+ export declare const SignVrfRequestCodec: import("scale-ts").Codec<{
16
+ productAccountId: [string, number];
17
+ productId: string;
18
+ transcriptLabel: Uint8Array<ArrayBufferLike>;
19
+ items: {
20
+ label: Uint8Array<ArrayBufferLike>;
21
+ value: Uint8Array<ArrayBufferLike>;
22
+ }[];
23
+ }>;
24
+ /** Failure returned by the Account Holder for a VRF signing request. */
25
+ export type SignVrfErr = CodecType<typeof SignVrfErrCodec>;
26
+ export declare const SignVrfErrCodec: [import("scale-ts").Encoder<import("@novasamatech/scale").CodecError<undefined, "SignVrfErr::Rejected"> | import("@novasamatech/scale").CodecError<{
27
+ reason: string;
28
+ }, "SignVrfErr::Unknown">>, import("scale-ts").Decoder<import("@novasamatech/scale").CodecError<undefined, "SignVrfErr::Rejected"> | import("@novasamatech/scale").CodecError<{
29
+ reason: string;
30
+ }, "SignVrfErr::Unknown">>] & {
31
+ enc: import("scale-ts").Encoder<import("@novasamatech/scale").CodecError<undefined, "SignVrfErr::Rejected"> | import("@novasamatech/scale").CodecError<{
32
+ reason: string;
33
+ }, "SignVrfErr::Unknown">>;
34
+ dec: import("scale-ts").Decoder<import("@novasamatech/scale").CodecError<undefined, "SignVrfErr::Rejected"> | import("@novasamatech/scale").CodecError<{
35
+ reason: string;
36
+ }, "SignVrfErr::Unknown">>;
37
+ } & {
38
+ readonly Rejected: import("@novasamatech/scale").ErrCodec<undefined, "SignVrfErr::Rejected">;
39
+ readonly Unknown: import("@novasamatech/scale").ErrCodec<{
40
+ reason: string;
41
+ }, "SignVrfErr::Unknown">;
42
+ } & {
43
+ [Symbol.hasInstance](v: unknown): v is import("@novasamatech/scale").CodecError<undefined, "SignVrfErr::Rejected"> | import("@novasamatech/scale").CodecError<{
44
+ reason: string;
45
+ }, "SignVrfErr::Unknown">;
46
+ };
47
+ export type SignVrfResponse = CodecType<typeof SignVrfResponseCodec>;
48
+ export declare const SignVrfResponseCodec: import("scale-ts").Codec<{
49
+ respondingTo: string;
50
+ payload: import("scale-ts").ResultPayload<{
51
+ preOutput: Uint8Array<ArrayBufferLike>;
52
+ proof: Uint8Array<ArrayBufferLike>;
53
+ }, import("@novasamatech/scale").CodecError<undefined, "SignVrfErr::Rejected"> | import("@novasamatech/scale").CodecError<{
54
+ reason: string;
55
+ }, "SignVrfErr::Unknown">>;
56
+ }>;
@@ -0,0 +1,18 @@
1
+ import { ProductAccountId, VrfSignature, VrfTranscriptItem } from '@novasamatech/host-api';
2
+ import { ErrEnum } from '@novasamatech/scale';
3
+ import { Bytes, Result, Struct, Vector, _void, str } from 'scale-ts';
4
+ export const SignVrfRequestCodec = Struct({
5
+ productAccountId: ProductAccountId,
6
+ productId: str,
7
+ transcriptLabel: Bytes(),
8
+ items: Vector(VrfTranscriptItem),
9
+ });
10
+ export const SignVrfErrCodec = ErrEnum('SignVrfErr', {
11
+ Rejected: [_void, 'Rejected'],
12
+ Unknown: [Struct({ reason: str }), ({ reason }) => reason],
13
+ });
14
+ export const SignVrfResponseCodec = Struct({
15
+ // referencing to RemoteMessage.messageId
16
+ respondingTo: str,
17
+ payload: Result(VrfSignature, SignVrfErrCodec),
18
+ });
@@ -1,15 +1,22 @@
1
- import { ContextualAlias, ProductProofContext, RingLocation, RingVrfProof } from '@novasamatech/host-api';
1
+ import { ContextualAlias, ProductProofContext, RingLocation, RingVrfProof, VrfSignature } from '@novasamatech/host-api';
2
2
  import type { Encryption, StatementProver, StatementStoreAdapter } from '@novasamatech/statement-store';
3
3
  import type { StorageAdapter } from '@novasamatech/storage-adapter';
4
4
  import { ResultAsync } from 'neverthrow';
5
5
  import type { CodecType } from 'scale-ts';
6
+ import type { Identity, IdentityRepository } from '../../identity/types.js';
6
7
  import type { Callback } from '../../types.js';
8
+ import type { AllowanceRepository, AllowanceResourceKind } from '../allowance/index.js';
7
9
  import type { StoredUserSession } from '../userSessionRepository.js';
8
10
  import type { CreateTransactionLegacyRequest, CreateTransactionRequest } from './scale/createTransaction.js';
9
11
  import { RemoteMessageCodec } from './scale/remoteMessage.js';
10
12
  import type { ApAllocationOutcome, ResourceAllocationRequest } from './scale/resourceAllocation.js';
13
+ import type { SignVrfRequest } from './scale/signVrf.js';
11
14
  import type { SignRawLegacyRequest, SigningPayloadRequest, SigningPayloadResponseData, SigningRawRequest } from './scale/signing.js';
12
15
  export type UserSession = StoredUserSession & {
16
+ /** Read this session's persisted allowance slot-account key for a product/resource. */
17
+ readAllowance(productId: string, resource: AllowanceResourceKind): ResultAsync<Uint8Array | null, Error>;
18
+ /** Look up the on-chain identity of this session's user identity account. */
19
+ getIdentity(): ResultAsync<Identity | null, Error>;
13
20
  sendDisconnectMessage(): ResultAsync<void, Error>;
14
21
  abortPendingRequests(): ResultAsync<void, Error>;
15
22
  signPayload(payload: SigningPayloadRequest): ResultAsync<SigningPayloadResponseData, Error>;
@@ -19,14 +26,17 @@ export type UserSession = StoredUserSession & {
19
26
  createTransactionLegacy(payload: CreateTransactionLegacyRequest): ResultAsync<Uint8Array, Error>;
20
27
  getRingVrfAlias(callingProductId: string, context: CodecType<typeof ProductProofContext>, ring: CodecType<typeof RingLocation>): ResultAsync<CodecType<typeof ContextualAlias>, Error>;
21
28
  createRingVrfProof(callingProductId: string, context: CodecType<typeof ProductProofContext>, ring: CodecType<typeof RingLocation>, message: Uint8Array): ResultAsync<CodecType<typeof RingVrfProof>, Error>;
29
+ signVrf(payload: SignVrfRequest): ResultAsync<CodecType<typeof VrfSignature>, Error>;
22
30
  requestResourceAllocation(request: ResourceAllocationRequest): ResultAsync<ApAllocationOutcome[], Error>;
23
31
  subscribe(callback: Callback<CodecType<typeof RemoteMessageCodec>, ResultAsync<boolean, Error>>): VoidFunction;
24
32
  dispose(): void;
25
33
  };
26
- export declare function createUserSession({ userSession, statementStore, encryption, storage, prover, }: {
34
+ export declare function createUserSession({ userSession, statementStore, encryption, storage, prover, allowanceRepository, identityRepository, }: {
27
35
  userSession: StoredUserSession;
28
36
  statementStore: StatementStoreAdapter;
29
37
  encryption: Encryption;
30
38
  storage: StorageAdapter;
31
39
  prover: StatementProver;
40
+ allowanceRepository: AllowanceRepository;
41
+ identityRepository: IdentityRepository;
32
42
  }): UserSession;
@@ -1,9 +1,10 @@
1
- import { ContextualAlias, ProductProofContext, RingLocation, RingVrfProof } from '@novasamatech/host-api';
1
+ import { ContextualAlias, ProductProofContext, RingLocation, RingVrfProof, VrfSignature } from '@novasamatech/host-api';
2
2
  import { enumValue } from '@novasamatech/scale';
3
3
  import { createSession } from '@novasamatech/statement-store';
4
4
  import { fieldListView } from '@novasamatech/storage-adapter';
5
5
  import { nanoid } from 'nanoid';
6
6
  import { ResultAsync, err, ok, okAsync } from 'neverthrow';
7
+ import { toHex } from 'polkadot-api/utils';
7
8
  import { emitHostPappDebugMessage } from '../../debugBus.js';
8
9
  import { createAsyncTaskPool } from '../../helpers/createAsyncTaskPool.js';
9
10
  import { toError } from '../../helpers/utils.js';
@@ -80,7 +81,7 @@ function withHostActionTrace(result, messageId, sessionId) {
80
81
  });
81
82
  });
82
83
  }
83
- export function createUserSession({ userSession, statementStore, encryption, storage, prover, }) {
84
+ export function createUserSession({ userSession, statementStore, encryption, storage, prover, allowanceRepository, identityRepository, }) {
84
85
  const requestQueue = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
85
86
  // Shared abort handle for everything currently on the request queue.
86
87
  // abortPendingRequests() fires it to drop the in-flight task plus anything
@@ -111,6 +112,12 @@ export function createUserSession({ userSession, statementStore, encryption, sto
111
112
  });
112
113
  return {
113
114
  ...userSession,
115
+ readAllowance(productId, resource) {
116
+ return allowanceRepository.read(userSession.id, productId, resource);
117
+ },
118
+ getIdentity() {
119
+ return identityRepository.getIdentity(toHex(userSession.identityAccountId));
120
+ },
114
121
  signPayload(payload) {
115
122
  return enqueue(() => {
116
123
  const messageId = nanoid();
@@ -280,6 +287,24 @@ export function createUserSession({ userSession, statementStore, encryption, sto
280
287
  return withHostActionTrace(awaitReplyOrAckFailure(request, reply).andThen(result => result.success ? ok(result.value) : err(result.value)), messageId, userSession.id);
281
288
  });
282
289
  },
290
+ signVrf(payload) {
291
+ return enqueue(() => {
292
+ const messageId = nanoid();
293
+ const data = enumValue('v1', enumValue('SignVrfRequest', payload));
294
+ emitHostAction(messageId, actionKindFromMessageData(data), userSession.id);
295
+ const responseFilter = (message) => {
296
+ if (message.data.tag === 'v1' &&
297
+ message.data.value.tag === 'SignVrfResponse' &&
298
+ message.data.value.value.respondingTo === messageId) {
299
+ return message.data.value.value.payload;
300
+ }
301
+ };
302
+ const request = session.request(RemoteMessageCodec, { messageId, data });
303
+ const reply = session.waitForRequestMessage(RemoteMessageCodec, responseFilter);
304
+ const inner = awaitReplyOrAckFailure(request, reply).andThen(result => result.success ? ok(result.value) : err(result.value));
305
+ return withHostActionTrace(withQueueTimeout(inner, 'signVrf'), messageId, userSession.id);
306
+ });
307
+ },
283
308
  requestResourceAllocation(payload) {
284
309
  return enqueue(() => {
285
310
  const messageId = nanoid();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/host-papp",
3
3
  "type": "module",
4
- "version": "0.8.11",
4
+ "version": "0.8.12",
5
5
  "description": "Polkadot app integration",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -34,14 +34,14 @@
34
34
  "@noble/ciphers": "2.2.0",
35
35
  "@noble/curves": "2.2.0",
36
36
  "@noble/hashes": "2.2.0",
37
- "@novasamatech/host-api": "0.8.11",
38
- "@novasamatech/scale": "0.8.11",
39
- "@novasamatech/statement-store": "0.8.11",
40
- "@novasamatech/storage-adapter": "0.8.11",
37
+ "@novasamatech/host-api": "0.8.12",
38
+ "@novasamatech/scale": "0.8.12",
39
+ "@novasamatech/statement-store": "0.8.12",
40
+ "@novasamatech/storage-adapter": "0.8.12",
41
41
  "@polkadot-api/utils": "^0.4.0",
42
- "@polkadot-labs/hdkd-helpers": "^0.0.30",
43
- "nanoevents": "9.1.0",
44
- "nanoid": "5.1.11",
42
+ "@polkadot-labs/hdkd-helpers": "^0.0.31",
43
+ "nanoevents": "10.0.0",
44
+ "nanoid": "6.0.0",
45
45
  "neverthrow": "^8.2.0",
46
46
  "polkadot-api": ">=2",
47
47
  "rxjs": "^7.8.2",