@sparkvault/sdk-mobile 6.0.2 → 7.0.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.
Files changed (148) 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/transient.d.ts +23 -0
  36. package/dist/app-sdk/transient.js +29 -0
  37. package/dist/app-sdk/transient.js.map +1 -0
  38. package/dist/app-sdk/vat-refusal.d.ts +66 -0
  39. package/dist/app-sdk/vat-refusal.js +69 -0
  40. package/dist/app-sdk/vat-refusal.js.map +1 -0
  41. package/dist/billing.d.ts +9 -17
  42. package/dist/billing.js +14 -17
  43. package/dist/billing.js.map +1 -1
  44. package/dist/config.d.ts +7 -1
  45. package/dist/config.js +10 -17
  46. package/dist/config.js.map +1 -1
  47. package/dist/encoding.d.ts +30 -4
  48. package/dist/encoding.js +89 -83
  49. package/dist/encoding.js.map +1 -1
  50. package/dist/entropy.d.ts +25 -4
  51. package/dist/entropy.js +62 -15
  52. package/dist/entropy.js.map +1 -1
  53. package/dist/entropy.test-d.d.ts +40 -0
  54. package/dist/entropy.test-d.js +12 -0
  55. package/dist/entropy.test-d.js.map +1 -0
  56. package/dist/errors.d.ts +67 -17
  57. package/dist/errors.js +70 -27
  58. package/dist/errors.js.map +1 -1
  59. package/dist/folders.js +8 -7
  60. package/dist/folders.js.map +1 -1
  61. package/dist/http.d.ts +57 -15
  62. package/dist/http.js +125 -138
  63. package/dist/http.js.map +1 -1
  64. package/dist/identity-dialog.js +4 -30
  65. package/dist/identity-dialog.js.map +1 -1
  66. package/dist/identity-input.d.ts +39 -0
  67. package/dist/identity-input.js +59 -0
  68. package/dist/identity-input.js.map +1 -0
  69. package/dist/index.d.ts +6 -5
  70. package/dist/index.js +4 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/ingots.d.ts +51 -18
  73. package/dist/ingots.js +132 -122
  74. package/dist/ingots.js.map +1 -1
  75. package/dist/mutex.d.ts +1 -2
  76. package/dist/mutex.js +0 -6
  77. package/dist/mutex.js.map +1 -1
  78. package/dist/notify.d.ts +7 -2
  79. package/dist/notify.js +12 -15
  80. package/dist/notify.js.map +1 -1
  81. package/dist/notify.test-d.d.ts +3 -2
  82. package/dist/notify.test-d.js +1 -1
  83. package/dist/products/identity.d.ts +5 -0
  84. package/dist/products/identity.js +40 -35
  85. package/dist/products/identity.js.map +1 -1
  86. package/dist/request-path.d.ts +32 -0
  87. package/dist/request-path.js +35 -0
  88. package/dist/request-path.js.map +1 -0
  89. package/dist/sparks.js +16 -15
  90. package/dist/sparks.js.map +1 -1
  91. package/dist/tus.d.ts +19 -2
  92. package/dist/tus.js +86 -35
  93. package/dist/tus.js.map +1 -1
  94. package/dist/types.d.ts +167 -15
  95. package/dist/users.d.ts +12 -5
  96. package/dist/users.js +27 -19
  97. package/dist/users.js.map +1 -1
  98. package/dist/validation.d.ts +20 -5
  99. package/dist/validation.js +29 -27
  100. package/dist/validation.js.map +1 -1
  101. package/dist/vaults.d.ts +34 -83
  102. package/dist/vaults.js +64 -44
  103. package/dist/vaults.js.map +1 -1
  104. package/dist/vaults.test-d.d.ts +22 -0
  105. package/dist/vaults.test-d.js +14 -0
  106. package/dist/vaults.test-d.js.map +1 -0
  107. package/package.json +4 -2
  108. package/src/account.ts +16 -11
  109. package/src/app-sdk/email-address.js +94 -0
  110. package/src/app-sdk/encoding.js +250 -0
  111. package/src/app-sdk/entropy-contract.js +56 -0
  112. package/src/app-sdk/filename.js +174 -0
  113. package/src/app-sdk/forge-url.js +224 -0
  114. package/src/app-sdk/ids.js +71 -0
  115. package/src/app-sdk/ingot-access-log.js +51 -0
  116. package/src/app-sdk/phone.js +139 -0
  117. package/src/app-sdk/platform-domains.js +99 -0
  118. package/src/app-sdk/request-path.js +81 -0
  119. package/src/app-sdk/transient.js +30 -0
  120. package/src/app-sdk/vat-refusal.js +74 -0
  121. package/src/billing.ts +16 -25
  122. package/src/config.ts +16 -23
  123. package/src/encoding.ts +91 -103
  124. package/src/entropy.test-d.ts +47 -0
  125. package/src/entropy.ts +86 -20
  126. package/src/errors.ts +93 -33
  127. package/src/folders.ts +8 -7
  128. package/src/http.ts +145 -150
  129. package/src/identity-dialog.tsx +4 -37
  130. package/src/identity-input.ts +70 -0
  131. package/src/index.ts +11 -3
  132. package/src/ingots.ts +173 -159
  133. package/src/mutex.ts +1 -8
  134. package/src/notify.test-d.ts +3 -1
  135. package/src/notify.ts +19 -17
  136. package/src/products/identity.ts +45 -39
  137. package/src/request-path.ts +45 -0
  138. package/src/sparks.ts +16 -13
  139. package/src/tus.ts +90 -37
  140. package/src/types.ts +165 -15
  141. package/src/users.ts +27 -17
  142. package/src/validation.ts +30 -24
  143. package/src/vaults.test-d.ts +29 -0
  144. package/src/vaults.ts +87 -153
  145. package/dist/platform-domains.generated.d.ts +0 -2
  146. package/dist/platform-domains.generated.js +0 -16
  147. package/dist/platform-domains.generated.js.map +0 -1
  148. package/src/platform-domains.generated.ts +0 -17
