@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.
- package/README.md +5 -5
- package/dist/account.d.ts +4 -3
- package/dist/account.js +16 -11
- package/dist/account.js.map +1 -1
- package/dist/app-sdk/email-address.d.ts +37 -0
- package/dist/app-sdk/email-address.js +91 -0
- package/dist/app-sdk/email-address.js.map +1 -0
- package/dist/app-sdk/encoding.d.ts +64 -0
- package/dist/app-sdk/encoding.js +245 -0
- package/dist/app-sdk/encoding.js.map +1 -0
- package/dist/app-sdk/entropy-contract.d.ts +33 -0
- package/dist/app-sdk/entropy-contract.js +51 -0
- package/dist/app-sdk/entropy-contract.js.map +1 -0
- package/dist/app-sdk/filename.d.ts +93 -0
- package/dist/app-sdk/filename.js +161 -0
- package/dist/app-sdk/filename.js.map +1 -0
- package/dist/app-sdk/forge-url.d.ts +107 -0
- package/dist/app-sdk/forge-url.js +222 -0
- package/dist/app-sdk/forge-url.js.map +1 -0
- package/dist/app-sdk/ids.d.ts +48 -0
- package/dist/app-sdk/ids.js +65 -0
- package/dist/app-sdk/ids.js.map +1 -0
- package/dist/app-sdk/ingot-access-log.d.ts +29 -0
- package/dist/app-sdk/ingot-access-log.js +49 -0
- package/dist/app-sdk/ingot-access-log.js.map +1 -0
- package/dist/app-sdk/phone.d.ts +37 -0
- package/dist/app-sdk/phone.js +134 -0
- package/dist/app-sdk/phone.js.map +1 -0
- package/dist/app-sdk/platform-domains.d.ts +41 -0
- package/dist/app-sdk/platform-domains.js +82 -0
- package/dist/app-sdk/platform-domains.js.map +1 -0
- package/dist/app-sdk/request-path.d.ts +49 -0
- package/dist/app-sdk/request-path.js +81 -0
- package/dist/app-sdk/request-path.js.map +1 -0
- package/dist/app-sdk/transfer-retry.d.ts +298 -0
- package/dist/app-sdk/transfer-retry.js +535 -0
- package/dist/app-sdk/transfer-retry.js.map +1 -0
- package/dist/app-sdk/transient.d.ts +23 -0
- package/dist/app-sdk/transient.js +29 -0
- package/dist/app-sdk/transient.js.map +1 -0
- package/dist/app-sdk/upload-publication.d.ts +81 -0
- package/dist/app-sdk/upload-publication.js +93 -0
- package/dist/app-sdk/upload-publication.js.map +1 -0
- package/dist/app-sdk/vat-refusal.d.ts +66 -0
- package/dist/app-sdk/vat-refusal.js +69 -0
- package/dist/app-sdk/vat-refusal.js.map +1 -0
- package/dist/billing.d.ts +9 -17
- package/dist/billing.js +14 -17
- package/dist/billing.js.map +1 -1
- package/dist/config.d.ts +33 -1
- package/dist/config.js +23 -17
- package/dist/config.js.map +1 -1
- package/dist/encoding.d.ts +30 -4
- package/dist/encoding.js +89 -83
- package/dist/encoding.js.map +1 -1
- package/dist/entropy.d.ts +25 -4
- package/dist/entropy.js +62 -15
- package/dist/entropy.js.map +1 -1
- package/dist/entropy.test-d.d.ts +40 -0
- package/dist/entropy.test-d.js +12 -0
- package/dist/entropy.test-d.js.map +1 -0
- package/dist/errors.d.ts +105 -21
- package/dist/errors.js +74 -28
- package/dist/errors.js.map +1 -1
- package/dist/folders.js +8 -7
- package/dist/folders.js.map +1 -1
- package/dist/http.d.ts +57 -15
- package/dist/http.js +125 -138
- package/dist/http.js.map +1 -1
- package/dist/identity-dialog.js +4 -30
- package/dist/identity-dialog.js.map +1 -1
- package/dist/identity-input.d.ts +39 -0
- package/dist/identity-input.js +59 -0
- package/dist/identity-input.js.map +1 -0
- package/dist/index.d.ts +8 -7
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/ingots.d.ts +77 -32
- package/dist/ingots.js +152 -136
- package/dist/ingots.js.map +1 -1
- package/dist/mutex.d.ts +1 -2
- package/dist/mutex.js +0 -6
- package/dist/mutex.js.map +1 -1
- package/dist/notify.d.ts +7 -2
- package/dist/notify.js +12 -15
- package/dist/notify.js.map +1 -1
- package/dist/notify.test-d.d.ts +3 -2
- package/dist/notify.test-d.js +1 -1
- package/dist/products/identity.d.ts +5 -0
- package/dist/products/identity.js +40 -35
- package/dist/products/identity.js.map +1 -1
- package/dist/request-path.d.ts +32 -0
- package/dist/request-path.js +35 -0
- package/dist/request-path.js.map +1 -0
- package/dist/sparks.js +16 -15
- package/dist/sparks.js.map +1 -1
- package/dist/tus.d.ts +198 -16
- package/dist/tus.js +816 -327
- package/dist/tus.js.map +1 -1
- package/dist/types.d.ts +183 -23
- package/dist/users.d.ts +12 -5
- package/dist/users.js +27 -19
- package/dist/users.js.map +1 -1
- package/dist/validation.d.ts +20 -5
- package/dist/validation.js +29 -27
- package/dist/validation.js.map +1 -1
- package/dist/vaults.d.ts +34 -83
- package/dist/vaults.js +64 -44
- package/dist/vaults.js.map +1 -1
- package/dist/vaults.test-d.d.ts +22 -0
- package/dist/vaults.test-d.js +14 -0
- package/dist/vaults.test-d.js.map +1 -0
- package/package.json +4 -2
- package/src/account.ts +16 -11
- package/src/app-sdk/email-address.js +94 -0
- package/src/app-sdk/encoding.js +250 -0
- package/src/app-sdk/entropy-contract.js +56 -0
- package/src/app-sdk/filename.js +174 -0
- package/src/app-sdk/forge-url.js +224 -0
- package/src/app-sdk/ids.js +71 -0
- package/src/app-sdk/ingot-access-log.js +51 -0
- package/src/app-sdk/phone.js +139 -0
- package/src/app-sdk/platform-domains.js +99 -0
- package/src/app-sdk/request-path.js +81 -0
- package/src/app-sdk/transfer-retry.js +563 -0
- package/src/app-sdk/transient.js +30 -0
- package/src/app-sdk/upload-publication.js +102 -0
- package/src/app-sdk/vat-refusal.js +74 -0
- package/src/billing.ts +16 -25
- package/src/config.ts +56 -23
- package/src/encoding.ts +91 -103
- package/src/entropy.test-d.ts +47 -0
- package/src/entropy.ts +86 -20
- package/src/errors.ts +135 -38
- package/src/folders.ts +8 -7
- package/src/http.ts +145 -150
- package/src/identity-dialog.tsx +4 -37
- package/src/identity-input.ts +70 -0
- package/src/index.ts +13 -3
- package/src/ingots.ts +225 -180
- package/src/mutex.ts +1 -8
- package/src/notify.test-d.ts +3 -1
- package/src/notify.ts +19 -17
- package/src/products/identity.ts +45 -39
- package/src/request-path.ts +45 -0
- package/src/sparks.ts +16 -13
- package/src/tus.ts +1054 -408
- package/src/types.ts +181 -23
- package/src/users.ts +27 -17
- package/src/validation.ts +30 -24
- package/src/vaults.test-d.ts +29 -0
- package/src/vaults.ts +87 -153
- package/dist/platform-domains.generated.d.ts +0 -2
- package/dist/platform-domains.generated.js +0 -16
- package/dist/platform-domains.generated.js.map +0 -1
- 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:
|
|
18
|
-
* pool usage, and Identity attempts. Drives the subscription /
|
|
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>(
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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<
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
145
|
-
|
|
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 ??
|
|
201
|
+
allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? [...SPARKVAULT_OWNED_HOST_PATTERNS],
|
|
169
202
|
};
|
|
170
203
|
}
|
|
171
204
|
|
|
172
|
-
export function identityUrl(config: ResolvedMobileConfig, path:
|
|
173
|
-
return `${config.identityBaseUrl}
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
92
|
+
let index = 0;
|
|
50
93
|
|
|
51
|
-
while (
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
output += String.fromCharCode(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
(
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
94
|
-
return
|
|
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
|
+
];
|