@sparkvault/sdk-mobile 6.0.2 → 7.0.1

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.
Files changed (156) hide show
  1. package/README.md +5 -5
  2. package/dist/account.d.ts +4 -3
  3. package/dist/account.js +16 -11
  4. package/dist/account.js.map +1 -1
  5. package/dist/app-sdk/email-address.d.ts +37 -0
  6. package/dist/app-sdk/email-address.js +91 -0
  7. package/dist/app-sdk/email-address.js.map +1 -0
  8. package/dist/app-sdk/encoding.d.ts +64 -0
  9. package/dist/app-sdk/encoding.js +245 -0
  10. package/dist/app-sdk/encoding.js.map +1 -0
  11. package/dist/app-sdk/entropy-contract.d.ts +33 -0
  12. package/dist/app-sdk/entropy-contract.js +51 -0
  13. package/dist/app-sdk/entropy-contract.js.map +1 -0
  14. package/dist/app-sdk/filename.d.ts +93 -0
  15. package/dist/app-sdk/filename.js +161 -0
  16. package/dist/app-sdk/filename.js.map +1 -0
  17. package/dist/app-sdk/forge-url.d.ts +107 -0
  18. package/dist/app-sdk/forge-url.js +222 -0
  19. package/dist/app-sdk/forge-url.js.map +1 -0
  20. package/dist/app-sdk/ids.d.ts +48 -0
  21. package/dist/app-sdk/ids.js +65 -0
  22. package/dist/app-sdk/ids.js.map +1 -0
  23. package/dist/app-sdk/ingot-access-log.d.ts +29 -0
  24. package/dist/app-sdk/ingot-access-log.js +49 -0
  25. package/dist/app-sdk/ingot-access-log.js.map +1 -0
  26. package/dist/app-sdk/phone.d.ts +37 -0
  27. package/dist/app-sdk/phone.js +134 -0
  28. package/dist/app-sdk/phone.js.map +1 -0
  29. package/dist/app-sdk/platform-domains.d.ts +41 -0
  30. package/dist/app-sdk/platform-domains.js +82 -0
  31. package/dist/app-sdk/platform-domains.js.map +1 -0
  32. package/dist/app-sdk/request-path.d.ts +49 -0
  33. package/dist/app-sdk/request-path.js +81 -0
  34. package/dist/app-sdk/request-path.js.map +1 -0
  35. package/dist/app-sdk/transfer-retry.d.ts +298 -0
  36. package/dist/app-sdk/transfer-retry.js +535 -0
  37. package/dist/app-sdk/transfer-retry.js.map +1 -0
  38. package/dist/app-sdk/transient.d.ts +23 -0
  39. package/dist/app-sdk/transient.js +29 -0
  40. package/dist/app-sdk/transient.js.map +1 -0
  41. package/dist/app-sdk/upload-publication.d.ts +81 -0
  42. package/dist/app-sdk/upload-publication.js +93 -0
  43. package/dist/app-sdk/upload-publication.js.map +1 -0
  44. package/dist/app-sdk/vat-refusal.d.ts +66 -0
  45. package/dist/app-sdk/vat-refusal.js +69 -0
  46. package/dist/app-sdk/vat-refusal.js.map +1 -0
  47. package/dist/billing.d.ts +9 -17
  48. package/dist/billing.js +14 -17
  49. package/dist/billing.js.map +1 -1
  50. package/dist/config.d.ts +33 -1
  51. package/dist/config.js +23 -17
  52. package/dist/config.js.map +1 -1
  53. package/dist/encoding.d.ts +30 -4
  54. package/dist/encoding.js +89 -83
  55. package/dist/encoding.js.map +1 -1
  56. package/dist/entropy.d.ts +25 -4
  57. package/dist/entropy.js +62 -15
  58. package/dist/entropy.js.map +1 -1
  59. package/dist/entropy.test-d.d.ts +40 -0
  60. package/dist/entropy.test-d.js +12 -0
  61. package/dist/entropy.test-d.js.map +1 -0
  62. package/dist/errors.d.ts +105 -21
  63. package/dist/errors.js +74 -28
  64. package/dist/errors.js.map +1 -1
  65. package/dist/folders.js +8 -7
  66. package/dist/folders.js.map +1 -1
  67. package/dist/http.d.ts +57 -15
  68. package/dist/http.js +125 -138
  69. package/dist/http.js.map +1 -1
  70. package/dist/identity-dialog.js +4 -30
  71. package/dist/identity-dialog.js.map +1 -1
  72. package/dist/identity-input.d.ts +39 -0
  73. package/dist/identity-input.js +59 -0
  74. package/dist/identity-input.js.map +1 -0
  75. package/dist/index.d.ts +8 -7
  76. package/dist/index.js +5 -4
  77. package/dist/index.js.map +1 -1
  78. package/dist/ingots.d.ts +77 -32
  79. package/dist/ingots.js +152 -136
  80. package/dist/ingots.js.map +1 -1
  81. package/dist/mutex.d.ts +1 -2
  82. package/dist/mutex.js +0 -6
  83. package/dist/mutex.js.map +1 -1
  84. package/dist/notify.d.ts +7 -2
  85. package/dist/notify.js +12 -15
  86. package/dist/notify.js.map +1 -1
  87. package/dist/notify.test-d.d.ts +3 -2
  88. package/dist/notify.test-d.js +1 -1
  89. package/dist/products/identity.d.ts +5 -0
  90. package/dist/products/identity.js +40 -35
  91. package/dist/products/identity.js.map +1 -1
  92. package/dist/request-path.d.ts +32 -0
  93. package/dist/request-path.js +35 -0
  94. package/dist/request-path.js.map +1 -0
  95. package/dist/sparks.js +16 -15
  96. package/dist/sparks.js.map +1 -1
  97. package/dist/tus.d.ts +198 -16
  98. package/dist/tus.js +816 -327
  99. package/dist/tus.js.map +1 -1
  100. package/dist/types.d.ts +183 -23
  101. package/dist/users.d.ts +12 -5
  102. package/dist/users.js +27 -19
  103. package/dist/users.js.map +1 -1
  104. package/dist/validation.d.ts +20 -5
  105. package/dist/validation.js +29 -27
  106. package/dist/validation.js.map +1 -1
  107. package/dist/vaults.d.ts +34 -83
  108. package/dist/vaults.js +64 -44
  109. package/dist/vaults.js.map +1 -1
  110. package/dist/vaults.test-d.d.ts +22 -0
  111. package/dist/vaults.test-d.js +14 -0
  112. package/dist/vaults.test-d.js.map +1 -0
  113. package/package.json +4 -2
  114. package/src/account.ts +16 -11
  115. package/src/app-sdk/email-address.js +94 -0
  116. package/src/app-sdk/encoding.js +250 -0
  117. package/src/app-sdk/entropy-contract.js +56 -0
  118. package/src/app-sdk/filename.js +174 -0
  119. package/src/app-sdk/forge-url.js +224 -0
  120. package/src/app-sdk/ids.js +71 -0
  121. package/src/app-sdk/ingot-access-log.js +51 -0
  122. package/src/app-sdk/phone.js +139 -0
  123. package/src/app-sdk/platform-domains.js +99 -0
  124. package/src/app-sdk/request-path.js +81 -0
  125. package/src/app-sdk/transfer-retry.js +563 -0
  126. package/src/app-sdk/transient.js +30 -0
  127. package/src/app-sdk/upload-publication.js +102 -0
  128. package/src/app-sdk/vat-refusal.js +74 -0
  129. package/src/billing.ts +16 -25
  130. package/src/config.ts +56 -23
  131. package/src/encoding.ts +91 -103
  132. package/src/entropy.test-d.ts +47 -0
  133. package/src/entropy.ts +86 -20
  134. package/src/errors.ts +135 -38
  135. package/src/folders.ts +8 -7
  136. package/src/http.ts +145 -150
  137. package/src/identity-dialog.tsx +4 -37
  138. package/src/identity-input.ts +70 -0
  139. package/src/index.ts +13 -3
  140. package/src/ingots.ts +225 -180
  141. package/src/mutex.ts +1 -8
  142. package/src/notify.test-d.ts +3 -1
  143. package/src/notify.ts +19 -17
  144. package/src/products/identity.ts +45 -39
  145. package/src/request-path.ts +45 -0
  146. package/src/sparks.ts +16 -13
  147. package/src/tus.ts +1054 -408
  148. package/src/types.ts +181 -23
  149. package/src/users.ts +27 -17
  150. package/src/validation.ts +30 -24
  151. package/src/vaults.test-d.ts +29 -0
  152. package/src/vaults.ts +87 -153
  153. package/dist/platform-domains.generated.d.ts +0 -2
  154. package/dist/platform-domains.generated.js +0 -16
  155. package/dist/platform-domains.generated.js.map +0 -1
  156. package/src/platform-domains.generated.ts +0 -17
