@novasamatech/host-papp 0.8.0-0 → 0.8.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.
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Lightweight public entry for the debug bus. Lives outside the main
3
3
  * `index.ts` so consumers can import the debug surface without
4
- * loading the attestation / session-manager modules, which pull in
5
- * verifiablejs WASM that can't initialise in all environments.
4
+ * loading the session-manager module.
6
5
  */
7
6
  export { emitHostPappDebugMessage, hasHostPappDebugListeners, onHostPappDebugMessage } from './debugBus.js';
8
7
  export type { AttestationDebugEvent, HostPappDebugEvent, SessionDebugEvent, SsoDebugEvent } from './debugTypes.js';
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Lightweight public entry for the debug bus. Lives outside the main
3
3
  * `index.ts` so consumers can import the debug surface without
4
- * loading the attestation / session-manager modules, which pull in
5
- * verifiablejs WASM that can't initialise in all environments.
4
+ * loading the session-manager module.
6
5
  */
7
6
  export { emitHostPappDebugMessage, hasHostPappDebugListeners, onHostPappDebugMessage } from './debugBus.js';
@@ -20,6 +20,7 @@ declare class AsyncTaskPool {
20
20
  constructor(config: Params);
21
21
  call<T>(fn: () => ResultAsync<T, Error>, params?: TaskParams): ResultAsync<T, Error>;
22
22
  settle(pool: string): Promise<void>;
23
+ private abortTask;
23
24
  private processPool;
24
25
  private tryToSettlePool;
25
26
  private retryDelay;
@@ -3,6 +3,9 @@ import { fromPromise } from 'neverthrow';
3
3
  import { promiseWithResolvers } from './promiseWithResolvers.js';
4
4
  import { nullable, toError } from './utils.js';
5
5
  export const DEFAULT_POOL = 'default';
6
+ function abortReason(signal) {
7
+ return signal.reason instanceof Error ? signal.reason : new Error('Task aborted');
8
+ }
6
9
  /**
7
10
  * Task manager with queues, retries and named pools.
8
11
  */
@@ -16,14 +19,21 @@ class AsyncTaskPool {
16
19
  }
17
20
  call(fn, params) {
18
21
  const { resolve, reject, promise } = promiseWithResolvers();
22
+ const signal = params?.signal;
23
+ // An already-aborted signal never enqueues the task — reject up-front.
24
+ if (signal?.aborted) {
25
+ reject(abortReason(signal));
26
+ return fromPromise(promise, toError);
27
+ }
19
28
  const task = {
20
29
  fn,
21
30
  pool: params?.pool ?? DEFAULT_POOL,
22
31
  retry: 0,
23
32
  resolve,
24
33
  reject,
25
- signal: params?.signal,
34
+ signal,
26
35
  };
36
+ signal?.addEventListener('abort', () => this.abortTask(task), { once: true });
27
37
  this.queue.push(task);
28
38
  this.processPool(task.pool);
29
39
  return fromPromise(promise, toError);
@@ -42,6 +52,23 @@ class AsyncTaskPool {
42
52
  const unsubscribe = this.events.on('settled', handler);
43
53
  });
44
54
  }