@@ -0,0 +1,30 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Source of truth: packages/app-sdk/src/transient.js
3
+ // Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
4
+
5
+ /**
6
+ * Whether sending the SAME request again can succeed, read from its HTTP
7
+ * status: the one retry rule every client and every provider adapter applies.
8
+ *
9
+ * A retry rule restated beside every client drifts, and the copy that drifted
10
+ * decides whether a deliverable message is retried or abandoned, or whether a
11
+ * download is tried again. Each caller keeps its provider's own extras on top of
12
+ * this rule (a success body, a dead-token reason, a vendor error name).
13
+ *
14
+ * Browser-safe and import-free: sdk-js bundles it and sdk-mobile mirrors it.
15
+ */
16
+
17
+ /**
18
+ * Is this HTTP status one a retry of the same request can turn into a success?
19
+ *
20
+ * RFC 9110: 408 means the server never received the whole request, 429 that it
21
+ * throttled it, and a 5xx that it failed; every other status is the server's
22
+ * verdict on this exact request. A caller that got no response at all (a network
23
+ * error, a timeout) has no status to pass and decides that case itself.
24
+ *
25
+ * @param {number} status
26
+ * @returns {boolean}
27
+ */
28
+ export function isTransientHttpStatus(status) {
29
+ return status === 408 || status === 429 || status >= 500;
30
+ }
@@ -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,
@@ -68,6 +54,11 @@ export interface SparkVaultMobileConfig {
68
54
  */
69
55
  backgroundUploader?: MobileBackgroundUploader;
70
56
  logger?: Partial<SparkVaultLogger>;
57
+ /**
58
+ * Host allowlist for the backend-issued Forge transfer URLs the SDK sends an
59
+ * ingot's ISTK to (downloads and uploads alike). Defaults to the SparkVault
60
+ * platform's own hosts; override only when pointing at another deployment.
61
+ */
71
62
  allowedDownloadHostPatterns?: RegExp[];
72
63
  }
73
64
 
@@ -141,8 +132,10 @@ export function resolveMobileConfig(config: SparkVaultMobileConfig): ResolvedMob
141
132
  });
142
133
  }
143
134
 