@@ -0,0 +1,102 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Source of truth: packages/app-sdk/src/upload-publication.js
3
+ // Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
4
+
5
+ /**
6
+ * The upload publication contract between Forge and Core's activation.
7
+ *
8
+ * Forge stamps UPLOAD_PUBLICATION_PROTOCOL on every upload session it creates
9
+ * and on the final state it hands Core's activation (POST
10
+ * /v1/vaults/:vault_id/ingots/:ingot_id/activate); activation refuses any
11
+ * other version. The two services deploy separately, so a change to what
12
+ * either side sends or expects moves this number: a session or a finalize
13
+ * from the other side's contract then fails closed ("restart the upload")
14
+ * instead of publishing under a contract the other side does not hold.
15
+ *
16
+ * Version 2: an upload's own metadata (content type, original file name,
17
+ * uploader, content HMAC) travels on its upload token and its session, and
18
+ * activation publishes it from the session; the final state Forge sends
19
+ * carries only the storage facts Forge owns.
20
+ *
21
+ * Isomorphic: no Node imports.
22
+ */
23
+
24
+ export const UPLOAD_PUBLICATION_PROTOCOL = 2;
25
+
26
+ /**
27
+ * An upload's id as both services spell it: `tus_` and the 32 lowercase hex
28
+ * digits of 16 random bytes. An encrypt token that names one (`upload_id`)
29
+ * was staged by Core under it, and Forge keys the upload's session, its chunk
30
+ * prefix and its VAK wrapping by it, so Core finds and settles the upload by
31
+ * the session's id.
32
+ */
33
+ export const UPLOAD_ID_PATTERN = /^tus_[0-9a-f]{32}$/;
34
+
35
+ /**
36
+ * How much of an ISTK names a transfer's charge on the usage ledger: enough to
37
+ * be unique to the token, never the whole bearer credential.
38
+ */
39
+ export const TRANSFER_REFERENCE_ISTK_CHARS = 24;
40
+
41
+ /**
42
+ * The ledger reference of an upload's transfer charge,
43
+ * `forge_ingot_transfer_upload_<first 24 ISTK chars>`: every charge of one
44
+ * upload carries it — Core's activation, Core's charge of an upload that never
45
+ * publishes, Forge's cancel — so whichever lands first is the upload's one
46
+ * ledger row and the others are duplicates.
47
+ *
48
+ * @param {string} istk - The upload's ISTK, as its session carries it
49
+ * @returns {string}
50
+ * @throws {TypeError} When the ISTK is not a string of at least
51
+ * TRANSFER_REFERENCE_ISTK_CHARS characters
52
+ */
53
+ export function uploadTransferReference(istk) {
54
+ if (typeof istk !== 'string' || istk.length < TRANSFER_REFERENCE_ISTK_CHARS) {
55
+ throw new TypeError(
56
+ `An upload's transfer reference is named from its ISTK, a string of at least ${TRANSFER_REFERENCE_ISTK_CHARS} characters`
57
+ );
58
+ }
59
+ return `forge_ingot_transfer_upload_${istk.slice(0, TRANSFER_REFERENCE_ISTK_CHARS)}`;
60
+ }
61
+
62
+ /**
63
+ * The codes Core's activation refuses an upload with, which Forge's refusal
64
+ * map reads (the status is Core's):
65
+ * - UPLOAD_CONTRACT_MISMATCH (409): a finalize or session from another
66
+ * publication contract than Core's, a mixed deployment that converges — the
67
+ * upload is kept and its finalize tried again.
68
+ * - UPLOAD_PROTOCOL_RETIRED (410): a session from a contract Core no longer
69
+ * publishes, which never can be.
70
+ * - UPLOAD_SUPERSEDED (410): a session a later staging of its ingot
71
+ * superseded, which never can be.
72
+ * - UPLOAD_POOL_FULL (402): an upload the owner's storage pool cannot take at
73
+ * publication, which is final.
74
+ */
75
+ export const UPLOAD_REFUSAL_CODES = Object.freeze({
76
+ UPLOAD_CONTRACT_MISMATCH: 'UPLOAD_CONTRACT_MISMATCH',
77
+ UPLOAD_PROTOCOL_RETIRED: 'UPLOAD_PROTOCOL_RETIRED',
78
+ UPLOAD_SUPERSEDED: 'UPLOAD_SUPERSEDED',
79
+ UPLOAD_POOL_FULL: 'UPLOAD_POOL_FULL'
80
+ });
81
+
82
+ /**
83
+ * The refusals of Core's that a new upload of the same file cannot remedy:
84
+ * the owner's storage pool cannot take it (UPLOAD_POOL_FULL), or a later
85
+ * upload of its ingot replaced it (UPLOAD_SUPERSEDED). Any other code Core
86
+ * ends an upload with ends its session alone, which a new upload starts
87
+ * over. Every client reads a terminal answer's code by it
88
+ * (transfer-retry.js): one of these is a refusal it reports in Core's words,
89
+ * any other a gone session.
90
+ */
91
+ export const UPLOAD_FINAL_REFUSAL_CODES = Object.freeze([
92
+ UPLOAD_REFUSAL_CODES.UPLOAD_POOL_FULL,
93
+ UPLOAD_REFUSAL_CODES.UPLOAD_SUPERSEDED
94
+ ]);
95
+
96
+ /**
97
+ * The response header that names the code Core refused an upload with for
98
+ * good: Forge sends it on every terminal answer to a tus request, a HEAD's
99
+ * (which has no body) included, so a client tells an upload the owner's pool
100
+ * cannot take from one a later upload superseded without reading a body.
101
+ */
102
+ export const UPLOAD_REFUSAL_HEADER = 'X-Upload-Refusal';
@@ -0,0 +1,74 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Source of truth: packages/app-sdk/src/vat-refusal.js
3
+ // Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
4
+
5
+ /**
6
+ * Why Core refused a Vault Access Token (VAT): the `details.reason` every VAT
7
+ * refusal carries, and the one reading of it clients share.
8
+ *
9
+ * 401 VAT_INVALID no token, or a malformed, unknown or swept one, or one a
10
+ * since-revoked DVAK minted — unseal again
11
+ * 403 VAT_EXPIRED the token outlived its TTL — unseal again, retry as-is
12
+ * 403 VAT_READ_ONLY a View Only token on a write — no unseal fixes it
13
+ *
14
+ * A 403 with no reason is a token for another vault or account, which no
15
+ * unseal of this vault fixes; a 401 with no reason is the login itself, never
16
+ * the VAT. Browser-safe: no imports.
17
+ */
18
+
19
+ export const VAT_REFUSAL_REASONS = Object.freeze({
20
+ INVALID: 'VAT_INVALID',
21
+ EXPIRED: 'VAT_EXPIRED',
22
+ READ_ONLY: 'VAT_READ_ONLY'
23
+ });
24
+
25
+ /** The status each refusal is answered with. */
26
+ export const VAT_REFUSAL_STATUS = Object.freeze({
27
+ [VAT_REFUSAL_REASONS.INVALID]: 401,
28
+ [VAT_REFUSAL_REASONS.EXPIRED]: 403,
29
+ [VAT_REFUSAL_REASONS.READ_ONLY]: 403
30
+ });
31
+
32
+ /**
33
+ * The VAT refusal a Core error answer names, or null when it is not one.
34
+ *
35
+ * @param {{ statusCode?: number, details?: { reason?: string } }} error - An
36
+ * error as parsed from a Core response (status plus the body's `details`)
37
+ * @returns {string|null} One of VAT_REFUSAL_REASONS, or null
38
+ */
39
+ export function vatRefusalReason(error) {
40
+ const reason = error?.details?.reason;
41
+ return typeof reason === 'string' && Object.hasOwn(VAT_REFUSAL_STATUS, reason) &&
42
+ VAT_REFUSAL_STATUS[reason] === error?.statusCode
43
+ ? reason
44
+ : null;
45
+ }
46
+
47
+ /**
48
+ * Whether a refusal says the token's vault session is over: an invalid or an
49
+ * expired token, which only a new unseal replaces, so a client forgets it. A
50
+ * read-only refusal leaves the session standing (the token still reads), and
51
+ * so does a refusal that names no VAT reason.
52
+ *
53
+ * @param {string|null} reason - What vatRefusalReason returned
54
+ * @returns {boolean}
55
+ */
56
+ export function endsVatSession(reason) {
57
+ return reason === VAT_REFUSAL_REASONS.INVALID || reason === VAT_REFUSAL_REASONS.EXPIRED;
58
+ }
59
+
60
+ /**
61
+ * True when Core refused the request's login, not a Vault Access Token it
62
+ * carried: a 401 bearing Core's session code (AUTHENTICATION_ERROR, the
63
+ * mapped error's `upstreamCode`) that names no VAT refusal. Its token was
64
+ * refused, or its account is unavailable (details.reason ACCOUNT_UNAVAILABLE,
65
+ * ./refusal-reasons.js). A 401 for a credential in the request body (an
66
+ * unseal key) carries Core's credential code (UNAUTHORIZED) and is not one.
67
+ *
68
+ * @param {{ statusCode?: number, upstreamCode?: string|null, details?: { reason?: string } }} error -
69
+ * An error as parsed from a Core response (parseApiError)
70
+ * @returns {boolean}
71
+ */
72
+ export function isRefusedLogin(error) {
73
+ return error?.statusCode === 401 && error?.upstreamCode === 'AUTHENTICATION_ERROR' && vatRefusalReason(error) === null;
74
+ }
package/src/billing.ts CHANGED
@@ -1,10 +1,6 @@
1
1
  import type { MobileHttpClient } from './http.js';
