@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.
- 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/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/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 +7 -1
- package/dist/config.js +10 -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 +67 -17
- package/dist/errors.js +70 -27
- 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 +6 -5
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/ingots.d.ts +51 -18
- package/dist/ingots.js +132 -122
- 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 +19 -2
- package/dist/tus.js +86 -35
- package/dist/tus.js.map +1 -1
- package/dist/types.d.ts +167 -15
- 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/transient.js +30 -0
- package/src/app-sdk/vat-refusal.js +74 -0
- package/src/billing.ts +16 -25
- package/src/config.ts +16 -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 +93 -33
- 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 +11 -3
- package/src/ingots.ts +173 -159
- 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 +90 -37
- package/src/types.ts +165 -15
- 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,224 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source of truth: packages/app-sdk/src/forge-url.js
|
|
3
|
+
// Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Forge transfer URLs: the one reading of the URL Core issues for an ingot
|
|
7
|
+
* transfer, `https://forge.sparkvault.com/{encrypt|decrypt}?istk=…`, and of the
|
|
8
|
+
* session URL a tus upload continues at, whether just created or kept (resumed,
|
|
9
|
+
* probed or terminated).
|
|
10
|
+
*
|
|
11
|
+
* The URL carries the ISTK, a bearer credential for one transfer, and an upload
|
|
12
|
+
* sends the plaintext after it, so every client reads the URL here before a
|
|
13
|
+
* byte or the credential leaves: the Node tus client (forge-tus), both SDKs
|
|
14
|
+
* (sdk-js bundles this module; sdk-mobile carries a generated copy), the
|
|
15
|
+
* console, the Slack app, the HubSpot record tab and the Salesforce record
|
|
16
|
+
* panel (each a generated copy). Core builds the URL with `forgeTransferUrl`.
|
|
17
|
+
*
|
|
18
|
+
* Both readings are string rules over the one shape Core and Forge write, not
|
|
19
|
+
* a URL parser: a React Native host need not provide a WHATWG `URL` (React
|
|
20
|
+
* Native's own resolves relative references and serializes query strings
|
|
21
|
+
* differently), and a shape this strict leaves nothing for a parser to
|
|
22
|
+
* normalize, so a transfer goes to exactly the host and path that was checked.
|
|
23
|
+
*
|
|
24
|
+
* Dependency-free and free of runtime globals.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { SPARKVAULT_FORGE_HOST } from './platform-domains.js';
|
|
28
|
+
|
|
29
|
+
/** Why a transfer URL was refused; each caller raises its own error for the reason. */
|
|
30
|
+
export const FORGE_URL_REFUSAL = Object.freeze({
|
|
31
|
+
/** Not a string, or not an absolute URL written in printable ASCII. */
|
|
32
|
+
INVALID: 'invalid_url',
|
|
33
|
+
/** Not https: the credential and the bytes would cross the network in the clear. */
|
|
34
|
+
NOT_HTTPS: 'not_https',
|
|
35
|
+
/** Userinfo, a port or a fragment, none of which Core ever issues. */
|
|
36
|
+
UNSAFE_PARTS: 'unsafe_parts',
|
|
37
|
+
/** A host the caller does not send transfers to, or one that is not a DNS name. */
|
|
38
|
+
HOST: 'host_not_allowed',
|
|
39
|
+
/** A path other than the transfer operation's own. */
|
|
40
|
+
OPERATION: 'wrong_operation',
|
|
41
|
+
/** No ISTK, more than one, or one that is not base64url. */
|
|
42
|
+
ISTK: 'bad_istk',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** An ISTK is base64url; its length is Core's to choose, so it is not pinned. */
|
|
46
|
+
const ISTK_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
47
|
+
|
|
48
|
+
/** A tus session Forge opens under its endpoint (`tus_<hex>`): one segment, no dot segment or escape. */
|
|
49
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
50
|
+
|
|
51
|
+
/** Printable ASCII with no space: every character a transfer URL is written in. */
|
|
52
|
+
const URL_TEXT = /^[\x21-\x7e]+$/;
|
|
53
|
+
|
|
54
|
+
/** An absolute URL cut at its delimiters: scheme, authority, path, query, fragment. */
|
|
55
|
+
const ABSOLUTE_URL = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(?:\?([^#]*))?(#.*)?$/;
|
|
56
|
+
|
|
57
|
+
/** A DNS name: letter-digit-hyphen labels joined by dots (compared lowercased). */
|
|
58
|
+
const HOSTNAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Forge's own host, the one a platform-issued transfer URL names.
|
|
62
|
+
* @param {string} hostname
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function isForgeHost(hostname) {
|
|
66
|
+
return hostname === SPARKVAULT_FORGE_HOST;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The URL Core issues for a transfer: Forge's operation endpoint carrying the
|
|
71
|
+
* ISTK.
|
|
72
|
+
*
|
|
73
|
+
* @param {'encrypt'|'decrypt'} operation
|
|
74
|
+
* @param {string} istk
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function forgeTransferUrl(operation, istk) {
|
|
78
|
+
return `https://${SPARKVAULT_FORGE_HOST}/${operation}?istk=${encodeURIComponent(istk)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read a Core-issued transfer URL. It must be written in printable ASCII, be
|
|
83
|
+
* https, carry no userinfo, port or fragment, name an allowed host, have the
|
|
84
|
+
* operation as its whole path, and carry exactly one base64url `istk`. Other
|
|
85
|
+
* query parameters (a preview URL's `disposition=inline`) ride along; the query
|
|
86
|
+
* is read the way a form encodes one (`+` is a space, escapes are decoded).
|
|
87
|
+
*
|
|
88
|
+
* A download fetches the URL it read as it was written; an upload is created
|
|
89
|
+
* at `endpoint` with the ISTK in a header.
|
|
90
|
+
*
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @param {Object} options
|
|
93
|
+
* @param {'encrypt'|'decrypt'} options.operation - The transfer the URL must name
|
|
94
|
+
* @param {(hostname: string) => boolean} [options.isAllowedHost] - Forge's own
|
|
95
|
+
* host by default; a published SDK passes its configured allowlist. It is
|
|
96
|
+
* handed the lowercased host name.
|
|
97
|
+
* @returns {{ ok: true, origin: string, endpoint: string, istk: string } | { ok: false, reason: string }}
|
|
98
|
+
* `origin` is the scheme and host; `endpoint` is where a tus upload is created.
|
|
99
|
+
*/
|
|
100
|
+
export function readForgeTransferUrl(value, { operation, isAllowedHost = isForgeHost }) {
|
|
101
|
+
const url = readHttpsUrl(value);
|
|
102
|
+
if (!url.ok) return url;
|
|
103
|
+
if (!isAllowedHost(url.hostname)) return refused(FORGE_URL_REFUSAL.HOST);
|
|
104
|
+
if (url.path !== `/${operation}`) return refused(FORGE_URL_REFUSAL.OPERATION);
|
|
105
|
+
const istks = queryValues(url.query, 'istk');
|
|
106
|
+
if (istks.length !== 1 || !ISTK_PATTERN.test(istks[0])) return refused(FORGE_URL_REFUSAL.ISTK);
|
|
107
|
+
const origin = `https://${url.hostname}`;
|
|
108
|
+
return { ok: true, origin, endpoint: `${origin}/${operation}`, istk: istks[0] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether a value is exactly the URL Core issues for a transfer
|
|
113
|
+
* (`forgeTransferUrl`), with nothing added or re-spelled. A client deployed
|
|
114
|
+
* with Core may demand this; a published SDK, which must accept what a newer
|
|
115
|
+
* Core adds, reads with readForgeTransferUrl.
|
|
116
|
+
*
|
|
117
|
+
* @param {unknown} value
|
|
118
|
+
* @param {'encrypt'|'decrypt'} operation
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function isIssuedForgeTransferUrl(value, operation) {
|
|
122
|
+
const read = readForgeTransferUrl(value, { operation });
|
|
123
|
+
return read.ok && value === forgeTransferUrl(operation, read.istk);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Place the session a tus create answered with (its `Location`) under the
|
|
128
|
+
* endpoint the session was created at. Every later request carries the ISTK
|
|
129
|
+
* and the file's bytes, so the session must be one segment directly under the
|
|
130
|
+
* endpoint's path on the endpoint's own origin: `<endpoint path>/<session>` as
|
|
131
|
+
* Forge sends it, or the same with the endpoint's origin written in front.
|
|
132
|
+
*
|
|
133
|
+
* @param {unknown} location
|
|
134
|
+
* @param {string} endpoint - An `endpoint` readForgeTransferUrl returned
|
|
135
|
+
* @returns {string|null} The session URL, or null when the answer points anywhere else
|
|
136
|
+
*/
|
|
137
|
+
export function resolveForgeSessionUrl(location, endpoint) {
|
|
138
|
+
const base = readHttpsUrl(endpoint);
|
|
139
|
+
if (!base.ok || base.query !== undefined || typeof location !== 'string' || !URL_TEXT.test(location)) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const origin = `https://${base.hostname}`;
|
|
143
|
+
let path = null;
|
|
144
|
+
if (location.startsWith(`${origin}/`)) {
|
|
145
|
+
path = location.slice(origin.length);
|
|
146
|
+
} else if (location.startsWith('/') && !location.startsWith('//')) {
|
|
147
|
+
path = location;
|
|
148
|
+
}
|
|
149
|
+
if (path === null || !path.startsWith(`${base.path}/`)) return null;
|
|
150
|
+
const session = path.slice(base.path.length + 1);
|
|
151
|
+
return SESSION_ID_PATTERN.test(session) ? `${origin}${base.path}/${session}` : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read a tus session URL a client kept to resume an upload: a session Forge
|
|
156
|
+
* opened directly under the operation's endpoint on an allowed host, exactly
|
|
157
|
+
* as resolveForgeSessionUrl placed it when the session was created. A resumed
|
|
158
|
+
* session receives the ISTK and the file's bytes, so it is held to the rule a
|
|
159
|
+
* created one was.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown} value
|
|
162
|
+
* @param {Object} options
|
|
163
|
+
* @param {'encrypt'|'decrypt'} options.operation - The transfer the session belongs to
|
|
164
|
+
* @param {(hostname: string) => boolean} [options.isAllowedHost] - As for readForgeTransferUrl
|
|
165
|
+
* @returns {{ ok: true, url: string } | { ok: false, reason: string }}
|
|
166
|
+
*/
|
|
167
|
+
export function readForgeSessionUrl(value, { operation, isAllowedHost = isForgeHost }) {
|
|
168
|
+
const url = readHttpsUrl(value);
|
|
169
|
+
if (!url.ok) return url;
|
|
170
|
+
if (!isAllowedHost(url.hostname)) return refused(FORGE_URL_REFUSAL.HOST);
|
|
171
|
+
const session = resolveForgeSessionUrl(value, `https://${url.hostname}/${operation}`);
|
|
172
|
+
return session === null ? refused(FORGE_URL_REFUSAL.OPERATION) : { ok: true, url: session };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Cut an https URL into its host name, path and query by string rules alone.
|
|
177
|
+
* @param {unknown} value
|
|
178
|
+
* @returns {{ ok: true, hostname: string, path: string, query: string|undefined } | { ok: false, reason: string }}
|
|
179
|
+
*/
|
|
180
|
+
function readHttpsUrl(value) {
|
|
181
|
+
if (typeof value !== 'string' || !URL_TEXT.test(value)) return refused(FORGE_URL_REFUSAL.INVALID);
|
|
182
|
+
const parts = ABSOLUTE_URL.exec(value);
|
|
183
|
+
if (!parts) return refused(FORGE_URL_REFUSAL.INVALID);
|
|
184
|
+
const [, scheme, authority, path, query, fragment] = parts;
|
|
185
|
+
if (scheme.toLowerCase() !== 'https') return refused(FORGE_URL_REFUSAL.NOT_HTTPS);
|
|
186
|
+
if (authority.includes('@') || /:[0-9]*$/.test(authority) || fragment !== undefined) {
|
|
187
|
+
return refused(FORGE_URL_REFUSAL.UNSAFE_PARTS);
|
|
188
|
+
}
|
|
189
|
+
const hostname = authority.toLowerCase();
|
|
190
|
+
if (!HOSTNAME.test(hostname)) return refused(FORGE_URL_REFUSAL.HOST);
|
|
191
|
+
return { ok: true, hostname, path, query };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every value of one query parameter, names and values read as a form encodes
|
|
196
|
+
* them: `+` is a space and escapes are decoded (text that is not a valid
|
|
197
|
+
* escape sequence stays as written).
|
|
198
|
+
* @param {string|undefined} query
|
|
199
|
+
* @param {string} name
|
|
200
|
+
* @returns {string[]}
|
|
201
|
+
*/
|
|
202
|
+
function queryValues(query, name) {
|
|
203
|
+
const values = [];
|
|
204
|
+
for (const pair of (query ?? '').split('&')) {
|
|
205
|
+
if (!pair) continue;
|
|
206
|
+
const equals = pair.indexOf('=');
|
|
207
|
+
const key = formDecode(equals === -1 ? pair : pair.slice(0, equals));
|
|
208
|
+
if (key === name) values.push(equals === -1 ? '' : formDecode(pair.slice(equals + 1)));
|
|
209
|
+
}
|
|
210
|
+
return values;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function formDecode(text) {
|
|
214
|
+
const spaced = text.replace(/\+/g, ' ');
|
|
215
|
+
try {
|
|
216
|
+
return decodeURIComponent(spaced);
|
|
217
|
+
} catch {
|
|
218
|
+
return spaced;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function refused(reason) {
|
|
223
|
+
return { ok: false, reason };
|
|
224
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source of truth: packages/app-sdk/src/ids.js
|
|
3
|
+
// Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SparkVault resource ids: the one grammar every surface reads an id by before
|
|
7
|
+
* the id names a request path or a stored file.
|
|
8
|
+
*
|
|
9
|
+
* Core mints an id as its kind's prefix, an underscore and a body: the hex of a
|
|
10
|
+
* UUIDv7 (apps/api lib/utils.js buildPrefixedIdFromBytes), or a hex digest for
|
|
11
|
+
* an invitation and an inbox notification. The grammar admits the kind's
|
|
12
|
+
* prefix, '_' and a body of the path-safe alphabet [A-Za-z0-9_-], 128
|
|
13
|
+
* characters in all, so an id that passes is exactly one request-path segment
|
|
14
|
+
* and one file-name component: it can never name a parent path, carry a
|
|
15
|
+
* separator or a dot-segment, or read as an id of another kind.
|
|
16
|
+
*
|
|
17
|
+
* - `isResourceIdOf(kind, value)`: an id of that kind.
|
|
18
|
+
* - `isResourceId(value)`: an id of any kind, for a check that takes several.
|
|
19
|
+
*
|
|
20
|
+
* Browser-safe and import-free: sdk-js bundles it, sdk-mobile mirrors it, and
|
|
21
|
+
* the apps, the console and the mobile app import it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The prefix Core mints each resource kind's id with. */
|
|
25
|
+
export const RESOURCE_ID_PREFIXES = Object.freeze({
|
|
26
|
+
account: 'acc',
|
|
27
|
+
folder: 'fld',
|
|
28
|
+
ingot: 'ing',
|
|
29
|
+
invite: 'inv',
|
|
30
|
+
notification: 'ntf',
|
|
31
|
+
spark: 'spk',
|
|
32
|
+
user: 'usr',
|
|
33
|
+
vault: 'vlt'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** The longest id, prefix included. */
|
|
37
|
+
export const RESOURCE_ID_MAX_LENGTH = 128;
|
|
38
|
+
|
|
39
|
+
const ID_BODY = /^[A-Za-z0-9_-]+$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether a value is an id of the given kind.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} kind - A key of RESOURCE_ID_PREFIXES
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
* @throws {TypeError} for a kind the grammar does not name: a misspelled kind
|
|
48
|
+
* would otherwise refuse every id without saying why
|
|
49
|
+
*/
|
|
50
|
+
export function isResourceIdOf(kind, value) {
|
|
51
|
+
if (!Object.hasOwn(RESOURCE_ID_PREFIXES, kind)) {
|
|
52
|
+
throw new TypeError(`No resource kind '${kind}'`);
|
|
53
|
+
}
|
|
54
|
+
const prefix = `${RESOURCE_ID_PREFIXES[kind]}_`;
|
|
55
|
+
return typeof value === 'string'
|
|
56
|
+
&& value.length <= RESOURCE_ID_MAX_LENGTH
|
|
57
|
+
&& value.startsWith(prefix)
|
|
58
|
+
&& ID_BODY.test(value.slice(prefix.length));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const KINDS = Object.freeze(Object.keys(RESOURCE_ID_PREFIXES));
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a value is an id of any kind.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} value
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isResourceId(value) {
|
|
70
|
+
return KINDS.some((kind) => isResourceIdOf(kind, value));
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source of truth: packages/app-sdk/src/ingot-access-log.js
|
|
3
|
+
// Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ingot access logs: which of an ingot's audit events are accesses, and how one
|
|
7
|
+
* audit entry reads as an access. Both SDKs read
|
|
8
|
+
* GET /v1/vaults/{vault_id}/ingots/{ingot_id}/audit-logs through it: sdk-js
|
|
9
|
+
* bundles this module, and sdk-mobile (which publishes standalone) carries a
|
|
10
|
+
* build-time copy pinned byte-for-byte by its drift test.
|
|
11
|
+
*
|
|
12
|
+
* An entry arrives in the server's public audit shape (AuditLogRepository
|
|
13
|
+
* toPublicFormat, annotated by the audit-log controller's actor resolution):
|
|
14
|
+
* `timestamp`, `event_type`, `actor_id`, `actor_type`, `actor_display`,
|
|
15
|
+
* `ip_address`, `user_agent`, `metadata` (null while encrypted), `encrypted`,
|
|
16
|
+
* `vault_id` and `ingot_id`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The ingot audit events that are accesses, and what each one is: Core records
|
|
21
|
+
* an access to a file as the download that delivered it, a SparkLink open of
|
|
22
|
+
* the file included.
|
|
23
|
+
*/
|
|
24
|
+
const ACCESS_ACTIONS = new Map([
|
|
25
|
+
['ingot_downloaded', 'download'],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** The event types an access-log request asks the server for. */
|
|
29
|
+
export const INGOT_ACCESS_EVENT_TYPES = Object.freeze([...ACCESS_ACTIONS.keys()]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One audit entry as an access. The actor reads as the server resolved it (a
|
|
33
|
+
* member's email, an API key's name), else as the identity a SparkLink visitor
|
|
34
|
+
* gave (kept in the entry's metadata), else as the raw actor id.
|
|
35
|
+
*
|
|
36
|
+
* @param {{ timestamp: number, event_type: string, actor_id?: string | null, actor_display?: string,
|
|
37
|
+
* ip_address?: string | null, user_agent?: string | null, metadata?: { identity?: string | null } | null }} entry
|
|
38
|
+
* @returns {{ timestamp: number, action: 'download', identity: string, ip_address: string,
|
|
39
|
+
* user_agent: string } | null} null for an event that is not an access
|
|
40
|
+
*/
|
|
41
|
+
export function readIngotAccess(entry) {
|
|
42
|
+
const action = ACCESS_ACTIONS.get(entry.event_type);
|
|
43
|
+
if (action === undefined) return null;
|
|
44
|
+
return {
|
|
45
|
+
timestamp: entry.timestamp,
|
|
46
|
+
action,
|
|
47
|
+
identity: entry.actor_display || entry.metadata?.identity || entry.actor_id || 'Unknown',
|
|
48
|
+
ip_address: entry.ip_address ?? '',
|
|
49
|
+
user_agent: entry.user_agent ?? '',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source of truth: packages/app-sdk/src/phone.js
|
|
3
|
+
// Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Phone numbers a person types: how one reads as E.164, and whether a typed
|
|
7
|
+
* identifier is a phone number at all. Pure, with no imports, so a browser or
|
|
8
|
+
* an SDK bundle can carry it: Core reads and checks every phone it is given
|
|
9
|
+
* with it, the console imports it, sdk-js bundles it, and sdk-mobile carries a
|
|
10
|
+
* build-time copy pinned byte-for-byte by its drift test.
|
|
11
|
+
*
|
|
12
|
+
* A '+' ahead of the digits DECLARES the country code, so every digit is kept
|
|
13
|
+
* exactly as given: ten digits after a '+' are a foreign number (+47 Norway,
|
|
14
|
+
* +45 Denmark, +65 Singapore, +354 Iceland …), and reading them as a NANP number
|
|
15
|
+
* would hand an OTP or a bearer link to whoever holds the matching North
|
|
16
|
+
* American line. Only input WITHOUT a '+' may take the NANP shorthand: exactly
|
|
17
|
+
* ten digits, or eleven starting with the '1'. Any other bare digit string
|
|
18
|
+
* declares no country and is refused rather than guessed.
|
|
19
|
+
*
|
|
20
|
+
* A typed number holds ASCII digits, at most one '+' ahead of all of them, and
|
|
21
|
+
* the separators people type: whitespace, parentheses, dots and dashes of any
|
|
22
|
+
* script, and the invisible formatting marks a contacts app wraps a number in.
|
|
23
|
+
* The separators and marks are dropped. Anything else is refused, never
|
|
24
|
+
* dropped: stripping it would fold part of the text into the number and deliver
|
|
25
|
+
* to someone else's line — a letter or a dial-control character (',' and ';'
|
|
26
|
+
* pause, '#' and '*' are keypad tones) carries an extension ("x2", ",89"), a
|
|
27
|
+
* digit of another script ('٧', '7') is a digit the ASCII read would silently
|
|
28
|
+
* lose, and a '+' after a digit declares nothing.
|
|
29
|
+
*
|
|
30
|
+
* An identifier typed where an email address or a phone number is accepted is
|
|
31
|
+
* a phone number only when it holds phone characters only, led by a digit, '+'
|
|
32
|
+
* or '(': an email whose local part starts with a digit ("2024user@gmail.com",
|
|
33
|
+
* "12345678@qq.com") stays an email.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** A '+' before any digit: the caller declared the country code. */
|
|
37
|
+
const DECLARED_COUNTRY_CODE = /^\D*\+/;
|
|
38
|
+
|
|
39
|
+
/** E.164 digits once a country code is declared: 8–15, never a leading 0. */
|
|
40
|
+
const E164_DIGITS = /^[1-9]\d{7,14}$/;
|
|
41
|
+
|
|
42
|
+
/** A canonical E.164 value. */
|
|
43
|
+
const E164 = /^\+[1-9]\d{7,14}$/;
|
|
44
|
+
|
|
45
|
+
/** Invisible formatting marks (bidi embeddings, zero-width characters), in any number. */
|
|
46
|
+
const FORMAT_MARKS = /\p{Cf}/gu;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Typed phone text once its formatting marks are dropped: ASCII digits and
|
|
50
|
+
* separators, with at most one '+', ahead of every digit.
|
|
51
|
+
*/
|
|
52
|
+
const PHONE_TEXT = /^[\s().\p{Pd}]*\+?[\d\s().\p{Pd}]*$/u;
|
|
53
|
+
|
|
54
|
+
/** How phone text starts where an identifier may be either: a digit, '+' or '('. */
|
|
55
|
+
const PHONE_LEAD = /^[+(\d]/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} value
|
|
59
|
+
* @returns {string} The value without its formatting marks
|
|
60
|
+
*/
|
|
61
|
+
function withoutFormatMarks(value) {
|
|
62
|
+
return value.replace(FORMAT_MARKS, '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read a typed phone number as E.164.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} raw
|
|
69
|
+
* @returns {string|null} E.164 with its leading '+', or null when the input
|
|
70
|
+
* names no dialable number
|
|
71
|
+
*/
|
|
72
|
+
export function parsePhone(raw) {
|
|
73
|
+
if (typeof raw !== 'string') return null;
|
|
74
|
+
const text = withoutFormatMarks(raw);
|
|
75
|
+
if (!PHONE_TEXT.test(text)) return null;
|
|
76
|
+
const digits = text.replace(/\D/g, '');
|
|
77
|
+
|
|
78
|
+
if (DECLARED_COUNTRY_CODE.test(text)) {
|
|
79
|
+
return E164_DIGITS.test(digits) ? `+${digits}` : null;
|
|
80
|
+
}
|
|
81
|
+
if (digits.length === 10) return `+1${digits}`;
|
|
82
|
+
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a value is already a canonical E.164 phone number — exactly the
|
|
88
|
+
* values parsePhone returns unchanged.
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} value
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function isE164Phone(value) {
|
|
94
|
+
return typeof value === 'string' && E164.test(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether an identifier typed where either is accepted is an email address or
|
|
99
|
+
* a phone number.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @returns {'email' | 'phone'}
|
|
103
|
+
*/
|
|
104
|
+
export function inferIdentityType(value) {
|
|
105
|
+
if (typeof value !== 'string') return 'email';
|
|
106
|
+
const text = withoutFormatMarks(value).trim();
|
|
107
|
+
return PHONE_LEAD.test(text) && PHONE_TEXT.test(text) ? 'phone' : 'email';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A phone number as a phone field shows it while it is typed: a NANP number as
|
|
112
|
+
* (XXX) XXX-XXXX; a declared country code, or a digit string that is not a NANP
|
|
113
|
+
* number, keeps every digit as given, so formatting never turns one destination
|
|
114
|
+
* into another. Text holding anything but phone characters (a letter, a
|
|
115
|
+
* dial-control character, a digit of another script) is left as typed, so it
|
|
116
|
+
* reaches parsePhone and is refused there instead of being dropped from the
|
|
117
|
+
* field.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} value
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function formatPhoneDisplay(value) {
|
|
123
|
+
const text = withoutFormatMarks(value);
|
|
124
|
+
if (!PHONE_TEXT.test(text)) return value;
|
|
125
|
+
const digits = text.replace(/\D/g, '');
|
|
126
|
+
|
|
127
|
+
if (DECLARED_COUNTRY_CODE.test(text) && !(digits.startsWith('1') && digits.length === 11)) {
|
|
128
|
+
return `+${digits}`;
|
|
129
|
+
}
|
|
130
|
+
if (digits.length > 11 || (digits.length === 11 && !digits.startsWith('1'))) {
|
|
131
|
+
return digits;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const national = digits.length === 11 ? digits.slice(1) : digits;
|
|
135
|
+
if (national.length === 0) return '';
|
|
136
|
+
if (national.length <= 3) return `(${national}`;
|
|
137
|
+
if (national.length <= 6) return `(${national.slice(0, 3)}) ${national.slice(3)}`;
|
|
138
|
+
return `(${national.slice(0, 3)}) ${national.slice(3, 6)}-${national.slice(6, 10)}`;
|
|
139
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source of truth: packages/app-sdk/src/platform-domains.js
|
|
3
|
+
// Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SparkVault-owned domain registry.
|
|
7
|
+
*
|
|
8
|
+
* Keep platform domain decisions centralized: CSP, redirect safety, custom
|
|
9
|
+
* domain reservation, and Identity postMessage trust all depend on the same
|
|
10
|
+
* ownership boundary.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const SPARKVAULT_ROOT_DOMAIN = 'sparkvault.com';
|
|
14
|
+
|
|
15
|
+
/** The person-facing Identity portal host (auth.sv shell + hosted login). */
|
|
16
|
+
export const SPARKVAULT_AUTH_HOST = 'auth.sv';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Identity IdP origin's host: it serves the OIDC ceremonies (/authorize,
|
|
20
|
+
* /token), the SDK sign-in page and the Identity API, issues every Identity
|
|
21
|
+
* token (`iss` is https://{host}/{account_id}), and holds the hub session
|
|
22
|
+
* cookie. Core verifies the tokens it issues and the Identity product mints
|
|
23
|
+
* them, so both name it from here. Never the auth.sv portal host.
|
|
24
|
+
*/
|
|
25
|
+
export const SPARKVAULT_IDENTITY_HOST = 'auth.sparkvault.com';
|
|
26
|
+
|
|
27
|
+
/** The SparkLinks host: shared Sparks, single-use links and the Messaging recipient pages. */
|
|
28
|
+
export const SPARKVAULT_LINKS_HOST = 'x.sv';
|
|
29
|
+
|
|
30
|
+
/** The durable file-share and Secure Upload Portal host. */
|
|
31
|
+
export const SPARKVAULT_FILES_HOST = 'files.sv';
|
|
32
|
+
|
|
33
|
+
export const SPARKVAULT_PLATFORM_SV_HOSTS = Object.freeze([
|
|
34
|
+
SPARKVAULT_AUTH_HOST,
|
|
35
|
+
SPARKVAULT_LINKS_HOST,
|
|
36
|
+
SPARKVAULT_FILES_HOST,
|
|
37
|
+
'file.sv',
|
|
38
|
+
'send.sv',
|
|
39
|
+
'spark.sv',
|
|
40
|
+
'by.sv',
|
|
41
|
+
'at.sv',
|
|
42
|
+
'db.sv'
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** The SparkVault console. */
|
|
46
|
+
export const SPARKVAULT_APP_HOST = 'app.sparkvault.com';
|
|
47
|
+
|
|
48
|
+
/** Forge, the streaming encrypt/decrypt service every ingot transfer goes through. */
|
|
49
|
+
export const SPARKVAULT_FORGE_HOST = 'forge.sparkvault.com';
|
|
50
|
+
|
|
51
|
+
const SPARKVAULT_WEBSOCKET_HOSTS = Object.freeze([
|
|
52
|
+
'ws.sparkvault.com'
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function escapeRegExp(value) {
|
|
56
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The platform's own hosts as patterns over a URL's hostname: the root domain
|
|
61
|
+
* and its subdomains, and each .sv host exactly (isSparkVaultOwnedHostname).
|
|
62
|
+
* Both SDKs trust a backend-issued transfer URL on these hosts by default;
|
|
63
|
+
* their `allowedDownloadHostPatterns` option replaces the list for another
|
|
64
|
+
* deployment.
|
|
65
|
+
*/
|
|
66
|
+
export const SPARKVAULT_OWNED_HOST_PATTERNS = Object.freeze([
|
|
67
|
+
new RegExp(`(^|\\.)${escapeRegExp(SPARKVAULT_ROOT_DOMAIN)}$`, 'i'),
|
|
68
|
+
new RegExp(`^(${SPARKVAULT_PLATFORM_SV_HOSTS.map(escapeRegExp).join('|')})$`, 'i')
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function normalizeHostname(hostname) {
|
|
72
|
+
if (typeof hostname !== 'string') return '';
|
|
73
|
+
return hostname.trim().toLowerCase().replace(/\.$/, '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isSparkVaultRootOrSubdomain(hostname) {
|
|
77
|
+
const normalized = normalizeHostname(hostname);
|
|
78
|
+
return normalized === SPARKVAULT_ROOT_DOMAIN || normalized.endsWith(`.${SPARKVAULT_ROOT_DOMAIN}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isSparkVaultPlatformSvHost(hostname) {
|
|
82
|
+
return SPARKVAULT_PLATFORM_SV_HOSTS.includes(normalizeHostname(hostname));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isSparkVaultOwnedHostname(hostname) {
|
|
86
|
+
return isSparkVaultRootOrSubdomain(hostname) || isSparkVaultPlatformSvHost(hostname);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isSparkVaultReservedDomain(hostname) {
|
|
90
|
+
return isSparkVaultOwnedHostname(hostname);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sparkVaultPlatformSvOrigins() {
|
|
94
|
+
return SPARKVAULT_PLATFORM_SV_HOSTS.map((host) => `https://${host}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function sparkVaultWebSocketOrigins() {
|
|
98
|
+
return SPARKVAULT_WEBSOCKET_HOSTS.map((host) => `wss://${host}`);
|
|
99
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source of truth: packages/app-sdk/src/request-path.js
|
|
3
|
+
// Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Request paths: the one rule every SDK applies before a caller-supplied value
|
|
7
|
+
* becomes part of a SparkVault request URL.
|
|
8
|
+
*
|
|
9
|
+
* A request carries the caller's credentials (a Bearer token, an API key, a
|
|
10
|
+
* Vault Access Token), so a value that smuggles '/' or a dot-segment into the
|
|
11
|
+
* path would re-target that credentialed request at another route. The rules:
|
|
12
|
+
*
|
|
13
|
+
* - `encodePathSegment`: every value interpolated into a path is encoded as
|
|
14
|
+
* exactly one segment. Percent-encoding keeps '/', '?' and '#' inside the
|
|
15
|
+
* segment, and the three values no encoding can keep one segment ('', '.'
|
|
16
|
+
* and '..', which URL resolution drops or collapses) are refused.
|
|
17
|
+
* - `buildRequestPath`: the template loop an SDK's tagged path builder runs,
|
|
18
|
+
* so every interpolated value passes `encodePathSegment` by construction.
|
|
19
|
+
* - `queryString`: the one query encoding, which leaves unset values out.
|
|
20
|
+
*
|
|
21
|
+
* A server-issued id is checked for its shape first, by the one resource-id
|
|
22
|
+
* grammar (ids.js).
|
|
23
|
+
*
|
|
24
|
+
* Browser-safe and dependency-free: sdk-js bundles it, and sdk-mobile (which
|
|
25
|
+
* publishes standalone) carries a build-time copy pinned byte-for-byte by its
|
|
26
|
+
* drift test. Each SDK wraps it in a typed front door that throws its own
|
|
27
|
+
* validation error.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Encode a value as exactly one request-path segment.
|
|
32
|
+
* @param {unknown} value
|
|
33
|
+
* @returns {string | null} the encoded segment, or null when the value cannot
|
|
34
|
+
* be one segment: not a string, empty, '.', '..', or not well-formed UTF-16
|
|
35
|
+
* (a lone surrogate has no percent-encoding).
|
|
36
|
+
*/
|
|
37
|
+
export function encodePathSegment(value) {
|
|
38
|
+
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return encodeURIComponent(value);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Join a tagged template's literals with its values, each value encoded as
|
|
50
|
+
* exactly one path segment.
|
|
51
|
+
* @param {readonly string[]} literals
|
|
52
|
+
* @param {readonly unknown[]} values
|
|
53
|
+
* @returns {string | null} the path, or null when any value cannot be one
|
|
54
|
+
* segment (see `encodePathSegment`).
|
|
55
|
+
*/
|
|
56
|
+
export function buildRequestPath(literals, values) {
|
|
57
|
+
let path = literals[0];
|
|
58
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
59
|
+
const segment = encodePathSegment(values[index]);
|
|
60
|
+
if (segment === null) return null;
|
|
61
|
+
path += segment + literals[index + 1];
|
|
62
|
+
}
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Encode query parameters, leaving out every unset value (`undefined`, `null`
|
|
68
|
+
* or `''`). Other values are sent as their string form, so `0` and `false`
|
|
69
|
+
* are sent.
|
|
70
|
+
* @param {Readonly<Record<string, string | number | boolean | null | undefined>> | undefined} params
|
|
71
|
+
* @returns {string} `?a=1&b=2`, or '' when no value is set.
|
|
72
|
+
*/
|
|
73
|
+
export function queryString(params) {
|
|
74
|
+
const search = new URLSearchParams();
|
|
75
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
76
|
+
if (value === undefined || value === null || value === '') continue;
|
|
77
|
+
search.append(key, String(value));
|
|
78
|
+
}
|
|
79
|
+
const query = search.toString();
|
|
80
|
+
return query ? `?${query}` : '';
|
|
81
|
+
}
|