144
- if (!identityAccountId.startsWith('acc_')) {
145
- throw new SparkVaultValidationError('identityAccountId must start with "acc_"', {
135
+ // The account id names Identity request paths: the platform's one id
136
+ // grammar keeps it a single path segment.
137
+ if (!isResourceIdOf('account', identityAccountId)) {
138
+ throw new SparkVaultValidationError('identityAccountId must be a SparkVault account id (acc_...)', {
146
139
  field: 'identityAccountId',
147
140
  received: identityAccountId,
148
141
  });
@@ -165,10 +158,10 @@ export function resolveMobileConfig(config: SparkVaultMobileConfig): ResolvedMob
165
158
  backgroundTransfer: config.backgroundTransfer,
166
159
  backgroundUploader: config.backgroundUploader,
167
160
  logger: { ...noopLogger, ...config.logger },
168
- allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? DEFAULT_ALLOWED_DOWNLOAD_HOST_PATTERNS,
161
+ allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? [...SPARKVAULT_OWNED_HOST_PATTERNS],
169
162
  };
170
163
  }
171
164
 
172
- export function identityUrl(config: ResolvedMobileConfig, path: string): string {
173
- return `${config.identityBaseUrl}/${config.identityAccountId}${path}`;
165
+ export function identityUrl(config: ResolvedMobileConfig, path: ApiPath): string {
166
+ return `${config.identityBaseUrl}${apiPath`/${config.identityAccountId}`}${path}`;
174
167
  }
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
+ ];
package/src/entropy.ts CHANGED
@@ -1,18 +1,34 @@
1
- import { base64ToBytes } from './encoding.js';
1
+ import { base64ToBytes } from './app-sdk/encoding.js';
2
+ import { ENTROPY_FORMATS, MAX_DRAW_BYTES, isEntropyFormat, maxBytesFor } from './app-sdk/entropy-contract.js';
2
3
  import { SparkVaultMobileError, SparkVaultValidationError } from './errors.js';
3
4
  import type { MobileHttpClient } from './http.js';
5
+ import { apiPath } from './request-path.js';
4
6
 
5
- export type EntropyFormat = 'hex' | 'base64' | 'bytes' | 'uuid';
7
+ /** An output format the entropy endpoint serves (the platform's entropy contract). */
8
+ export type EntropyFormat =
9
+ | 'hex'
10
+ | 'base64'
11
+ | 'base64url'
12
+ | 'alphanumeric'
13
+ | 'alphanumeric-mixed'
14
+ | 'password'
15
+ | 'numeric'
16
+ | 'uuid'
17
+ | 'bytes';
6
18
 
7
19
  export interface GenerateEntropyOptions {
20
+ /** Number of random bytes: 1–1024, or 1–512 for alphanumeric, alphanumeric-mixed and password. */
8
21
  bytes: number;
22
+ /** Output format. Defaults to base64. */
9
23
  format?: EntropyFormat;
10
24
  }
11
25
 
12
- export interface EntropyResponse {
13
- value: string;
26
+ /** The endpoint's answer: `value` is a number array for the `bytes` format and a string for every other. */
27
+ export interface EntropyResponse<Value extends string | number[] = string | number[]> {
28
+ value: Value;
14
29
  num_bytes: number;
15
30
  format?: EntropyFormat;
31
+ reference_id?: string;
16
32
  }
17
33
 
18
34
  export class MobileEntropyClient {
@@ -22,35 +38,85 @@ export class MobileEntropyClient {
22
38
  this.http = http;
23
39
  }
24
40
 
41
+ generate(options: GenerateEntropyOptions & { format: 'bytes' }): Promise<EntropyResponse<number[]>>;
42
+ generate(options: GenerateEntropyOptions & { format?: Exclude<EntropyFormat, 'bytes'> }): Promise<EntropyResponse<string>>;
43
+ generate(options: GenerateEntropyOptions): Promise<EntropyResponse>;
25
44
  async generate(options: GenerateEntropyOptions): Promise<EntropyResponse> {
26
- this.validateByteCount(options.bytes);
27
- const response = await this.http.post<EntropyResponse>('/entropy/generate', {
45
+ const format = options.format ?? 'base64';
46
+ validateEntropyRequest(options.bytes, format);
47
+ const response = await this.http.post<EntropyResponse>(apiPath`/entropy/generate`, {
28
48
  num_bytes: options.bytes,
29
- format: options.format ?? 'base64',
49
+ format,
30
50
  });
31
51
  return response.data;
32
52
  }
33
53
 
34
54
  async generateBytes(bytes: number): Promise<Uint8Array> {
35
55
  const response = await this.generate({ bytes, format: 'base64' });
36
- if (typeof response.value !== 'string' || response.value.length === 0) {
37
- throw new SparkVaultMobileError('Entropy endpoint returned no value');
38
- }
39
-
40
- const entropy = base64ToBytes(response.value);
41
- if (entropy.length !== bytes) {
42
- throw new SparkVaultMobileError(`Entropy length mismatch: expected ${bytes}, got ${entropy.length}`);
43
- }
44
-
45
- return entropy;
56
+ return drawnBytes(response.value, bytes);
46
57
  }
47
58
 
48
- private validateByteCount(bytes: number): void {
49
- if (!Number.isInteger(bytes) || bytes < 1 || bytes > 1024) {
50
- throw new SparkVaultValidationError('Entropy byte count must be an integer from 1 to 1024', {
59
+ /**
60
+ * Random bytes for the signed-in client's own working values (a key's id, a
61
+ * secret's server share), from POST /v1/entropy/first-party: answered to an
62
+ * interactive SparkVault session only, never plan- or bandwidth-gated and
63
+ * never recorded as the account's usage. Values an integration asks for come
64
+ * from `generate` / `generateBytes`.
65
+ *
66
+ * @param bytes - 1 to the entropy contract's ceiling (MAX_DRAW_BYTES)
67
+ * @returns exactly `bytes` bytes
68
+ */
69
+ async drawFirstParty(bytes: number): Promise<Uint8Array> {
70
+ if (!Number.isInteger(bytes) || bytes < 1 || bytes > MAX_DRAW_BYTES) {
71
+ throw new SparkVaultValidationError(`Entropy byte count must be an integer from 1 to ${MAX_DRAW_BYTES}`, {
51
72
  field: 'bytes',
52
73
  received: bytes,
53
74
  });
54
75
  }
76
+ const response = await this.http.post<{ value: unknown; num_bytes: number }>(apiPath`/entropy/first-party`, {
77
+ num_bytes: bytes,
78
+ });
79
+ return drawnBytes(response.data.value, bytes);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The bytes a draw answered as base64, refused unless they are exactly the
85
+ * bytes asked for, so a malformed answer fails the operation that asked for it.
86
+ */
87
+ function drawnBytes(value: unknown, bytes: number): Uint8Array {
88
+ if (typeof value !== 'string' || value.length === 0) {
89
+ throw new SparkVaultMobileError('Entropy endpoint returned no value');
90
+ }
91
+
92
+ const entropy = base64ToBytes(value);
93
+ if (entropy === null) {
94
+ throw new SparkVaultMobileError('Entropy endpoint returned a value that is not base64');
95
+ }
96
+ if (entropy.length !== bytes) {
97
+ throw new SparkVaultMobileError(`Entropy length mismatch: expected ${bytes}, got ${entropy.length}`);
98
+ }
99
+
100
+ return entropy;
101
+ }
102
+
103
+ /**
104
+ * Refuse a request the endpoint would refuse, before it is sent: the format must
105
+ * be one the endpoint serves, and the byte count an integer from 1 to that
106
+ * format's ceiling.
107
+ */
108
+ function validateEntropyRequest(bytes: number, format: EntropyFormat): void {
109
+ if (!isEntropyFormat(format)) {
110
+ throw new SparkVaultValidationError(`Entropy format must be one of: ${ENTROPY_FORMATS.join(', ')}`, {
111
+ field: 'format',
112
+ received: format,
113
+ });
114
+ }
115
+ const max = maxBytesFor(format);
116
+ if (!Number.isInteger(bytes) || bytes < 1 || bytes > max) {
117
+ throw new SparkVaultValidationError(`Entropy byte count must be an integer from 1 to ${max} for the ${format} format`, {
118
+ field: 'bytes',
119
+ received: bytes,
120
+ });
55
121
  }
56
122
  }