@parity/product-sdk-signer 0.6.4 → 0.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parity/product-sdk-signer",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Signer manager for Polkadot — Host API and dev accounts",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,13 +20,13 @@
20
20
  "dependencies": {
21
21
  "polkadot-api": "^2.1.5",
22
22
  "@parity/product-sdk-address": "0.1.1",
23
- "@parity/product-sdk-keys": "0.3.7",
24
- "@parity/product-sdk-logger": "0.1.1",
25
- "@parity/product-sdk-host": "0.9.0"
23
+ "@parity/product-sdk-host": "0.10.0",
24
+ "@parity/product-sdk-keys": "0.3.8",
25
+ "@parity/product-sdk-logger": "0.1.1"
26
26
  },
27
27
  "optionalDependencies": {
28
- "@novasamatech/host-api-wrapper": "^0.8.7-2",
29
- "@novasamatech/host-api": "^0.8.7-2"
28
+ "@novasamatech/host-api-wrapper": "^0.8.7",
29
+ "@novasamatech/host-api": "^0.8.7"
30
30
  },
31
31
  "devDependencies": {
32
32
  "tsup": "^8.5.1",
package/src/errors.ts CHANGED
@@ -10,7 +10,18 @@ export class SignerError extends Error {
10
10
  }
11
11
  }
12
12
 