2
- import type { IapPlatform, SparkVaultSubscription } from './types.js';
3
-
4
- /** Short-lived Stripe Customer Portal URL for managing the subscription on web. */
5
- export interface PortalSession {
6
- portal_url: string;
7
- }
2
+ import type { IapPlatform, IapValidation, SparkVaultSubscription } from './types.js';
3
+ import { apiPath } from './request-path.js';
8
4
 
9
5
  export class MobileBillingClient {
10
6
  private readonly http: MobileHttpClient;
@@ -14,38 +10,33 @@ export class MobileBillingClient {
14
10
  }
15
11
 
16
12
  /**
17
- * Current subscription: plan, licensed vs. used seats, capacity blocks, live
18
- * pool usage, and Identity attempts. Drives the subscription / usage view.
13
+ * Current subscription: billing lifecycle, licensed vs. used seats, capacity
14
+ * blocks, live pool usage, and Identity attempts. Drives the subscription /
15
+ * usage view.
19
16
  */
20
17
  async getSubscription(): Promise<SparkVaultSubscription> {
21
- const response = await this.http.get<SparkVaultSubscription>('/billing/subscription');
22
- return response.data;
23
- }
24
-
25
- /**
26
- * Open the Stripe Customer Portal to manage the subscription on web (seat /
27
- * block changes that fixed native SKUs cannot carry route here). The caller
28
- * loads the returned URL in the WebView.
29
- */
30
- async createPortalSession(): Promise<PortalSession> {
31
- const response = await this.http.post<PortalSession>('/billing/portal');
18
+ const response = await this.http.get<SparkVaultSubscription>(apiPath`/billing/subscription`);
32
19
  return response.data;
33
20
  }
34
21
 
35
22
  /**
36
- * Validate a native in-app purchase receipt server-side and apply the
37
- * resulting entitlements. Returns the refreshed subscription so the client
38
- * can update its plan / usage view immediately after purchase.
23
+ * Validate a native in-app purchase server-side and apply the entitlements
24
+ * it buys. `receipt` is the App Store signed transaction (JWS) or the Play
25
+ * purchase token. Answers whether Core provisioned the purchase; read the
26
+ * subscription again with getSubscription() to show the change.
39
27
  */
40
28
  async iapValidate(
41
29
  platform: IapPlatform,
42
30
  productId: string,
43
31
  receipt: string
44
- ): Promise<SparkVaultSubscription> {
45
- const response = await this.http.post<SparkVaultSubscription>('/billing/iap/validate', {
32
+ ): Promise<IapValidation> {
33
+ // Core reads an App Store purchase's signed transaction as `receipt` and a
34
+ // Play purchase's token as `purchase_token`.
35
+ const credential = platform === 'android' ? { purchase_token: receipt } : { receipt };
36
+ const response = await this.http.post<IapValidation>(apiPath`/billing/iap/validate`, {
46
37
  platform,
47
38
  product_id: productId,
48
- receipt,
39
+ ...credential,
49
40
  });
50
41
  return response.data;
51
42
  }
package/src/config.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import { SparkVaultValidationError } from './errors.js';
2
- import {
3
- SPARKVAULT_PLATFORM_SV_HOSTS,
4
- SPARKVAULT_ROOT_DOMAIN,
5
- } from './platform-domains.generated.js';
2
+ import { isResourceIdOf } from './app-sdk/ids.js';
3
+ import { SPARKVAULT_IDENTITY_HOST, SPARKVAULT_OWNED_HOST_PATTERNS } from './app-sdk/platform-domains.js';
6
4
  import type {
7
5
  FetchLike,
8
6
  MobileBackgroundTransfer,
@@ -12,22 +10,10 @@ import type {
12
10
  SparkVaultLogger,
13
11
  TokenStorageAdapter,
14
12
  } from './types.js';
13
+ import { apiPath, type ApiPath } from './request-path.js';
15
14
 
16
15
  const DEFAULT_API_BASE_URL = 'https://api.sparkvault.com/v1';
17
- const DEFAULT_IDENTITY_BASE_URL = 'https://auth.sparkvault.com';
18
-
19
- function escapeRegExp(value: string): string {
20
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
- }
22
-
23
- // Hosts that are allowed to serve backend-issued ingot download URLs, derived
24
- // from the canonical platform domain registry baked in at build time.
25
- const DEFAULT_ALLOWED_DOWNLOAD_HOST_PATTERNS: RegExp[] = [
26
- new RegExp(`(^|\\.)${escapeRegExp(SPARKVAULT_ROOT_DOMAIN)}$`, 'i'),
27
- new RegExp(`(^|\\.)(${SPARKVAULT_PLATFORM_SV_HOSTS.map(escapeRegExp).join('|')})$`, 'i'),
28
- /\.amazonaws\.com$/i,
29
- /\.cloudfront\.net$/i,
30
- ];
16
+ const DEFAULT_IDENTITY_BASE_URL = `https://${SPARKVAULT_IDENTITY_HOST}`;
31
17
 
32
18
  const noopLogger: SparkVaultLogger = {
33
19
  debug: () => undefined,
@@ -45,6 +31,11 @@ export interface SparkVaultMobileConfig {
45
31
  userAgent?: string;
46
32
  timeoutMs?: number;
47
33
  fileTransferTimeoutMs?: number;
34
+ /**
35
+ * Timeout of a tus create POST and DELETE. A HEAD and the receipt (the
36
+ * PATCH at the final offset) wait HEAD_AND_RECEIPT_TIMEOUT_MS instead:
37
+ * Forge answers either only after its own call to Core.
38
+ */
48
39
  tusPostTimeoutMs?: number;
49
40
  /** Stall watchdog per tus chunk PATCH: aborts after this long with NO upload progress (not an absolute deadline). */
50
41
  tusChunkTimeoutMs?: number;
@@ -67,10 +58,36 @@ export interface SparkVaultMobileConfig {
67
58
  * upload, and every upload when this is unset, streams over XHR.
68
59
  */
69
60
  backgroundUploader?: MobileBackgroundUploader;
61
+ /**
62
+ * Whether the device has a network path right now (an app wires it to its
63
+ * OS network signal). Read when an upload request got no answer: while it
64
+ * answers false the upload waits for `onOnline` instead of spending its
65
+ * retries, then asks Forge where the session stands. Only a definite
66
+ * "offline" should answer false; a path that may come up is online. Given
67
+ * together with `onOnline`, or not at all.
68
+ */
69
+ isOnline?: () => boolean;
70
+ /**
71
+ * Subscribe to the network coming back; returns the unsubscribe. The pair
72
+ * of `isOnline`. A wait it ends is otherwise ended only by the upload's
73
+ * `abortSignal`.
74
+ */
75
+ onOnline?: (listener: () => void) => () => void;
70
76
  logger?: Partial<SparkVaultLogger>;
77
+ /**
78
+ * Host allowlist for the backend-issued Forge transfer URLs the SDK sends an
79
+ * ingot's ISTK to (downloads and uploads alike). Defaults to the SparkVault
80
+ * platform's own hosts; override only when pointing at another deployment.
81
+ */
71
82
  allowedDownloadHostPatterns?: RegExp[];
72
83
  }
73
84
 
85
+ /** The device's network signal, as the upload's offline wait reads it (`isOnline` with `onOnline`). */
86
+ export interface MobileConnectivity {
87
+ isOnline(): boolean;
88
+ onOnline(listener: () => void): () => void;
89
+ }
90
+
74
91
  export interface ResolvedMobileConfig {
75
92
  tokenStorage: TokenStorageAdapter;
76
93
  apiBaseUrl: string;
@@ -87,10 +104,23 @@ export interface ResolvedMobileConfig {
87
104
  fileDownloader?: MobileFileDownloader;
88
105
  backgroundTransfer?: MobileBackgroundTransfer;
89
106
  backgroundUploader?: MobileBackgroundUploader;
107
+ connectivity?: MobileConnectivity;
90
108
  logger: SparkVaultLogger;
91
109
  allowedDownloadHostPatterns: RegExp[];
92
110
  }
93
111
 
112
+ /** The `isOnline` / `onOnline` pair as one signal, or none: one without the other cannot wait for the network. */
113
+ function resolveConnectivity(config: SparkVaultMobileConfig): MobileConnectivity | undefined {
114
+ const { isOnline, onOnline } = config;
115
+ if (isOnline === undefined && onOnline === undefined) return undefined;
116
+ if (typeof isOnline !== 'function' || typeof onOnline !== 'function') {
117
+ throw new SparkVaultValidationError('isOnline and onOnline are given together, as functions', {
118
+ field: typeof isOnline === 'function' ? 'onOnline' : 'isOnline',
119
+ });
120
+ }
121
+ return { isOnline, onOnline };
122
+ }
123
+
94
124
  function normalizeBaseUrl(url: string, field: string): string {
95
125
  if (!url || typeof url !== 'string') {
96
126
  throw new SparkVaultValidationError(`${field} must be a non-empty string`, { field });
@@ -141,8 +171,10 @@ export function resolveMobileConfig(config: SparkVaultMobileConfig): ResolvedMob
141
171
  });
142
172
  }
143
173
 
144
- if (!identityAccountId.startsWith('acc_')) {
145
- throw new SparkVaultValidationError('identityAccountId must start with "acc_"', {
174
+ // The account id names Identity request paths: the platform's one id
175
+ // grammar keeps it a single path segment.
176
+ if (!isResourceIdOf('account', identityAccountId)) {
177
+ throw new SparkVaultValidationError('identityAccountId must be a SparkVault account id (acc_...)', {
146
178
  field: 'identityAccountId',
147
179
  received: identityAccountId,
148
180
  });
@@ -164,11 +196,12 @@ export function resolveMobileConfig(config: SparkVaultMobileConfig): ResolvedMob
164
196
  fileDownloader: config.fileDownloader,
165
197
  backgroundTransfer: config.backgroundTransfer,
166
198
  backgroundUploader: config.backgroundUploader,
199
+ connectivity: resolveConnectivity(config),
167
200
  logger: { ...noopLogger, ...config.logger },
168
- allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? DEFAULT_ALLOWED_DOWNLOAD_HOST_PATTERNS,
201
+ allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? [...SPARKVAULT_OWNED_HOST_PATTERNS],
169
202
  };
170
203
  }
171
204
 
172
- export function identityUrl(config: ResolvedMobileConfig, path: string): string {
173
- return `${config.identityBaseUrl}/${config.identityAccountId}${path}`;
205
+ export function identityUrl(config: ResolvedMobileConfig, path: ApiPath): string {
206
+ return `${config.identityBaseUrl}${apiPath`/${config.identityAccountId}`}${path}`;
174
207
  }
package/src/encoding.ts CHANGED
@@ -1,7 +1,48 @@
1
- const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
2
- const BASE64_LOOKUP = new Map<string, number>(
3
- Array.from(BASE64_ALPHABET).map((char, index) => [char, index])
4
- );
1
+ /**
2
+ * Base64 and UTF-8 for the mobile SDK.
3
+ *
4
+ * The base64 codec is the platform's one copy, mirrored from
5
+ * `@sparkvault/app-sdk/encoding` into `./app-sdk/encoding.js`; the decoders here
6
+ * raise this SDK's validation error where that codec refuses a text. UTF-8 is
7
+ * converted here rather than with TextEncoder/TextDecoder, which the JavaScript
8
+ * engines of the React Native versions this SDK supports need not provide.
9
+ * Decoding UTF-8 is fatal: bytes that are not UTF-8 are refused, never read as
10
+ * invented characters.
11
+ */
12
+
13
+ import {
14
+ base64ToBytes as decodeBase64,
15
+ base64urlToBytes as decodeBase64url,
16
+ bytesToBase64,
17
+ bytesToBase64url,
18
+ } from './app-sdk/encoding.js';
19
+ import { SparkVaultValidationError } from './errors.js';
20
+
21
+ export { bytesToBase64, bytesToBase64url };
22
+
23
+ /**
24
+ * Decode standard base64.
25
+ * @throws SparkVaultValidationError when the text is not base64
26
+ */
27
+ export function base64ToBytes(value: string): Uint8Array {
28
+ const bytes = decodeBase64(value);
29
+ if (bytes === null) {
30
+ throw new SparkVaultValidationError('Invalid base64 input');
31
+ }
32
+ return bytes;
33
+ }
34
+
35
+ /**
36
+ * Decode base64url.
37
+ * @throws SparkVaultValidationError when the text is not base64url
38
+ */
39
+ export function base64UrlToBytes(value: string): Uint8Array {
40
+ const bytes = decodeBase64url(value);
41
+ if (bytes === null) {
42
+ throw new SparkVaultValidationError('Invalid base64url input');
43
+ }
44
+ return bytes;
45
+ }
5
46
 
6
47
  export function utf8ToBytes(value: string): Uint8Array {
7
48
  const bytes: number[] = [];
@@ -40,121 +81,68 @@ export function utf8ToBytes(value: string): Uint8Array {
40
81
  return new Uint8Array(bytes);
41
82
  }
42
83
 
43
- export function base64EncodeUtf8(value: string): string {
44
- return bytesToBase64(utf8ToBytes(value));
45
- }
46
-
84
+ /**
85
+ * Decode UTF-8 by the WHATWG decoder's rules in fatal mode: a byte that cannot
86
+ * start a sequence, a sequence cut short, an overlong form, a surrogate or a
87
+ * code point past U+10FFFF refuses the whole input.
88
+ * @throws SparkVaultValidationError when the bytes are not UTF-8
89
+ */
47
90
  export function bytesToUtf8(bytes: Uint8Array): string {
48
91
  let output = '';
49
- let i = 0;
92
+ let index = 0;
50
93
 
51
- while (i < bytes.length) {
52
- const byte1 = bytes[i++];
53
- if (byte1 < 0x80) {
54
- output += String.fromCharCode(byte1);
94
+ while (index < bytes.length) {
95
+ const lead = bytes[index++];
96
+ if (lead < 0x80) {
97
+ output += String.fromCharCode(lead);
55
98
  continue;
56
99
  }
57
100
 
58
- if (byte1 >= 0xc0 && byte1 < 0xe0) {
59
- const byte2 = bytes[i++];
60
- output += String.fromCharCode(((byte1 & 0x1f) << 6) | (byte2 & 0x3f));
61
- continue;
101
+ // The first continuation byte's range is what excludes overlong forms,
102
+ // surrogates and code points past U+10FFFF.
103
+ let continuations: number;
104
+ let codePoint: number;
105
+ let lower = 0x80;
106
+ let upper = 0xbf;
107
+ if (lead >= 0xc2 && lead <= 0xdf) {
108
+ continuations = 1;
109
+ codePoint = lead & 0x1f;
110
+ } else if (lead >= 0xe0 && lead <= 0xef) {
111
+ continuations = 2;
112
+ codePoint = lead & 0x0f;
113
+ if (lead === 0xe0) lower = 0xa0;
114
+ if (lead === 0xed) upper = 0x9f;
115
+ } else if (lead >= 0xf0 && lead <= 0xf4) {
116
+ continuations = 3;
117
+ codePoint = lead & 0x07;
118
+ if (lead === 0xf0) lower = 0x90;
119
+ if (lead === 0xf4) upper = 0x8f;
120
+ } else {
121
+ throw new SparkVaultValidationError('Invalid UTF-8 input');
62
122
  }
63
123
 
64
- if (byte1 >= 0xe0 && byte1 < 0xf0) {
65
- const byte2 = bytes[i++];
66
- const byte3 = bytes[i++];
67
- output += String.fromCharCode(
68
- ((byte1 & 0x0f) << 12) |
69
- ((byte2 & 0x3f) << 6) |
70
- (byte3 & 0x3f)
71
- );
72
- continue;
124
+ for (let seen = 0; seen < continuations; seen++) {
125
+ if (index >= bytes.length) {
126
+ throw new SparkVaultValidationError('Invalid UTF-8 input');
127
+ }
128
+ const next = bytes[index++];
129
+ if (next < lower || next > upper) {
130
+ throw new SparkVaultValidationError('Invalid UTF-8 input');
131
+ }
132
+ lower = 0x80;
133
+ upper = 0xbf;
134
+ codePoint = (codePoint << 6) | (next & 0x3f);
73
135
  }
74
-
75
- const byte2 = bytes[i++];
76
- const byte3 = bytes[i++];
77
- const byte4 = bytes[i++];
78
- const codePoint = (
79
- ((byte1 & 0x07) << 18) |
80
- ((byte2 & 0x3f) << 12) |
81
- ((byte3 & 0x3f) << 6) |
82
- (byte4 & 0x3f)
83
- ) - 0x10000;
84
- output += String.fromCharCode(
85
- 0xd800 + (codePoint >> 10),
86
- 0xdc00 + (codePoint & 0x3ff)
87
- );
136
+ output += String.fromCodePoint(codePoint);
88
137
  }
89
138
 
90
139
  return output;
91
140
  }
92
141
 
93
- export function base64UrlToBytes(value: string): Uint8Array {
94
- return base64ToBytes(value);
142
+ export function base64EncodeUtf8(value: string): string {
143
+ return bytesToBase64(utf8ToBytes(value));
95
144
  }
96
145
 
97
146
  export function base64UrlToUtf8(value: string): string {
98
147
  return bytesToUtf8(base64UrlToBytes(value));
99
148
  }
100
-
101
- export function bytesToBase64(bytes: Uint8Array): string {
102
- let output = '';
103
-
104
- for (let i = 0; i < bytes.length; i += 3) {
105
- const byte1 = bytes[i];
106
- const byte2 = bytes[i + 1] ?? 0;
107
- const byte3 = bytes[i + 2] ?? 0;
108
- const triplet = (byte1 << 16) | (byte2 << 8) | byte3;
109
-
110
- output += BASE64_ALPHABET[(triplet >> 18) & 0x3f];
111
- output += BASE64_ALPHABET[(triplet >> 12) & 0x3f];
112
- output += i + 1 < bytes.length ? BASE64_ALPHABET[(triplet >> 6) & 0x3f] : '=';
113
- output += i + 2 < bytes.length ? BASE64_ALPHABET[triplet & 0x3f] : '=';
114
- }
115
-
116
- return output;
117
- }
118
-
119
- export function base64ToBytes(value: string): Uint8Array {
120
- const sanitized = value.replace(/\s/g, '').replace(/-/g, '+').replace(/_/g, '/');
121
- if (sanitized.length % 4 === 1) {
122
- throw new Error('Invalid base64 input');
123
- }
124
-
125
- const padded = sanitized.padEnd(Math.ceil(sanitized.length / 4) * 4, '=');
126
-
127
- // Preallocate the exact output size: 3 bytes per quartet, minus one byte per
128
- // '=' (a '=' in quartet positions 1-2 throws below, so counting every '=' is
129
- // safe). Multi-megabyte upload chunks decode through here, so per-byte array
130
- // growth is not acceptable.
131
- let paddingCount = 0;
132
- for (let i = 0; i < padded.length; i++) {
133
- if (padded[i] === '=') paddingCount++;
134
- }
135
- const bytes = new Uint8Array((padded.length / 4) * 3 - paddingCount);
136
- let outIndex = 0;
137
-
138
- for (let i = 0; i < padded.length; i += 4) {
139
- const c1 = padded[i];
140
- const c2 = padded[i + 1];
141
- const c3 = padded[i + 2];
142
- const c4 = padded[i + 3];
143
-
144
- const v1 = BASE64_LOOKUP.get(c1);
145
- const v2 = BASE64_LOOKUP.get(c2);
146
- const v3 = c3 === '=' ? 0 : BASE64_LOOKUP.get(c3);
147
- const v4 = c4 === '=' ? 0 : BASE64_LOOKUP.get(c4);
148
-
149
- if (v1 === undefined || v2 === undefined || v3 === undefined || v4 === undefined) {
150
- throw new Error('Invalid base64 input');
151
- }
152
-
153
- const triplet = (v1 << 18) | (v2 << 12) | (v3 << 6) | v4;
154
- bytes[outIndex++] = (triplet >> 16) & 0xff;
155
- if (c3 !== '=') bytes[outIndex++] = (triplet >> 8) & 0xff;
156
- if (c4 !== '=') bytes[outIndex++] = triplet & 0xff;
157
- }
158
-
159
- return bytes;
160
- }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Entropy wire-contract assertions — COMPILE-TIME ONLY (see notify.test-d.ts for
3
+ * why these live in `src` under a `.test-d.ts` name).
4
+ *
5
+ * The endpoint answers a `bytes` request with a number array and every other
6
+ * format with a string, so `generate` is typed per format, and a format known
7
+ * only as an EntropyFormat resolves to either shape. The format list
8
+ * itself is pinned to the platform contract at runtime by
9
+ * tests/app-sdk-mirrors.test.ts.
10
+ */
11
+
12
+ import type { EntropyFormat, MobileEntropyClient } from './entropy.js';
13
+
14
+ /** Fails to compile unless `T` resolves to exactly `true`. */
15
+ type Assert<T extends true> = T;
16
+
17
+ /** Invariant type equality: `A` and `B` must be the same type, not merely mutually assignable. */
18
+ type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
19
+ ? true
20
+ : false;
21
+
22
+ /** What `generate` resolves to for a call with `Options`: its first overload that accepts them. */
23
+ type Answer<Options> = MobileEntropyClient['generate'] extends {
24
+ (options: infer First): infer FirstResult;
25
+ (options: infer Second): infer SecondResult;
26
+ (options: infer Third): infer ThirdResult;
27
+ }
28
+ ? Options extends First
29
+ ? Awaited<FirstResult>
30
+ : Options extends Second
31
+ ? Awaited<SecondResult>
32
+ : Options extends Third
33
+ ? Awaited<ThirdResult>
34
+ : never
35
+ : never;
36
+
37
+ export type EntropyWireContract = [
38
+ Assert<Equals<
39
+ EntropyFormat,
40
+ 'hex' | 'base64' | 'base64url' | 'alphanumeric' | 'alphanumeric-mixed' | 'password' | 'numeric' | 'uuid' | 'bytes'
41
+ >>,
42
+ Assert<Equals<Answer<{ bytes: number; format: 'bytes' }>['value'], number[]>>,
43
+ Assert<Equals<Answer<{ bytes: number; format: 'password' }>['value'], string>>,
44
+ Assert<Equals<Answer<{ bytes: number }>['value'], string>>,
45
+ // A format known only as an EntropyFormat still compiles, answering either shape.
46
+ Assert<Equals<Answer<{ bytes: number; format: EntropyFormat }>['value'], string | number[]>>,
47
+ ];