55
+ // Drop a task because its signal aborted, whether it is still queued, waiting on a
56
+ // retry timer, or already running. Rejecting a promise that later settles on its own
57
+ // is a no-op, so the in-flight fn finishing afterwards can't override this rejection.
58
+ abortTask(task) {
59
+ if (task.retryTimeout !== undefined) {
60
+ clearTimeout(task.retryTimeout);
61
+ }
62
+ const queueIndex = this.queue.indexOf(task);
63
+ if (queueIndex >= 0) {
64
+ this.queue.splice(queueIndex, 1);
65
+ }
66
+ this.activeTasks = this.activeTasks.filter(x => x !== task);
67
+ task.reject(task.signal ? abortReason(task.signal) : new Error('Task aborted'));
68
+ // Free the slot the task held (or would have held) so the pool keeps draining.
69
+ this.processPool(task.pool);
70
+ this.tryToSettlePool(task.pool);
71
+ }
45
72
  processPool(pool) {
46
73
  let task = null;
47
74
  const activeTasks = this.activeTasks.filter(x => x.pool === pool);
@@ -63,17 +90,17 @@ class AsyncTaskPool {
63
90
  }
64
91
  this.activeTasks.push(task);
65
92
  const handleError = (task, error) => {
93
+ // The task was already rejected by abortTask; don't reject again or schedule a retry.
94
+ if (task.signal?.aborted) {
95
+ return;
96
+ }
66
97
  if (task.retry >= this.config.retryCount) {
67
98
  task.reject(error);
68
99
  }
69
100
  else {
70
- if (task.signal?.aborted) {
71
- task.reject(error);
72
- return;
73
- }
74
101
  const retryDelay = this.retryDelay(task);
75
102
  task.retry++;
76
- setTimeout(() => {
103
+ task.retryTimeout = setTimeout(() => {
77
104
  this.queue.push(task);
78
105
  this.processPool(pool);
79
106
  }, retryDelay);
@@ -115,6 +115,50 @@ describe('asyncTaskPool', () => {
115
115
  await pool.settle('test');
116
116
  expect(result).toEqual([1, 2, 3, 4]);
117
117
  });
118
+ describe('abort signal', () => {
119
+ it('rejects a queued task when the signal aborts, without ever running it', async () => {
120
+ const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
121
+ const controller = new AbortController();
122
+ const queuedSpy = vi.fn(() => okAsync('queued'));
123
+ // Occupy the single slot with a slow task so the next call has to queue.
124
+ const active = pool.call(() => fromPromise(delay(50).then(() => 'active'), toError));
125
+ const queued = pool.call(queuedSpy, { signal: controller.signal });
126
+ controller.abort();
127
+ const queuedResult = await queued;
128
+ expect(queuedResult.isErr()).toBe(true);
129
+ expect(queuedSpy).not.toHaveBeenCalled();
130
+ expect((await active).isOk()).toBe(true);
131
+ });
132
+ it('rejects the in-flight active task when the signal aborts', async () => {
133
+ const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
134
+ const controller = new AbortController();
135
+ const active = pool.call(() => fromPromise(delay(10_000).then(() => 'done'), toError), {
136
+ signal: controller.signal,
137
+ });
138
+ controller.abort();
139
+ expect((await active).isErr()).toBe(true);
140
+ });
141
+ it('frees the slot for later tasks after an abort', async () => {
142
+ const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
143
+ const controller = new AbortController();
144
+ const aborted = pool.call(() => fromPromise(delay(10_000).then(() => 'never'), toError), {
145
+ signal: controller.signal,
146
+ });
147
+ controller.abort();
148
+ await aborted;
149
+ const next = await pool.call(() => okAsync('next'));
150
+ expect(next).toEqual(ok('next'));
151
+ });
152
+ it('rejects immediately when called with an already-aborted signal', async () => {
153
+ const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
154
+ const controller = new AbortController();
155
+ controller.abort();
156
+ const spy = vi.fn(() => okAsync('x'));
157
+ const result = await pool.call(spy, { signal: controller.signal });
158
+ expect(result.isErr()).toBe(true);
159
+ expect(spy).not.toHaveBeenCalled();
160
+ });
161
+ });
118
162
  it('should settle tasks that was created by chain reaction', async () => {
119
163
  const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
120
164
  const tasks = [
@@ -1,6 +1,7 @@
1
1
  import type { StorageAdapter } from '@novasamatech/storage-adapter';
2
2
  import type { IdentityAdapter, IdentityRepository } from './types.js';
3
- export declare function createIdentityRepository({ adapter, storage, }: {
3
+ export declare function createIdentityRepository({ adapter, storage, initialEmissionTimeoutMs, }: {
4
4
  adapter: IdentityAdapter;
5
5
  storage: StorageAdapter;
6
+ initialEmissionTimeoutMs?: number;
6
7
  }): IdentityRepository;
@@ -1,7 +1,67 @@
1
1
  import { Result, ResultAsync, err, ok, okAsync } from 'neverthrow';
2
+ import { defer, distinctUntilChanged, filter, finalize, map, merge, shareReplay, takeUntil, tap, timer } from 'rxjs';
2
3
  import { toError } from '../helpers/utils.js';
3
- export function createIdentityRepository({ adapter, storage, }) {
4
- const cachedRequester = createCachedIdentityRequester(storage, accountId => `identity_${accountId}`);
4
+ const WATCH_IDENTITY_INITIAL_TIMEOUT_MS = 15_000;
5
+ function getCacheKey(accountId) {
6
+ return `identity_${accountId}`;
7
+ }
8
+ function parseIdentity(raw) {
9
+ if (!raw)
10
+ return null;
11
+ try {
12
+ return JSON.parse(raw);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ // Identity values are produced by `decodeRawIdentity` only, so key order is
19
+ // deterministic and JSON.stringify is a safe structural equality probe.
20
+ function identitiesEqual(a, b) {
21
+ if (a === b)
22
+ return true;
23
+ if (a === null || b === null)
24
+ return false;
25
+ return JSON.stringify(a) === JSON.stringify(b);
26
+ }
27
+ function readCachedIdentity(storage, accountId) {
28
+ return defer(() => storage.read(getCacheKey(accountId)).match(parseIdentity, () => null));
29
+ }
30
+ export function createIdentityRepository({ adapter, storage, initialEmissionTimeoutMs = WATCH_IDENTITY_INITIAL_TIMEOUT_MS, }) {
31
+ const cachedRequester = createCachedIdentityRequester(storage, getCacheKey);
32
+ // Per-account de-dup: concurrent watchIdentity(acc) calls share one chain
33
+ // subscription via the shared stream built below. The entry clears itself —
34
+ // see the `finalize` in `buildWatch` — so the map can't accumulate dead
35
+ // streams across distinct accounts.
36
+ const watchCache = new Map();
37
+ function buildWatch(accountId) {
38
+ const live$ = adapter.watchIdentity(accountId).pipe(distinctUntilChanged(identitiesEqual), tap(identity => {
39
+ if (identity === null)
40
+ return;
41
+ // Best-effort write-through; failures must not surface on the read.
42
+ void storage.write(getCacheKey(accountId), JSON.stringify(identity));
43
+ }), shareReplay({ bufferSize: 1, refCount: true }));
44
+ // Seed from cache so warm consumers paint a value before the first chain
45
+ // block. Only a non-null seed is emitted: a cold cache must fall through to
46
+ // the live read / fallback rather than surface a premature `null` (that
47
+ // also keeps the fallback timer below reachable). `shareReplay` keeps the
48
+ // single storage read from being duplicated by the fallback's `takeUntil`.
49
+ const seed$ = readCachedIdentity(storage, accountId).pipe(filter((identity) => identity !== null), takeUntil(live$), shareReplay({ bufferSize: 1, refCount: true }));
50
+ // Cold-cache + silent-chain safety net: emit `null` so the UI doesn't hang.
51
+ const fallback$ = timer(initialEmissionTimeoutMs).pipe(takeUntil(merge(seed$, live$)), map(() => null));
52
+ const stream = merge(seed$, live$, fallback$).pipe(distinctUntilChanged(identitiesEqual),
53
+ // refCount tears down the chain subscription when the last subscriber
54
+ // leaves; `finalize` then drops the map entry so a later watch rebuilds
55
+ // a fresh stream instead of reusing a dead one. The shared `shareReplay`
56
+ // guarantees this fires once, on the final unsubscribe — not per caller.
57
+ // Guard on identity: a late re-subscribe to an already torn-down stream
58
+ // must not evict a newer entry that another caller built for this account.
59
+ finalize(() => {
60
+ if (watchCache.get(accountId) === stream)
61
+ watchCache.delete(accountId);
62
+ }), shareReplay({ bufferSize: 1, refCount: true }));
63
+ return stream;
64
+ }
5
65
  return {
6
66
  getIdentity(accountId) {
7
67
  return cachedRequester([accountId], adapter.readIdentities).map(map => map[accountId] ?? null);
@@ -9,6 +69,16 @@ export function createIdentityRepository({ adapter, storage, }) {
9
69
  getIdentities(accounts) {
10
70
  return cachedRequester(accounts, adapter.readIdentities);
11
71
  },
72
+ // Adapter errors propagate as-is; callers must attach an error handler
73
+ // and re-subscribe to recover (no automatic retry).
74
+ watchIdentity(accountId) {
75
+ const existing = watchCache.get(accountId);
76
+ if (existing)
77
+ return existing;
78
+ const stream = buildWatch(accountId);
79
+ watchCache.set(accountId, stream);
80
+ return stream;
81
+ },
12
82
  };
13
83
  }
14
84
  function createCachedIdentityRequester(storage, getKey) {
@@ -1,15 +1,36 @@
1
1
  import { errAsync, fromPromise, ok } from 'neverthrow';
2
2
  import { AccountId } from 'polkadot-api';
3
+ import { defer, map, throwError } from 'rxjs';
3
4
  import { toError } from '../helpers/utils.js';
4
5
  import { zipWith } from '../helpers/zipWith.js';
6
+ function decodeRawIdentity(accountId, raw, textDecoder) {
7
+ if (!raw)
8
+ return null;
9
+ const credibility = raw.credibility.type === 'Lite'
10
+ ? { type: 'Lite' }
11
+ : {
12
+ type: 'Person',
13
+ alias: raw.credibility.value.alias,
14
+ lastUpdate: raw.credibility.value.last_update.toString(),
15
+ };
16
+ return {
17
+ accountId,
18
+ fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null,
19
+ liteUsername: textDecoder.decode(raw.lite_username),
20
+ credibility,
21
+ };
22
+ }
5
23
  export function createIdentityRpcAdapter(lazyClient) {
6
24
  const accCodec = AccountId();
25
+ const textDecoder = new TextDecoder();
26
+ function getConsumersStorage() {
27
+ const client = lazyClient.getClient();
28
+ const unsafeApi = client.getUnsafeApi();
29
+ return unsafeApi.query.Resources.Consumers;
30
+ }
7
31
  return {
8
32
  readIdentities(accounts) {
9
- const textDecoder = new TextDecoder();
10
- const client = lazyClient.getClient();
11
- const unsafeApi = client.getUnsafeApi();
12
- const method = unsafeApi.query.Resources.Consumers;
33
+ const method = getConsumersStorage();
13
34
  if (!method) {
14
35
  return errAsync(new Error('Method Resources.Consumers not found'));
15
36
  }
@@ -18,36 +39,24 @@ export function createIdentityRpcAdapter(lazyClient) {
18
39
  if (!results) {
19
40
  return ok({});
20
41
  }
21
- return ok(Object.fromEntries(zipWith([accounts, results], x => x).map(([accountId, typedRaw]) => {
22
- if (!typedRaw) {
23
- return [accountId, null];
24
- }
25
- // Runtime metadata may expose fields in snake_case (V1) or
26
- // camelCase (V2 multi-device). Read defensively. The .papi
27
- // descriptor only types snake_case, so widen here.
28
- const raw = typedRaw;
29
- const fullUsername = raw.full_username ?? raw.fullUsername;
30
- const liteUsername = raw.lite_username ?? raw.liteUsername;
31
- const credibility = raw.credibility.type == 'Lite'
32
- ? {
33
- type: 'Lite',
34
- }
35
- : {
36
- type: 'Person',
37
- alias: raw.credibility.value.alias,
38
- lastUpdate: (raw.credibility.value.last_update ??
39
- raw.credibility.value.lastUpdate).toString(),
40
- };
41
- return [
42
- accountId,
43
- {
44
- accountId: accountId,
45
- fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null,
46
- liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '',
47
- credibility,
48
- },
49
- ];
50
- })));
42
+ return ok(Object.fromEntries(zipWith([accounts, results], x => x).map(([accountId, typedRaw]) => [
43
+ accountId,
44
+ decodeRawIdentity(accountId, typedRaw, textDecoder),
45
+ ])));
46
+ });
47
+ },
48
+ watchIdentity(accountId) {
49
+ // `defer` so client resolution and key decoding run on subscribe and any
50
+ // failure surfaces as a stream error, not a synchronous throw at the call
51
+ // site (the consumer only attaches its error handler via `.subscribe`).
52
+ return defer(() => {
53
+ const method = getConsumersStorage();
54
+ if (!method) {
55
+ return throwError(() => new Error('Method Resources.Consumers not found'));
56
+ }
57
+ return method
58
+ .watchValue(accCodec.dec(accountId))
59
+ .pipe(map(emission => decodeRawIdentity(accountId, emission.value, textDecoder)));
51
60
  });
52
61
  },
53
62
  };
@@ -1,4 +1,5 @@
1
1
  import type { ResultAsync } from 'neverthrow';
2
+ import type { Observable } from 'rxjs';
2
3
  export type Credibility = {
3
4
  type: 'Lite';
4
5
  } | {
@@ -14,8 +15,10 @@ export type Identity = {
14
15
  };
15
16
  export type IdentityAdapter = {
16
17
  readIdentities(accounts: string[]): ResultAsync<Record<string, Identity | null>, Error>;
18
+ watchIdentity(accountId: string): Observable<Identity | null>;
17
19
  };
18
20
  export type IdentityRepository = {
19
21
  getIdentity(accountId: string): ResultAsync<Identity | null, Error>;
20
22
  getIdentities(accounts: string[]): ResultAsync<Record<string, Identity | null>, Error>;
23
+ watchIdentity(accountId: string): Observable<Identity | null>;
21
24
  };
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export { createPappAdapter } from './papp.js';
4
4
  export type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js';
5
5
  export type { PairingStatus } from './sso/auth/types.js';
6
6
  export type { DeviceIdentityForPairing } from './sso/auth/v2/service.js';
7
+ export type { AllowanceErrorReason, AllowanceService } from './sso/allowance/index.js';
8
+ export { AllowanceError } from './sso/allowance/index.js';
7
9
  export type { UserSession } from './sso/sessionManager/userSession.js';
8
10
  export type { StoredUserSession } from './sso/userSessionRepository.js';
9
11
  export type { Identity } from './identity/types.js';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { SS_PASEO_STABLE_STAGE_ENDPOINTS, SS_PREVIEW_STAGE_ENDPOINTS, SS_STABLE_STAGE_ENDPOINTS } from './constants.js';
2
2
  export { createPappAdapter } from './papp.js';
3
+ export { AllowanceError } from './sso/allowance/index.js';
package/dist/papp.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { LazyClient, StatementStoreAdapter } from '@novasamatech/statement-store';
2
2
  import type { StorageAdapter } from '@novasamatech/storage-adapter';
3
3
  import type { IdentityAdapter, IdentityRepository } from './identity/types.js';
4
+ import type { AllowanceService } from './sso/allowance/index.js';
4
5
  import type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js';
5
6
  import type { DeviceIdentityForPairing } from './sso/auth/v2/service.js';
6
7
  import type { SsoSessionManager } from './sso/sessionManager/impl.js';
@@ -10,6 +11,7 @@ export type PappAdapter = {
10
11
  sessions: SsoSessionManager;
11
12
  secrets: UserSecretRepository;
12
13
  identity: IdentityRepository;
14
+ allowance: AllowanceService;
13
15
  };
14
16
  type Adapters = {
15
17
  statementStore: StatementStoreAdapter;
package/dist/papp.js CHANGED
@@ -4,6 +4,7 @@ import { getWsProvider } from 'polkadot-api/ws';
4
4
  import { SS_STABLE_STAGE_ENDPOINTS } from './constants.js';
5
5
  import { createIdentityRepository } from './identity/impl.js';
6
6
  import { createIdentityRpcAdapter } from './identity/rpcAdapter.js';
7
+ import { createAllowanceRepository, createAllowanceService } from './sso/allowance/index.js';
7
8
  import { createAuth } from './sso/auth/impl.js';
8
9
  import { createDeviceIdentityStore } from './sso/deviceIdentityStore.js';
9
10
  import { createSsoSessionManager } from './sso/sessionManager/impl.js';
@@ -17,7 +18,15 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
17
18
  const storage = adapters?.storage ?? createLocalStorageAdapter(appId);
18
19
  const ssoSessionRepository = createUserSessionRepository(storage);
19
20
  const userSecretRepository = createUserSecretRepository(appId, storage);
21
+ const allowanceRepository = createAllowanceRepository(appId, storage);
20
22
  const deviceIdentityStore = createDeviceIdentityStore(appId, storage);
23
+ const sessions = createSsoSessionManager({
24
+ storage,
25
+ statementStore,
26
+ ssoSessionRepository,
27
+ userSecretRepository,
28
+ allowanceRepository,
29
+ });
21
30
  return {
22
31
  sso: createAuth({
23
32
  hostMetadata,
@@ -28,8 +37,9 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
28
37
  userSecretRepository,
29
38
  onAuthSuccess,
30
39
  }),
31
- sessions: createSsoSessionManager({ storage, statementStore, ssoSessionRepository, userSecretRepository }),
40
+ sessions,
32
41
  secrets: userSecretRepository,
33
42
  identity: createIdentityRepository({ adapter: identities, storage }),
43
+ allowance: createAllowanceService({ sessions: sessions.sessions, repository: allowanceRepository }),
34
44
  };
35
45
  }
@@ -0,0 +1,24 @@
1
+ import type { StatementProver } from '@novasamatech/statement-store';
2
+ import { ResultAsync } from 'neverthrow';
3
+ import type { PolkadotSigner } from 'polkadot-api/signer';
4
+ import type { Callback } from '../../types.js';
5
+ import type { UserSession } from '../sessionManager/userSession.js';
6
+ import type { AllowanceRepository } from './repository.js';
7
+ export type AllowanceErrorReason = 'NoSession' | 'Rejected' | 'NotAvailable' | 'UnexpectedResponse';
8
+ export declare class AllowanceError extends Error {
9
+ readonly reason: AllowanceErrorReason;
10
+ constructor(reason: AllowanceErrorReason, message?: string);
11
+ }
12
+ type SessionsView = {
13
+ read(): UserSession[];
14
+ subscribe(callback: Callback<UserSession[]>): VoidFunction;
15
+ };
16
+ export type AllowanceService = {
17
+ getBulletinSigner(sessionId: string, productId: string): ResultAsync<PolkadotSigner, AllowanceError>;
18
+ getStatementStoreProver(sessionId: string, productId: string): ResultAsync<StatementProver, AllowanceError>;
19
+ };
20
+ export declare function createAllowanceService({ sessions, repository, }: {
21
+ sessions: SessionsView;
22
+ repository: AllowanceRepository;
23
+ }): AllowanceService;
24
+ export {};
@@ -0,0 +1,70 @@
1
+ import { createSr25519Prover, deriveSlotAccountPublicKey, ensureSubstrateSlotSr25519Ready, signSlotAccountSecret, } from '@novasamatech/statement-store';
2
+ import { ResultAsync, errAsync, okAsync } from 'neverthrow';
3
+ import { getPolkadotSigner } from 'polkadot-api/signer';
4
+ export class AllowanceError extends Error {
5
+ reason;
6
+ constructor(reason, message) {
7
+ super(message ?? reason);
8
+ this.reason = reason;
9
+ this.name = 'AllowanceError';
10
+ }
11
+ }
12
+ export function createAllowanceService({ sessions, repository, }) {
13
+ const fetchKey = (sessionId, productId, resource) => {
14
+ return repository
15
+ .read(sessionId, productId, resource)
16
+ .mapErr(e => new AllowanceError('UnexpectedResponse', e.message))
17
+ .andThen(cached => (cached ? okAsync(cached) : requestFromMobile(sessionId, productId, resource)));
18
+ };
19
+ const requestFromMobile = (sessionId, productId, resource) => {
20
+ const session = sessions.read().find(s => s.id === sessionId);
21
+ if (!session) {
22
+ return errAsync(new AllowanceError('NoSession', `No active session ${sessionId}`));
23
+ }
24
+ return session
25
+ .requestResourceAllocation({
26
+ callingProductId: productId,
27
+ resources: [toApResource(resource)],
28
+ onExisting: 'Ignore',
29
+ })
30
+ .mapErr(e => new AllowanceError('UnexpectedResponse', e.message))
31
+ .andThen(outcomes => {
32
+ const outcome = outcomes[0];
33
+ if (!outcome) {
34
+ return errAsync(new AllowanceError('UnexpectedResponse', 'Empty allocation response'));
35
+ }
36
+ if (outcome.tag === 'Rejected') {
37
+ return errAsync(new AllowanceError('Rejected', `Allowance request rejected for ${resource}`));
38
+ }
39
+ if (outcome.tag === 'NotAvailable') {
40
+ return errAsync(new AllowanceError('NotAvailable', `Allowance not available for ${resource}`));
41
+ }
42
+ const allocated = outcome.value;
43
+ const expectedTag = resource === 'bulletin' ? 'BulletInAllowance' : 'StatementStoreAllowance';
44
+ if (allocated.tag !== expectedTag) {
45
+ return errAsync(new AllowanceError('UnexpectedResponse', `Expected ${expectedTag}, got ${allocated.tag}`));
46
+ }
47
+ const slotAccountKey = allocated.value.slotAccountKey;
48
+ return repository
49
+ .write(sessionId, productId, resource, slotAccountKey)
50
+ .mapErr(e => new AllowanceError('UnexpectedResponse', e.message))
51
+ .map(() => slotAccountKey);
52
+ });
53
+ };
54
+ return {
55
+ getBulletinSigner(sessionId, productId) {
56
+ return fetchKey(sessionId, productId, 'bulletin').andThen(secret => ResultAsync.fromPromise(ensureSubstrateSlotSr25519Ready().then(() => getPolkadotSigner(deriveSlotAccountPublicKey(secret), 'Sr25519', input => signSlotAccountSecret(secret, input))), e => new AllowanceError('UnexpectedResponse', e instanceof Error ? e.message : String(e))));
57
+ },
58
+ getStatementStoreProver(sessionId, productId) {
59
+ return fetchKey(sessionId, productId, 'statementStore').map(secret => createSr25519Prover(secret));
60
+ },
61
+ };
62
+ }
63
+ function toApResource(resource) {
64
+ switch (resource) {
65
+ case 'bulletin':
66
+ return { tag: 'BulletInAllowance', value: undefined };
67
+ case 'statementStore':
68
+ return { tag: 'StatementStoreAllowance', value: undefined };
69
+ }
70
+ }
@@ -0,0 +1,4 @@
1
+ export type { AllowanceRepository, AllowanceResourceKind } from './repository.js';
2
+ export { createAllowanceRepository } from './repository.js';
3
+ export type { AllowanceErrorReason, AllowanceService } from './impl.js';
4
+ export { AllowanceError, createAllowanceService } from './impl.js';
@@ -0,0 +1,2 @@
1
+ export { createAllowanceRepository } from './repository.js';
2
+ export { AllowanceError, createAllowanceService } from './impl.js';
@@ -0,0 +1,9 @@
1
+ import type { StorageAdapter } from '@novasamatech/storage-adapter';
2
+ import type { ResultAsync } from 'neverthrow';
3
+ export type AllowanceResourceKind = 'bulletin' | 'statementStore';
4
+ export type AllowanceRepository = ReturnType<typeof createAllowanceRepository>;
5
+ export declare function createAllowanceRepository(salt: string, storage: StorageAdapter): {
6
+ read(sessionId: string, productId: string, resource: AllowanceResourceKind): ResultAsync<Uint8Array | null, Error>;
7
+ write(sessionId: string, productId: string, resource: AllowanceResourceKind, slotAccountKey: Uint8Array): ResultAsync<void, Error>;
8
+ clearSession(sessionId: string): ResultAsync<void, Error>;
9
+ };
@@ -0,0 +1,60 @@
1
+ import { gcm } from '@noble/ciphers/aes.js';
2
+ import { blake2b } from '@noble/hashes/blake2.js';
3
+ import { fromThrowable } from 'neverthrow';
4
+ import { fromHex, toHex } from 'polkadot-api/utils';
5
+ import { Bytes, Enum, Struct, Vector, _void, str } from 'scale-ts';
6
+ import { stringToBytes } from '../../crypto.js';
7
+ import { toError } from '../../helpers/utils.js';
8
+ const AllowanceResourceKindCodec = Enum({
9
+ bulletin: _void,
10
+ statementStore: _void,
11
+ });
12
+ const StoredAllowanceEntryCodec = Struct({
13
+ productId: str,
14
+ resource: AllowanceResourceKindCodec,
15
+ slotAccountKey: Bytes(),
16
+ });
17
+ const StoredAllowancesCodec = Vector(StoredAllowanceEntryCodec);
18
+ export function createAllowanceRepository(salt, storage) {
19
+ const baseKey = 'AllowanceKeys';
20
+ const encode = fromThrowable(StoredAllowancesCodec.enc, toError);
21
+ const decode = fromThrowable((value) => (value ? StoredAllowancesCodec.dec(value) : []), toError);
22
+ const encrypt = fromThrowable((value) => {
23
+ const aes = getAes(salt);
24
+ return toHex(aes.encrypt(value));
25
+ }, toError);
26
+ const decrypt = fromThrowable((value) => {
27
+ if (value === null)
28
+ return null;
29
+ const aes = getAes(salt);
30
+ return aes.decrypt(fromHex(value));
31
+ }, toError);
32
+ const readAll = (sessionId) => storage.read(createKey(baseKey, sessionId)).andThen(decrypt).andThen(decode);
33
+ const writeAll = (sessionId, entries) => encode(entries)
34
+ .andThen(encrypt)
35
+ .asyncAndThen(value => storage.write(createKey(baseKey, sessionId), value));
36
+ return {
37
+ read(sessionId, productId, resource) {
38
+ return readAll(sessionId).map(entries => {
39
+ const entry = entries.find(e => e.productId === productId && e.resource.tag === resource);
40
+ return entry ? entry.slotAccountKey : null;
41
+ });
42
+ },
43
+ write(sessionId, productId, resource, slotAccountKey) {
44
+ return readAll(sessionId).andThen(entries => {
45
+ const next = [
46
+ ...entries.filter(e => !(e.productId === productId && e.resource.tag === resource)),
47
+ { productId, resource: { tag: resource, value: undefined }, slotAccountKey },
48
+ ];
49
+ return writeAll(sessionId, next);
50
+ });
51
+ },
52
+ clearSession(sessionId) {
53
+ return storage.clear(createKey(baseKey, sessionId));
54
+ },
55
+ };
56
+ }
57
+ const createKey = (key, context) => `${key}_${context}`;
58
+ function getAes(salt) {
59
+ return gcm(blake2b(stringToBytes(salt), { dkLen: 16 }), blake2b(stringToBytes('nonce'), { dkLen: 32 }));
60
+ }
@@ -215,6 +215,9 @@ export type EncryptedHandshakeResponseV2Value = {
215
215
  * matches the peer's wire format directly.
216
216
  */
217
217
  export declare const EncryptedHandshakeResponseV2: import("scale-ts").Codec<{
218
+ tag: "Failed";
219
+ value: string;
220
+ } | {
218
221
  tag: "Pending";
219
222
  value: {
220
223
  tag: "AllowanceAllocation";
@@ -228,9 +231,6 @@ export declare const EncryptedHandshakeResponseV2: import("scale-ts").Codec<{
228
231
  identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
229
232
  deviceEncPubKey: Uint8Array<ArrayBufferLike>;
230
233
  };
231
- } | {
232
- tag: "Failed";
233
- value: string;
234
234
  }>;
235
235
  export declare const HandshakeResponseV2: import("scale-ts").Codec<{
236
236
  encrypted: Uint8Array<ArrayBufferLike>;
@@ -1,6 +1,7 @@
1
1
  import type { StatementStoreAdapter } from '@novasamatech/statement-store';
2
2
  import type { StorageAdapter } from '@novasamatech/storage-adapter';
3
3
  import type { Callback } from '../../types.js';
4
+ import type { AllowanceRepository } from '../allowance/index.js';
4
5
  import type { UserSecretRepository } from '../userSecretRepository.js';
5
6
  import type { StoredUserSession, UserSessionRepository } from '../userSessionRepository.js';
6
7
  import type { UserSession } from './userSession.js';
@@ -10,8 +11,9 @@ type Params = {
10
11
  statementStore: StatementStoreAdapter;
11
12
  ssoSessionRepository: UserSessionRepository;
12
13
  userSecretRepository: UserSecretRepository;
14
+ allowanceRepository: AllowanceRepository;
13
15
  };
14
- export declare function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, statementStore, storage, }: Params): {
16
+ export declare function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, statementStore, storage, }: Params): {
15
17
  sessions: {
16
18
  read: () => UserSession[];
17
19
  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, statementStore, storage, }) {
7
+ export function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, statementStore, storage, }) {
8
8
  const localSessions = createState({});
9
9
  const sessionUnsubscribes = new Map();
10
10
  const releaseSession = (id) => {
@@ -77,7 +77,8 @@ export function createSsoSessionManager({ ssoSessionRepository, userSecretReposi
77
77
  return session
78
78
  .sendDisconnectMessage()
79
79
  .andThen(() => disconnect(userSession))
80
- .andThen(() => userSecretRepository.clear(userSession.id));
80
+ .andThen(() => userSecretRepository.clear(userSession.id))
81
+ .andThen(() => allowanceRepository.clearSession(userSession.id));
81
82
  },
82
83
  dispose() {
83
84
  for (const session of Object.values(localSessions.read())) {
@@ -73,15 +73,15 @@ export declare const RemoteMessageCodec: import("scale-ts").Codec<{
73
73
  resources: ({
74
74
  tag: "StatementStoreAllowance";
75
75
  value: undefined;
76
- } | {
77
- tag: "BulletInAllowance";
78
- value: undefined;
79
76
  } | {
80
77
  tag: "SmartContractAllowance";
81
78
  value: number;
82
79
  } | {
83
80
  tag: "AutoSigning";
84
81
  value: undefined;
82
+ } | {
83
+ tag: "BulletInAllowance";
84
+ value: undefined;
85
85
  })[];
86
86
  onExisting: "Ignore" | "Increase";
87
87
  };
@@ -99,11 +99,6 @@ export declare const RemoteMessageCodec: import("scale-ts").Codec<{
99
99
  value: {
100
100
  slotAccountKey: Uint8Array<ArrayBufferLike>;
101
101
  };
102
- } | {
103
- tag: "BulletInAllowance";
104
- value: {
105
- slotAccountKey: Uint8Array<ArrayBufferLike>;
106
- };
107
102
  } | {
108
103
  tag: "SmartContractAllowance";
109
104
  value: undefined;
@@ -113,6 +108,11 @@ export declare const RemoteMessageCodec: import("scale-ts").Codec<{
113
108
  productDerivationSecret: string;
114
109
  productRootPrivateKey: Uint8Array<ArrayBufferLike>;
115
110
  };
111
+ } | {
112
+ tag: "BulletInAllowance";
113
+ value: {
114
+ slotAccountKey: Uint8Array<ArrayBufferLike>;
115
+ };
116
116
  };
117
117
  } | {
118
118
  tag: "NotAvailable";
@@ -3,15 +3,15 @@ export type ApAllocatableResource = CodecType<typeof ApAllocatableResourceCodec>
3
3
  export declare const ApAllocatableResourceCodec: import("scale-ts").Codec<{
4
4
  tag: "StatementStoreAllowance";
5
5
  value: undefined;
6
- } | {
7
- tag: "BulletInAllowance";
8
- value: undefined;
9
6
  } | {
10
7
  tag: "SmartContractAllowance";
11
8
  value: number;
12
9
  } | {
13
10
  tag: "AutoSigning";
14
11
  value: undefined;
12
+ } | {
13
+ tag: "BulletInAllowance";
14
+ value: undefined;
15
15
  }>;
16
16
  export type ApAllocatedResource = CodecType<typeof ApAllocatedResourceCodec>;
17
17
  export declare const ApAllocatedResourceCodec: import("scale-ts").Codec<{
@@ -19,11 +19,6 @@ export declare const ApAllocatedResourceCodec: import("scale-ts").Codec<{
19
19
  value: {
20
20
  slotAccountKey: Uint8Array<ArrayBufferLike>;
21
21
  };
22
- } | {
23
- tag: "BulletInAllowance";
24
- value: {
25
- slotAccountKey: Uint8Array<ArrayBufferLike>;
26
- };
27
22
  } | {
28
23
  tag: "SmartContractAllowance";
29
24
  value: undefined;
@@ -33,6 +28,11 @@ export declare const ApAllocatedResourceCodec: import("scale-ts").Codec<{
33
28
  productDerivationSecret: string;
34
29
  productRootPrivateKey: Uint8Array<ArrayBufferLike>;
35
30
  };
31
+ } | {
32
+ tag: "BulletInAllowance";
33
+ value: {
34
+ slotAccountKey: Uint8Array<ArrayBufferLike>;
35
+ };
36
36
  }>;
37
37
  export type ApAllocationOutcome = CodecType<typeof ApAllocationOutcomeCodec>;
38
38
  export declare const ApAllocationOutcomeCodec: import("scale-ts").Codec<{
@@ -45,11 +45,6 @@ export declare const ApAllocationOutcomeCodec: import("scale-ts").Codec<{
45
45
  value: {
46
46
  slotAccountKey: Uint8Array<ArrayBufferLike>;
47
47
  };
48
- } | {
49
- tag: "BulletInAllowance";
50
- value: {
51
- slotAccountKey: Uint8Array<ArrayBufferLike>;
52
- };
53
48
  } | {
54
49
  tag: "SmartContractAllowance";
55
50
  value: undefined;
@@ -59,6 +54,11 @@ export declare const ApAllocationOutcomeCodec: import("scale-ts").Codec<{
59
54
  productDerivationSecret: string;
60
55
  productRootPrivateKey: Uint8Array<ArrayBufferLike>;
61
56
  };
57
+ } | {
58
+ tag: "BulletInAllowance";
59
+ value: {
60
+ slotAccountKey: Uint8Array<ArrayBufferLike>;
61
+ };
62
62
  };
63
63
  } | {
64
64
  tag: "NotAvailable";
@@ -71,15 +71,15 @@ export declare const ResourceAllocationRequestCodec: import("scale-ts").Codec<{
71
71
  resources: ({
72
72
  tag: "StatementStoreAllowance";
73
73
  value: undefined;
74
- } | {
75
- tag: "BulletInAllowance";
76
- value: undefined;
77
74
  } | {
78
75
  tag: "SmartContractAllowance";
79
76
  value: number;
80
77
  } | {
81
78
  tag: "AutoSigning";
82
79
  value: undefined;
80
+ } | {
81
+ tag: "BulletInAllowance";
82
+ value: undefined;
83
83
  })[];
84
84
  onExisting: "Ignore" | "Increase";
85
85
  }>;
@@ -96,11 +96,6 @@ export declare const ResourceAllocationResponseCodec: import("scale-ts").Codec<{
96
96
  value: {
97
97
  slotAccountKey: Uint8Array<ArrayBufferLike>;
98
98
  };
99
- } | {
100
- tag: "BulletInAllowance";
101
- value: {
102
- slotAccountKey: Uint8Array<ArrayBufferLike>;
103
- };
104
99
  } | {
105
100
  tag: "SmartContractAllowance";
106
101
  value: undefined;
@@ -110,6 +105,11 @@ export declare const ResourceAllocationResponseCodec: import("scale-ts").Codec<{
110
105
  productDerivationSecret: string;
111
106
  productRootPrivateKey: Uint8Array<ArrayBufferLike>;
112
107
  };
108
+ } | {
109
+ tag: "BulletInAllowance";
110
+ value: {
111
+ slotAccountKey: Uint8Array<ArrayBufferLike>;
112
+ };
113
113
  };
114
114
  } | {
115
115
  tag: "NotAvailable";
@@ -11,6 +11,7 @@ import type { ApAllocationOutcome, ResourceAllocationRequest } from './scale/res
11
11
  import type { SigningPayloadRequest, SigningPayloadResponseData, SigningRawRequest } from './scale/signing.js';
12
12
  export type UserSession = StoredUserSession & {
13
13
  sendDisconnectMessage(): ResultAsync<void, Error>;
14
+ abortPendingRequests(): ResultAsync<void, Error>;
14
15
  signPayload(payload: SigningPayloadRequest): ResultAsync<SigningPayloadResponseData, Error>;
15
16
  signRaw(payload: SigningRawRequest): ResultAsync<SigningPayloadResponseData, Error>;
16
17
  createTransaction(payload: CreateTransactionRequest): ResultAsync<Uint8Array, Error>;
@@ -13,6 +13,8 @@ import { RemoteMessageCodec } from './scale/remoteMessage.js';
13
13
  // payload is for an SDK version the mobile app doesn't support yet. After
14
14
  // this timeout the queue task fails, freeing the pool for the next request.
15
15
  const QUEUE_TASK_TIMEOUT_MS = 180_000;
16
+ // Mobile SSO statements allow 256 KiB total; keep headroom for statement/session overhead.
17
+ const MAX_SSO_REQUEST_SIZE = 254 * 1024;
16
18
  function withQueueTimeout(resultAsync, label) {
17
19
  const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(err(new Error(`${label} timed out — queue freed`))), QUEUE_TASK_TIMEOUT_MS));
18
20
  return ResultAsync.fromPromise(Promise.race([resultAsync, timeoutPromise]), toError).andThen(r => r);
@@ -64,12 +66,20 @@ function withHostActionTrace(result, messageId, sessionId) {
64
66
  }
65
67
  export function createUserSession({ userSession, statementStore, encryption, storage, prover, }) {
66
68
  const requestQueue = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
69
+ // Shared abort handle for everything currently on the request queue.
70
+ // abortPendingRequests() fires it to drop the in-flight task plus anything
71
+ // queued behind it, then swaps in a fresh controller so later requests aren't
72
+ // pre-aborted.
73
+ let requestAbort = new AbortController();
74
+ // Enqueue against the live abort signal so abortPendingRequests() can drop every pending task.
75
+ const enqueue = (fn) => requestQueue.call(fn, { signal: requestAbort.signal });
67
76
  const session = createSession({
68
77
  localAccount: userSession.localAccount,
69
78
  remoteAccount: userSession.remoteAccount,
70
79
  statementStore,
71
80
  encryption,
72
81
  prover,
82
+ maxRequestSize: MAX_SSO_REQUEST_SIZE,
73
83
  });
74
84
  const processedMessages = fieldListView({
75
85
  storage,
@@ -80,7 +90,7 @@ export function createUserSession({ userSession, statementStore, encryption, sto
80
90
  return {
81
91
  ...userSession,
82
92
  signPayload(payload) {
83
- return requestQueue.call(() => {
93
+ return enqueue(() => {
84
94
  const messageId = nanoid();
85
95
  const data = enumValue('v1', enumValue('SignRequest', enumValue('Payload', payload)));
86
96
  emitHostAction(messageId, actionKindFromMessageData(data), userSession.id);
@@ -106,7 +116,7 @@ export function createUserSession({ userSession, statementStore, encryption, sto
106
116
  });
107
117
  },
108
118
  signRaw(payload) {
109
- return requestQueue.call(() => {
119
+ return enqueue(() => {
110
120
  const messageId = nanoid();
111
121
  const data = enumValue('v1', enumValue('SignRequest', enumValue('Raw', payload)));
112
122
  emitHostAction(messageId, actionKindFromMessageData(data), userSession.id);
@@ -132,7 +142,7 @@ export function createUserSession({ userSession, statementStore, encryption, sto
132
142
  });
133
143
  },
134
144
  createTransaction(payload) {
135
- return requestQueue.call(() => {
145
+ return enqueue(() => {
136
146
  const messageId = nanoid();
137
147
  const request = session.request(RemoteMessageCodec, {
138
148
  messageId,
@@ -159,7 +169,7 @@ export function createUserSession({ userSession, statementStore, encryption, sto
159
169
  });
160
170
  },
161
171
  sendDisconnectMessage() {
162
- return requestQueue.call(() => session
172
+ return enqueue(() => session
163
173
  .submitRequestMessage(RemoteMessageCodec, {
164
174
  messageId: nanoid(),
165
175
  data: enumValue('v1', enumValue('Disconnected', undefined)),
@@ -167,7 +177,7 @@ export function createUserSession({ userSession, statementStore, encryption, sto
167
177
  .map(() => undefined));
168
178
  },
169
179
  getRingVrfAlias(productAccountId, productId) {
170
- return requestQueue.call(() => {
180
+ return enqueue(() => {
171
181
  const messageId = nanoid();
172
182
  const data = enumValue('v1', enumValue('RingVrfAliasRequest', {
173
183
  productAccountId,
@@ -188,7 +198,7 @@ export function createUserSession({ userSession, statementStore, encryption, sto
188
198
  });
189
199
  },
190
200
  requestResourceAllocation(request) {
191
- return requestQueue.call(() => {
201
+ return enqueue(() => {
192
202
  const messageId = nanoid();
193
203
  const sendRequest = session.request(RemoteMessageCodec, {
194
204
  messageId,
@@ -268,6 +278,17 @@ export function createUserSession({ userSession, statementStore, encryption, sto
268
278
  });
269
279
  });
270
280
  },
281
+ abortPendingRequests() {
282
+ // Drop the whole request queue: aborting the shared signal rejects the
283
+ // in-flight task and every request queued behind it, freeing the single
284
+ // slot immediately instead of waiting out the per-task 180s timeout. Swap
285
+ // in a fresh controller so subsequent requests aren't pre-aborted.
286
+ requestAbort.abort(new Error('Session request aborted'));
287
+ requestAbort = new AbortController();
288
+ // Then supersede the in-flight on-chain batch with an empty one and reject
289
+ // any session-level response waiters left orphaned by the dropped tasks.
290
+ return session.clearOutgoingStatement();
291
+ },
271
292
  dispose() {
272
293
  return session.dispose();
273
294
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/host-papp",
3
3
  "type": "module",
4
- "version": "0.8.0-0",
4
+ "version": "0.8.0",
5
5
  "description": "Polkadot app integration",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -34,19 +34,18 @@
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.0-0",
38
- "@novasamatech/scale": "0.8.0-0",
39
- "@novasamatech/statement-store": "0.8.0-0",
40
- "@novasamatech/storage-adapter": "0.8.0-0",
37
+ "@novasamatech/host-api": "0.8.0",
38
+ "@novasamatech/scale": "0.8.0",
39
+ "@novasamatech/statement-store": "0.8.0",
40
+ "@novasamatech/storage-adapter": "0.8.0",
41
41
  "@polkadot-api/utils": "^0.4.0",
42
42
  "@polkadot-labs/hdkd-helpers": "^0.0.30",
43
43
  "nanoevents": "9.1.0",
44
- "nanoid": "5.1.9",
44
+ "nanoid": "5.1.11",
45
45
  "neverthrow": "^8.2.0",
46
46
  "polkadot-api": ">=2",
47
47
  "rxjs": "^7.8.2",
48
- "scale-ts": "1.6.1",
49
- "verifiablejs": "1.2.0"
48
+ "scale-ts": "1.6.1"
50
49
  },
51
50
  "publishConfig": {
52
51
  "access": "public"