13
- /** The Host API is not available (product-sdk not installed or not inside a container). */
13
+ /**
14
+ * The Host API is not available.
15
+ *
16
+ * Common causes:
17
+ * - The app is loaded outside a Polkadot host container (a regular browser tab
18
+ * under `npm run dev`, no iframe, no WebView). This is the dominant case
19
+ * during local development.
20
+ * - The optional `@novasamatech/host-api(-wrapper)` peer is not installed.
21
+ *
22
+ * Branch with `instanceof HostUnavailableError` to surface a "open this app
23
+ * in a Polkadot host, or pick a dev provider" message to the user.
24
+ */
14
25
  export class HostUnavailableError extends SignerError {
15
26
  constructor(message = "Host API is not available") {
16
27
  super(message);
@@ -55,8 +55,6 @@ export interface HostProviderOptions {
55
55
  * `dotNsIdentifier`, skipping the legacy fetch entirely. For apps
56
56
  * that sign exclusively with a per-dapp derived account.
57
57
  *
58
- * `SignerAccount.name` is populated best-effort from
59
- * `accounts.getUserId().primaryUsername`; failures leave it null.
60
58
  * Signing is pinned to `createTransaction` (see PR #96).
61
59
  */
62
60
  productAccount?: {
@@ -64,6 +62,20 @@ export interface HostProviderOptions {
64
62
  dotNsIdentifier: string;
65
63
  /** Derivation index within the app scope. Default: 0. */
66
64
  derivationIndex?: number;
65
+ /**
66
+ * Populate `SignerAccount.name` best-effort from
67
+ * `accounts.getUserId().primaryUsername`.
68
+ *
69
+ * On by default. Set to `false` to skip the fetch: `getUserId`
70
+ * triggers a host identity-permission prompt, so apps that don't
71
+ * render the user's name (those with their own display chain, e.g.
72
+ * registry username → fallback) can opt out and avoid the prompt.
73
+ * When enabled and the fetch fails (NotConnected, PermissionDenied,
74
+ * codec drift) the name stays null and connect still succeeds. The
75
+ * name can also be fetched later on demand via
76
+ * {@link HostProvider.getUserId}. Default: `true`.
77
+ */
78
+ requestName?: boolean;
67
79
  };
68
80
  }
69
81
 
@@ -181,6 +193,15 @@ export interface ProductSdkModule {
181
193
  createAccountsProvider: () => AccountsProvider;
182
194
  /** Present from product-sdk ≥ 0.6; used to request TransactionSubmit. */
183
195
  hostApi?: HostApiPermissionBridge;
196
+ /**
197
+ * `sandboxTransport.isCorrectEnvironment()` returns `false` when the app
198
+ * is loaded outside a Polkadot host container (e.g. a regular browser
199
+ * tab). Calling `getLegacyAccounts()` / `getProductAccount()` in that
200
+ * state surfaces the upstream `Environment is not correct` exception,
201
+ * so we pre-check during `connect()` and raise a specific
202
+ * {@link HostUnavailableError} with actionable guidance instead.
203
+ */
204
+ sandboxTransport?: { isCorrectEnvironment(): boolean };
184
205
  }
185
206
 
186
207
  /* @integration */
@@ -196,9 +217,13 @@ async function defaultLoadHostApiEnum(): Promise<HostApiEnumHelper> {
196
217
  /**
197
218
  * Provider for the Host API (Polkadot Desktop / Android).
198
219
  *
199
- * Dynamically imports `@novasamatech/host-api-wrapper` at runtime so it remains
200
- * an optional peer dependency. Apps running outside a host container will
201
- * gracefully get a `HOST_UNAVAILABLE` error.
220
+ * Dynamically imports `@novasamatech/host-api-wrapper` at runtime. Apps running
221
+ * outside a host container — e.g. a plain browser tab during `npm run dev` —
222
+ * resolve to {@link HostUnavailableError} with guidance on what to do (open
223
+ * the app inside a Polkadot host or pick a non-host provider). The check uses
224
+ * the wrapper's `sandboxTransport.isCorrectEnvironment()` predicate and runs
225
+ * before any host RPC call, so the user never sees the upstream
226
+ * `Environment is not correct` exception leaking through.
202
227
  *
203
228
  * Supports both non-product accounts (user's external wallets) and product
204
229
  * accounts (app-scoped derived accounts managed by the host).
@@ -395,6 +420,43 @@ export class HostProvider implements SignerProvider {
395
420
  }
396
421
  }
397
422
 
423
+ /**
424
+ * Fetch the connected user's primary username from the host.
425
+ *
426
+ * Use this to retrieve the name lazily — e.g. on a profile screen that
427
+ * actually displays it — when `connect()` ran without
428
+ * `productAccount.requestName` (the default) and so never fetched it.
429
+ * Like the connect-time fetch this triggers a host identity-permission
430
+ * prompt; unlike it, the result is returned as a structured `Result` so
431
+ * callers can react to a `PermissionDenied` / `NotConnected` rejection
432
+ * explicitly instead of silently falling back to a nameless account.
433
+ *
434
+ * Requires a prior successful `connect()` call.
435
+ */
436
+ async getUserId(): Promise<Result<{ primaryUsername: string }, SignerError>> {
437
+ if (!this.accountsProvider) {
438
+ return err(new HostUnavailableError("Host provider is not connected"));
439
+ }
440
+
441
+ try {
442
+ const result = (await this.accountsProvider.getUserId().match(
443
+ (value) => value,
444
+ (error) => {
445
+ throw new Error(`Host rejected user id request: ${formatError(error)}`);
446
+ },
447
+ )) as { primaryUsername: string };
448
+
449
+ return ok(result);
450
+ } catch (cause) {
451
+ log.error("failed to get user id", { cause });
452
+ return err(
453
+ new HostRejectedError(
454
+ cause instanceof Error ? cause.message : "Failed to get user id",
455
+ ),
456
+ );
457
+ }
458
+ }
459
+
398
460
  /**
399
461
  * Create a Ring VRF proof for anonymous operations.
400
462
  *
@@ -454,11 +516,41 @@ export class HostProvider implements SignerProvider {
454
516
  );
455
517
  }
456
518
 
457
- // Step 2: Create accounts provider
519
+ // Step 2: Verify we're actually running inside a host container.
520
+ //
521
+ // The upstream `host-api` transport throws `Error('Environment is not
522
+ // correct')` from inside `getLegacyAccounts()` / `getProductAccount()`
523
+ // when `sandboxTransport.isCorrectEnvironment()` returns false (i.e.
524
+ // we're not in an iframe under Polkadot Desktop, or a WebView under
525
+ // Polkadot Mobile). Without this pre-check, that exception used to
526
+ // surface as `HostRejectedError("Host rejected account request:
527
+ // Environment is not correct")` — misleading because no host rejected
528
+ // anything; there's no host at all.
529
+ //
530
+ // Returning `HostUnavailableError` here matches the TSDoc contract
531
+ // ("Apps running outside a host container will gracefully get a
532
+ // HOST_UNAVAILABLE error") and gives consumers actionable guidance.
533
+ //
534
+ // The `sandboxTransport` field is optional in `ProductSdkModule` so
535
+ // older wrapper versions (or test mocks that don't supply it) keep
536
+ // working — we fall through to the existing flow and rely on the
537
+ // catch in Step 4 as a safety net.
538
+ if (sdk.sandboxTransport && !sdk.sandboxTransport.isCorrectEnvironment()) {
539
+ log.warn("not inside a host container — Host API unavailable");
540
+ return err(
541
+ new HostUnavailableError(
542
+ "Host API is not available: not running inside a Polkadot host container. " +
543
+ "Open this app inside Polkadot Desktop or the Polkadot Mobile WebView, " +
544
+ "or pick a non-host signer provider (e.g. dev accounts).",
545
+ ),
546
+ );
547
+ }
548
+
549
+ // Step 3: Create accounts provider
458
550
  const provider = sdk.createAccountsProvider();
459
551
  this.accountsProvider = provider;
460
552
 
461
- // Step 3: Fetch accounts.
553
+ // Step 4: Fetch accounts.
462
554
  //
463
555
  // When `productAccount` is configured, skip the legacy fetch entirely
464
556
  // and return a single product account. Product-account-only apps
@@ -471,6 +563,7 @@ export class HostProvider implements SignerProvider {
471
563
  provider,
472
564
  this.productAccount.dotNsIdentifier,
473
565
  this.productAccount.derivationIndex ?? 0,
566
+ this.productAccount.requestName ?? true,
474
567
  );
475
568
  if (!accountResult.ok) return accountResult;
476
569
  signerAccounts = [accountResult.value];
@@ -484,6 +577,24 @@ export class HostProvider implements SignerProvider {
484
577
  },
485
578
  )) as RawAccount[];
486
579
  } catch (cause) {
580
+ // Safety net: upstream `host-api/transport.js` throws
581
+ // `Error('Environment is not correct')` synchronously inside
582
+ // `getLegacyAccounts()` when the env check fails. The Step 2
583
+ // pre-check catches this normally, but we also re-classify
584
+ // here for older wrappers that don't expose `sandboxTransport`
585
+ // and for races where the env flips after the pre-check.
586
+ // Without this, the user sees a misleading "Host rejected
587
+ // account request:" prefix for an error nothing rejected.
588
+ if (cause instanceof Error && /environment is not correct/i.test(cause.message)) {
589
+ log.warn("not inside a host container (detected at getLegacyAccounts)");
590
+ return err(
591
+ new HostUnavailableError(
592
+ "Host API is not available: not running inside a Polkadot host container. " +
593
+ "Open this app inside Polkadot Desktop or the Polkadot Mobile WebView, " +
594
+ "or pick a non-host signer provider (e.g. dev accounts).",
595
+ ),
596
+ );
597
+ }
487
598
  log.error("failed to get accounts from host", { cause });
488
599
  return err(
489
600
  new HostRejectedError(
@@ -500,7 +611,7 @@ export class HostProvider implements SignerProvider {
500
611
  signerAccounts = this.mapAccounts(rawAccounts);
501
612
  }
502
613
 
503
- // Step 4: Request ChainSubmit permission up-front.
614
+ // Step 5: Request ChainSubmit permission up-front.
504
615
  //
505
616
  // The host gates signing on this permission — without it, the
506
617
  // production host rejects every sign request with `PermissionDenied`
@@ -561,14 +672,19 @@ export class HostProvider implements SignerProvider {
561
672
  provider: AccountsProvider,
562
673
  dotNsIdentifier: string,
563
674
  derivationIndex: number,
675
+ requestName: boolean,
564
676
  ): Promise<Result<SignerAccount, SignerError>> {
565
- // Run the account fetch and the best-effort username fetch in
566
- // parallel they're independent host RPCs. `getUserId` failures
567
- // (NotConnected, PermissionDenied, codec drift) resolve to `null`
568
- // so they never abort connect; the account name falls back to
569
- // whatever `getProductAccount` returned (typically also null,
570
- // since product accounts are nameless on the host side).
677
+ // The name fetch is on by default; `requestName: false` opts out.
678
+ // `getUserId` triggers a host identity-permission prompt, so apps
679
+ // that don't render the user's name can skip it. When enabled it
680
+ // runs in parallel with the account fetch they're independent host
681
+ // RPCs and its failures (NotConnected, PermissionDenied, codec
682
+ // drift) resolve to `null` so they never abort connect; the account
683
+ // name then falls back to whatever `getProductAccount` returned
684
+ // (typically also null, since product accounts are nameless on the
685
+ // host side).
571
686
  const fetchUsername = async (): Promise<string | null> => {
687
+ if (!requestName) return null;
572
688
  try {
573
689
  return await provider.getUserId().match(
574
690
  (result) => result.primaryUsername,
@@ -747,11 +863,21 @@ if (import.meta.vitest) {
747
863
  mockProvider: ReturnType<typeof createMockProvider>,
748
864
  opts?: {
749
865
  hostApi?: HostApiPermissionBridge;
866
+ /**
867
+ * When provided, the mock's `sandboxTransport.isCorrectEnvironment()`
868
+ * returns this value — exercises the env-check branch added in the
869
+ * `connect()` flow. Omit to skip the check entirely (older-wrapper
870
+ * compatibility path).
871
+ */
872
+ isCorrectEnvironment?: boolean;
750
873
  },
751
874
  ): ProductSdkModule {
752
875
  return {
753
876
  createAccountsProvider: () => mockProvider as unknown as AccountsProvider,
754
877
  ...(opts?.hostApi ? { hostApi: opts.hostApi } : {}),
878
+ ...(opts?.isCorrectEnvironment !== undefined
879
+ ? { sandboxTransport: { isCorrectEnvironment: () => opts.isCorrectEnvironment! } }
880
+ : {}),
755
881
  };
756
882
  }
757
883
 
@@ -791,6 +917,89 @@ if (import.meta.vitest) {
791
917
  }
792
918
  });
793
919
 
920
+ test("returns HOST_UNAVAILABLE with actionable guidance when not inside a host container", async () => {
921
+ // Repro for playground-cli#4: `pg mod foo` + `npm run dev` opens
922
+ // localhost in a plain browser tab (no iframe, no WebView).
923
+ // sandboxTransport.isCorrectEnvironment() returns false, and
924
+ // pre-fix we surfaced the upstream "Environment is not correct"
925
+ // as `HostRejectedError("Host rejected account request: ...")`.
926
+ // Post-fix: we pre-check during connect() and return a specific
927
+ // HostUnavailableError naming the host container and pointing
928
+ // the user at the fix path.
929
+ const mockProvider = createMockProvider({ accounts: [] });
930
+ const provider = new HostProvider({
931
+ maxRetries: 1,
932
+ loadSdk: () =>
933
+ Promise.resolve(createMockSdk(mockProvider, { isCorrectEnvironment: false })),
934
+ });
935
+ const result = await provider.connect();
936
+
937
+ expect(result.ok).toBe(false);
938
+ if (!result.ok) {
939
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
940
+ expect(result.error.message).toMatch(
941
+ /not running inside a Polkadot host container/i,
942
+ );
943
+ expect(result.error.message).toMatch(/Polkadot Desktop|Polkadot Mobile/i);
944
+ }
945
+ // We never reached `getLegacyAccounts()` — proves the env check
946
+ // short-circuits before any RPC call, so users in a dev browser
947
+ // never see the upstream exception text leak through.
948
+ expect(mockProvider.getLegacyAccounts).not.toHaveBeenCalled();
949
+ });
950
+
951
+ test("safety net: re-classifies upstream 'Environment is not correct' as HOST_UNAVAILABLE", async () => {
952
+ // For older wrappers (or test mocks) that don't supply
953
+ // `sandboxTransport`, the Step 2 pre-check is skipped and the
954
+ // upstream throw surfaces at `getLegacyAccounts()`. The catch
955
+ // in Step 4 must re-classify it rather than wrapping with the
956
+ // misleading "Host rejected account request:" prefix.
957
+ const mockProvider = createMockProvider({
958
+ shouldReject: true,
959
+ error: "Environment is not correct",
960
+ });
961
+ const provider = new HostProvider({
962
+ maxRetries: 1,
963
+ // sandboxTransport intentionally omitted — exercises the
964
+ // safety-net path, not the pre-check path.
965
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
966
+ });
967
+ const result = await provider.connect();
968
+
969
+ expect(result.ok).toBe(false);
970
+ if (!result.ok) {
971
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
972
+ // Must NOT contain the misleading "Host rejected account request:" prefix.
973
+ expect(result.error.message).not.toMatch(/Host rejected/i);
974
+ expect(result.error.message).toMatch(
975
+ /not running inside a Polkadot host container/i,
976
+ );
977
+ }
978
+ });
979
+
980
+ test("connect proceeds when sandboxTransport reports a correct environment", async () => {
981
+ // Mirror of the existing happy path, but with an explicit
982
+ // `isCorrectEnvironment: true` to prove the pre-check doesn't
983
+ // false-fail when the env IS correct.
984
+ const rawAccounts: RawAccountTest[] = [
985
+ { publicKey: new Uint8Array(32).fill(0x42), name: "Alice" },
986
+ ];
987
+ const mockProvider = createMockProvider({ accounts: rawAccounts });
988
+ const provider = new HostProvider({
989
+ maxRetries: 1,
990
+ loadSdk: () =>
991
+ Promise.resolve(createMockSdk(mockProvider, { isCorrectEnvironment: true })),
992
+ });
993
+ const result = await provider.connect();
994
+
995
+ expect(result.ok).toBe(true);
996
+ if (result.ok) {
997
+ expect(result.value).toHaveLength(1);
998
+ expect(result.value[0].address).toMatch(/^5/);
999
+ }
1000
+ expect(mockProvider.getLegacyAccounts).toHaveBeenCalled();
1001
+ });
1002
+
794
1003
  test("returns HOST_REJECTED when getLegacyAccounts fails", async () => {
795
1004
  const mockProvider = createMockProvider({ shouldReject: true, error: "Rejected" });
796
1005
  const provider = new HostProvider({
@@ -899,7 +1108,7 @@ if (import.meta.vitest) {
899
1108
  unsub();
900
1109
  });
901
1110
 
902
- test("productAccount option returns the derived account, populates name via getUserId, and skips the legacy fetch", async () => {
1111
+ test("productAccount populates name via getUserId by default and skips the legacy fetch", async () => {
903
1112
  const productPubkey = new Uint8Array(32).fill(0xcc);
904
1113
  const mockProvider = createMockProvider({
905
1114
  accounts: [{ publicKey: productPubkey, name: undefined }],
@@ -932,7 +1141,33 @@ if (import.meta.vitest) {
932
1141
  expect(mockProvider.getLegacyAccounts).not.toHaveBeenCalled();
933
1142
  });
934
1143
 
935
- test("productAccount option survives getUserId failure (name stays null, connect still succeeds)", async () => {
1144
+ test("productAccount with requestName:false skips getUserId (no identity prompt) and leaves name null", async () => {
1145
+ // Opt-out: `getUserId` triggers a host identity-permission prompt,
1146
+ // so apps that don't render the name set `requestName: false`.
1147
+ const productPubkey = new Uint8Array(32).fill(0xab);
1148
+ const mockProvider = createMockProvider({
1149
+ accounts: [{ publicKey: productPubkey, name: undefined }],
1150
+ primaryUsername: "alice",
1151
+ });
1152
+ const provider = new HostProvider({
1153
+ maxRetries: 1,
1154
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1155
+ productAccount: { dotNsIdentifier: "myapp.dot", requestName: false },
1156
+ });
1157
+ const result = await provider.connect();
1158
+
1159
+ expect(result.ok).toBe(true);
1160
+ if (result.ok) {
1161
+ expect(result.value).toHaveLength(1);
1162
+ expect(result.value[0].publicKey).toEqual(productPubkey);
1163
+ expect(result.value[0].name).toBeNull();
1164
+ }
1165
+ expect(mockProvider.getProductAccount).toHaveBeenCalledWith("myapp.dot", 0);
1166
+ expect(mockProvider.getUserId).not.toHaveBeenCalled();
1167
+ expect(mockProvider.getLegacyAccounts).not.toHaveBeenCalled();
1168
+ });
1169
+
1170
+ test("productAccount survives getUserId failure (name stays null, connect still succeeds)", async () => {
936
1171
  const productPubkey = new Uint8Array(32).fill(0xee);
937
1172
  const mockProvider = createMockProvider({
938
1173
  accounts: [{ publicKey: productPubkey, name: undefined }],
@@ -957,6 +1192,62 @@ if (import.meta.vitest) {
957
1192
  }
958
1193
  });
959
1194
 
1195
+ test("getUserId() retrieves the primary username after a connect that opted out of the name fetch", async () => {
1196
+ // The escape hatch for `requestName: false`: connect without the
1197
+ // prompt (name=null), then fetch the name lazily later — e.g. when
1198
+ // a profile screen needs to display it.
1199
+ const productPubkey = new Uint8Array(32).fill(0xa1);
1200
+ const mockProvider = createMockProvider({
1201
+ accounts: [{ publicKey: productPubkey, name: undefined }],
1202
+ primaryUsername: "alice",
1203
+ });
1204
+ const provider = new HostProvider({
1205
+ maxRetries: 1,
1206
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1207
+ productAccount: { dotNsIdentifier: "myapp.dot", requestName: false },
1208
+ });
1209
+ const connectResult = await provider.connect();
1210
+ expect(connectResult.ok).toBe(true);
1211
+ if (connectResult.ok) expect(connectResult.value[0].name).toBeNull();
1212
+ // Not fetched during connect...
1213
+ expect(mockProvider.getUserId).not.toHaveBeenCalled();
1214
+
1215
+ // ...but reachable on demand afterwards.
1216
+ const userId = await provider.getUserId();
1217
+ expect(userId.ok).toBe(true);
1218
+ if (userId.ok) expect(userId.value.primaryUsername).toBe("alice");
1219
+ expect(mockProvider.getUserId).toHaveBeenCalledTimes(1);
1220
+ });
1221
+
1222
+ test("getUserId() returns HostUnavailableError before connect", async () => {
1223
+ const provider = new HostProvider({ maxRetries: 1 });
1224
+ const result = await provider.getUserId();
1225
+ expect(result.ok).toBe(false);
1226
+ if (!result.ok) expect(result.error).toBeInstanceOf(HostUnavailableError);
1227
+ });
1228
+
1229
+ test("getUserId() surfaces a host rejection as HostRejectedError", async () => {
1230
+ const mockProvider = createMockProvider({
1231
+ accounts: [{ publicKey: new Uint8Array(32).fill(0xa2), name: undefined }],
1232
+ });
1233
+ const provider = new HostProvider({
1234
+ maxRetries: 1,
1235
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1236
+ productAccount: { dotNsIdentifier: "myapp.dot", requestName: false },
1237
+ });
1238
+ await provider.connect();
1239
+ // After connect, force the host to reject the on-demand fetch.
1240
+ mockProvider.getUserId.mockReturnValue({
1241
+ match: async (
1242
+ _onOk: (v: { primaryUsername: string }) => unknown,
1243
+ onErr: (e: unknown) => unknown,
1244
+ ) => onErr({ tag: "v1", value: { tag: "GetUserIdErr::PermissionDenied" } }),
1245
+ });
1246
+ const result = await provider.getUserId();
1247
+ expect(result.ok).toBe(false);
1248
+ if (!result.ok) expect(result.error).toBeInstanceOf(HostRejectedError);
1249
+ });
1250
+
960
1251
  test("productAccount option succeeds when host has no legacy accounts (regression: signer 0.5.0 NoAccountsError)", async () => {
961
1252
  // Without the option, this scenario returned `err(NoAccountsError)`
962
1253
  // before any product-account fetch could happen — breaking every