@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
package/src/tus.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResolvedMobileConfig } from './config.js';
|
|
2
|
-
import {
|
|
2
|
+
import { FORGE_URL_REFUSAL, readForgeSessionUrl, readForgeTransferUrl, resolveForgeSessionUrl } from './app-sdk/forge-url.js';
|
|
3
|
+
import { SPARKVAULT_OWNED_HOST_PATTERNS } from './app-sdk/platform-domains.js';
|
|
3
4
|
import {
|
|
4
5
|
BackgroundTransferError,
|
|
5
6
|
gateErrorFromBody,
|
|
@@ -55,8 +56,8 @@ export interface TusSessionProbe {
|
|
|
55
56
|
export interface TusUploadFromUriOptions {
|
|
56
57
|
fileUri: string;
|
|
57
58
|
fileSize: number;
|
|
59
|
+
/** Names the file in error context and debug logs; Forge never receives it. */
|
|
58
60
|
filename: string;
|
|
59
|
-
contentType: string;
|
|
60
61
|
/**
|
|
61
62
|
* Forge tus endpoint plus ISTK (`forge_url` from the ingot create).
|
|
62
63
|
* Required to create a session; on resume it only supplies the ISTK when
|
|
@@ -101,29 +102,72 @@ export interface TusUploadFromUriOptions {
|
|
|
101
102
|
|
|
102
103
|
const TUS_VERSION = '1.0.0';
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
/** What a refused Forge URL was refused for, in the words this SDK's errors use. */
|
|
106
|
+
const FORGE_URL_REFUSAL_MESSAGES: Readonly<Record<string, string>> = {
|
|
107
|
+
[FORGE_URL_REFUSAL.INVALID]: 'Invalid Forge URL format',
|
|
108
|
+
[FORGE_URL_REFUSAL.NOT_HTTPS]: 'Forge URL must use HTTPS protocol',
|
|
109
|
+
[FORGE_URL_REFUSAL.UNSAFE_PARTS]: 'Forge URL must not carry credentials or a fragment',
|
|
110
|
+
[FORGE_URL_REFUSAL.HOST]: 'Forge URL host is not an allowed transfer host',
|
|
111
|
+
[FORGE_URL_REFUSAL.OPERATION]: 'Forge URL is not an upload endpoint',
|
|
112
|
+
[FORGE_URL_REFUSAL.ISTK]: 'Forge URL must carry exactly one base64url ISTK parameter',
|
|
113
|
+
};
|
|
111
114
|
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Read a Core-issued Forge upload URL with the platform's transfer URL rule
|
|
117
|
+
* (app-sdk/forge-url.js): https, no credentials or fragment, the encrypt
|
|
118
|
+
* endpoint on an allowed host, and one base64url ISTK.
|
|
119
|
+
*
|
|
120
|
+
* @param allowedHostPatterns - Hosts trusted with the upload; the platform's
|
|
121
|
+
* own hosts by default (a configured SDK passes `allowedDownloadHostPatterns`).
|
|
122
|
+
* @throws SparkVaultValidationError when the URL is refused.
|
|
123
|
+
*/
|
|
124
|
+
export function parseForgeUrl(
|
|
125
|
+
forgeUrl: string,
|
|
126
|
+
allowedHostPatterns: readonly RegExp[] = SPARKVAULT_OWNED_HOST_PATTERNS
|
|
127
|
+
): ParsedForgeUrl {
|
|
128
|
+
const read = readForgeTransferUrl(forgeUrl, {
|
|
129
|
+
operation: 'encrypt',
|
|
130
|
+
isAllowedHost: allowsHost(allowedHostPatterns),
|
|
131
|
+
});
|
|
132
|
+
if (!read.ok) {
|
|
133
|
+
throw new SparkVaultValidationError(FORGE_URL_REFUSAL_MESSAGES[read.reason] ?? 'Invalid Forge URL', {
|
|
134
|
+
reason: read.reason,
|
|
135
|
+
});
|
|
114
136
|
}
|
|
137
|
+
return {
|
|
138
|
+
baseUrl: read.origin,
|
|
139
|
+
tusEndpoint: read.endpoint,
|
|
140
|
+
istk: read.istk,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
115
143
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Read a kept tus session URL (the one `onSessionCreated` handed out) with the
|
|
146
|
+
* platform's session URL rule (app-sdk/forge-url.js) before the session is
|
|
147
|
+
* sent anything. Every request to a session carries the ISTK, and a PATCH the
|
|
148
|
+
* file's bytes, so a kept session is held to the rule a created one was: a
|
|
149
|
+
* session directly under the upload endpoint on an allowed host. The one
|
|
150
|
+
* reader for every path that sends a kept session a request: resume, probe
|
|
151
|
+
* and terminate.
|
|
152
|
+
*
|
|
153
|
+
* @throws SparkVaultValidationError when the URL is refused.
|
|
154
|
+
*/
|
|
155
|
+
function readKeptSessionUrl(uploadUrl: string, allowedHostPatterns: readonly RegExp[]): string {
|
|
156
|
+
const session = readForgeSessionUrl(uploadUrl, {
|
|
157
|
+
operation: 'encrypt',
|
|
158
|
+
isAllowedHost: allowsHost(allowedHostPatterns),
|
|
159
|
+
});
|
|
160
|
+
if (!session.ok) {
|
|
161
|
+
throw new SparkVaultValidationError('uploadUrl is not an upload session on an allowed transfer host', {
|
|
162
|
+
reason: session.reason,
|
|
163
|
+
});
|
|
119
164
|
}
|
|
165
|
+
return session.url;
|
|
166
|
+
}
|
|
120
167
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
tusEndpoint: `${baseUrl}${url.pathname}`,
|
|
125
|
-
istk,
|
|
126
|
-
};
|
|
168
|
+
/** The forge-url rule's host check over a configured list of host patterns. */
|
|
169
|
+
export function allowsHost(allowedHostPatterns: readonly RegExp[]): (hostname: string) => boolean {
|
|
170
|
+
return hostname => allowedHostPatterns.some(pattern => pattern.test(hostname));
|
|
127
171
|
}
|
|
128
172
|
|
|
129
173
|
/**
|
|
@@ -287,18 +331,20 @@ type TusSessionSource =
|
|
|
287
331
|
| { mode: 'create'; parsed: ParsedForgeUrl }
|
|
288
332
|
| { mode: 'resume'; uploadUrl: string; istk: string };
|
|
289
333
|
|
|
290
|
-
function resolveSessionSource(options: TusUploadFromUriOptions): TusSessionSource {
|
|
334
|
+
function resolveSessionSource(options: TusUploadFromUriOptions, allowedHostPatterns: readonly RegExp[]): TusSessionSource {
|
|
291
335
|
if (options.resume) {
|
|
292
|
-
const
|
|
336
|
+
const uploadUrl = readKeptSessionUrl(options.resume.uploadUrl, allowedHostPatterns);
|
|
337
|
+
const istk = options.resume.istk
|
|
338
|
+
?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl, allowedHostPatterns).istk : null);
|
|
293
339
|
if (!istk) {
|
|
294
340
|
throw new SparkVaultValidationError('resume.istk or forgeUrl is required to resume a TUS upload session');
|
|
295
341
|
}
|
|
296
|
-
return { mode: 'resume', uploadUrl
|
|
342
|
+
return { mode: 'resume', uploadUrl, istk };
|
|
297
343
|
}
|
|
298
344
|
if (!options.forgeUrl) {
|
|
299
345
|
throw new SparkVaultValidationError('forgeUrl is required to create a TUS upload session');
|
|
300
346
|
}
|
|
301
|
-
return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl) };
|
|
347
|
+
return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl, allowedHostPatterns) };
|
|
302
348
|
}
|
|
303
349
|
|
|
304
350
|
async function createTusUpload(
|
|
@@ -306,12 +352,12 @@ async function createTusUpload(
|
|
|
306
352
|
parsed: ParsedForgeUrl,
|
|
307
353
|
fileSize: number,
|
|
308
354
|
filename: string,
|
|
309
|
-
contentType: string,
|
|
310
355
|
signal?: AbortSignal
|
|
311
356
|
): Promise<{ uploadUrl: string; chunkSize: number }> {
|
|
312
357
|
ensureNotAborted(signal);
|
|
313
358
|
|
|
314
|
-
|
|
359
|
+
// Forge's create contract is these three headers: the ingot's name and type
|
|
360
|
+
// were set by Core when the ingot was created, and Forge publishes Core's.
|
|
315
361
|
const response = await tusRequest(
|
|
316
362
|
config,
|
|
317
363
|
parsed.tusEndpoint,
|
|
@@ -320,7 +366,6 @@ async function createTusUpload(
|
|
|
320
366
|
headers: {
|
|
321
367
|
'Tus-Resumable': TUS_VERSION,
|
|
322
368
|
'Upload-Length': String(fileSize),
|
|
323
|
-
'Upload-Metadata': metadata,
|
|
324
369
|
'X-ISTK': parsed.istk,
|
|
325
370
|
},
|
|
326
371
|
signal,
|
|
@@ -349,17 +394,17 @@ async function createTusUpload(
|
|
|
349
394
|
});
|
|
350
395
|
}
|
|
351
396
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
397
|
+
// Every chunk carries the file's bytes, so the session must live under the
|
|
398
|
+
// endpoint it was created at.
|
|
399
|
+
const uploadUrl = resolveForgeSessionUrl(response.headers.get('Location'), parsed.tusEndpoint);
|
|
400
|
+
if (!uploadUrl) {
|
|
401
|
+
throw new TusUploadError('TUS create response Location header is missing or outside the upload endpoint', {
|
|
355
402
|
filename,
|
|
356
403
|
fileSize,
|
|
357
404
|
phase: 'create',
|
|
358
405
|
});
|
|
359
406
|
}
|
|
360
407
|
|
|
361
|
-
const uploadUrl = location.startsWith('http') ? location : `${parsed.baseUrl}${location}`;
|
|
362
|
-
|
|
363
408
|
// Forge fixes the session's chunk geometry and rejects any non-final chunk
|
|
364
409
|
// that is not exactly the negotiated size, so the chunk size is the server's
|
|
365
410
|
// to dictate via `X-Chunk-Size` (mirrors sdk-js). A client-side guess could
|
|
@@ -792,7 +837,7 @@ export class MobileTusUploader {
|
|
|
792
837
|
throw new SparkVaultValidationError('fileReader adapter is required for URI uploads');
|
|
793
838
|
}
|
|
794
839
|
|
|
795
|
-
const source = resolveSessionSource(options);
|
|
840
|
+
const source = resolveSessionSource(options, this.config.allowedDownloadHostPatterns);
|
|
796
841
|
const istk = source.mode === 'create' ? source.parsed.istk : source.istk;
|
|
797
842
|
const debug = options.debug;
|
|
798
843
|
const cleanUri = options.fileUri.split('#')[0];
|
|
@@ -843,7 +888,6 @@ export class MobileTusUploader {
|
|
|
843
888
|
source.parsed,
|
|
844
889
|
options.fileSize,
|
|
845
890
|
options.filename,
|
|
846
|
-
options.contentType,
|
|
847
891
|
options.abortSignal
|
|
848
892
|
);
|
|
849
893
|
sessionUrl = created.uploadUrl;
|
|
@@ -988,10 +1032,14 @@ export class MobileTusUploader {
|
|
|
988
1032
|
* throws `sessionLost` on a mismatch) to judge. Lets a caller reconcile
|
|
989
1033
|
* sessions on relaunch (a completed one just needs its ingot verified; a
|
|
990
1034
|
* lost one needs a fresh create) before spending an upload slot on it.
|
|
1035
|
+
*
|
|
1036
|
+
* @throws SparkVaultValidationError, before any request, when `uploadUrl`
|
|
1037
|
+
* is not an upload session on an allowed transfer host.
|
|
991
1038
|
*/
|
|
992
1039
|
async probeSession(session: { uploadUrl: string; istk: string }): Promise<TusSessionProbe | null> {
|
|
1040
|
+
const uploadUrl = readKeptSessionUrl(session.uploadUrl, this.config.allowedDownloadHostPatterns);
|
|
993
1041
|
try {
|
|
994
|
-
return await headTusSession(this.config,
|
|
1042
|
+
return await headTusSession(this.config, uploadUrl, session.istk);
|
|
995
1043
|
} catch (err) {
|
|
996
1044
|
if (err instanceof TusUploadError && err.sessionLost) return null;
|
|
997
1045
|
throw err;
|
|
@@ -1021,17 +1069,22 @@ export class MobileTusUploader {
|
|
|
1021
1069
|
* Forge sent) so the caller can retry later. Not bound to any
|
|
1022
1070
|
* abort signal: a terminate is the last thing a caller does with a
|
|
1023
1071
|
* session, never something to cancel.
|
|
1072
|
+
*
|
|
1073
|
+
* @throws SparkVaultValidationError, before any request, when `uploadUrl`
|
|
1074
|
+
* is not an upload session on an allowed transfer host.
|
|
1024
1075
|
*/
|
|
1025
1076
|
async terminateSession(session: { uploadUrl: string; istk: string }): Promise<void> {
|
|
1077
|
+
const uploadUrl = readKeptSessionUrl(session.uploadUrl, this.config.allowedDownloadHostPatterns);
|
|
1078
|
+
|
|
1026
1079
|
// A terminate is the one place the SDK knows the OS job must die with
|
|
1027
1080
|
// the session: a DELETE under a job the engine is still chaining would
|
|
1028
1081
|
// leave it PATCHing a gone session until it fails on its own.
|
|
1029
1082
|
const backgroundTransfer = this.config.backgroundTransfer;
|
|
1030
1083
|
if (backgroundTransfer) {
|
|
1031
|
-
await this.cancelBackgroundTransfer(backgroundTransfer,
|
|
1084
|
+
await this.cancelBackgroundTransfer(backgroundTransfer, uploadUrl);
|
|
1032
1085
|
}
|
|
1033
1086
|
|
|
1034
|
-
const response = await this.requestTerminate(
|
|
1087
|
+
const response = await this.requestTerminate(uploadUrl, session.istk);
|
|
1035
1088
|
|
|
1036
1089
|
if (response.ok || isSessionGone(response.status)) return;
|
|
1037
1090
|
|
package/src/types.ts
CHANGED
|
@@ -5,7 +5,9 @@ export type PoolState = 'ok' | 'notice' | 'warning' | 'exhausted';
|
|
|
5
5
|
export interface PoolStatus {
|
|
6
6
|
used_gb: number;
|
|
7
7
|
limit_gb: number | null;
|
|
8
|
+
/** Used fraction of the pool: 0.85 is 85% full, 1 is full (above 1 when over). */
|
|
8
9
|
pct: number;
|
|
10
|
+
/** The server's severity for the pool, from the canonical thresholds; display keys off it. */
|
|
9
11
|
state: PoolState;
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -137,6 +139,12 @@ export interface RemoveMemberResponse {
|
|
|
137
139
|
user_id: string;
|
|
138
140
|
removed_at: number;
|
|
139
141
|
dvaks_revoked: number;
|
|
142
|
+
/**
|
|
143
|
+
* True when this removal ends the subscription: the member renewed the last
|
|
144
|
+
* license it renews, and it was not already set to end. It ends at the
|
|
145
|
+
* period end.
|
|
146
|
+
*/
|
|
147
|
+
wind_down: boolean;
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
export interface ResendInviteResponse {
|
|
@@ -177,7 +185,7 @@ export interface SparkVaultSubscription {
|
|
|
177
185
|
renewal_date: number | null;
|
|
178
186
|
renewal_amount_usd: string;
|
|
179
187
|
seats: {
|
|
180
|
-
/**
|
|
188
|
+
/** Licenses the subscription renews with — what the next renewal bills. */
|
|
181
189
|
full: number;
|
|
182
190
|
viewer: number;
|
|
183
191
|
licensed: number;
|
|
@@ -187,6 +195,18 @@ export interface SparkVaultSubscription {
|
|
|
187
195
|
assigned_viewer: number;
|
|
188
196
|
/** Roster members with no license (no access until licensed; free). */
|
|
189
197
|
unlicensed_members: number;
|
|
198
|
+
/** Licenses paid for the current billing period (a removed license stays
|
|
199
|
+
* paid until the renewal). */
|
|
200
|
+
paid: { full: number; viewer: number };
|
|
201
|
+
/** Paid licenses nobody holds — assignable at no charge until the renewal. */
|
|
202
|
+
available: { full: number; viewer: number };
|
|
203
|
+
/**
|
|
204
|
+
* Licenses are prepaid per period on a web subscription: a removed one
|
|
205
|
+
* stays paid until the renewal, free to give to someone else until then.
|
|
206
|
+
* False on a store subscription (its one license lands on a member by
|
|
207
|
+
* itself) and on an account with no web subscription.
|
|
208
|
+
*/
|
|
209
|
+
prepaid: boolean;
|
|
190
210
|
};
|
|
191
211
|
blocks: {
|
|
192
212
|
/** Effective capacity blocks (this subscription plus any add-on rail). */
|
|
@@ -204,6 +224,21 @@ export interface SparkVaultSubscription {
|
|
|
204
224
|
/** Native in-app purchase store channel for receipt validation. */
|
|
205
225
|
export type IapPlatform = 'ios' | 'android';
|
|
206
226
|
|
|
227
|
+
/** The store rail a validated purchase is billed on. */
|
|
228
|
+
export type IapChannel = 'appstore' | 'play';
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* POST /v1/billing/iap/validate — whether Core provisioned the purchase: a
|
|
232
|
+
* provisioning status, not a subscription (read the subscription again with
|
|
233
|
+
* `billing.getSubscription()` to show what changed). A purchase the store
|
|
234
|
+
* reports as not entitling (expired, refunded, revoked or not yet paid) is
|
|
235
|
+
* answered, not refused, and provisions nothing; every other refusal is an
|
|
236
|
+
* error. `addon` marks a capacity add-on stacked on the license.
|
|
237
|
+
*/
|
|
238
|
+
export type IapValidation =
|
|
239
|
+
| { provisioned: true; channel: IapChannel; addon?: true }
|
|
240
|
+
| { provisioned: false; reason: 'not_entitling' };
|
|
241
|
+
|
|
207
242
|
export type IdentityType = 'email' | 'phone';
|
|
208
243
|
export type VerifiedIdentityType = IdentityType | 'social';
|
|
209
244
|
|
|
@@ -542,9 +577,104 @@ export interface UnsealVaultResponse {
|
|
|
542
577
|
ttl_seconds: number;
|
|
543
578
|
}
|
|
544
579
|
|
|
580
|
+
/** One page of GET /v1/vaults. */
|
|
545
581
|
export interface ListVaultsResponse {
|
|
546
582
|
vaults: Vault[];
|
|
547
|
-
|
|
583
|
+
count: number;
|
|
584
|
+
has_more: boolean;
|
|
585
|
+
next_cursor?: string;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** A vault's public sharing policy (GET /v1/vaults/{id}/sharing). */
|
|
589
|
+
export interface VaultSharingConfig {
|
|
590
|
+
vault_id: string;
|
|
591
|
+
public_sharing_enabled: boolean;
|
|
592
|
+
all_ingots_public: boolean;
|
|
593
|
+
session_length_seconds: number | null;
|
|
594
|
+
enabled_at: number | null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** The sharing policy a caller sets when enabling or updating vault sharing. */
|
|
598
|
+
export interface VaultSharingOptions {
|
|
599
|
+
all_ingots_public?: boolean;
|
|
600
|
+
session_length_seconds?: number;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export interface EnableVaultSharingResponse {
|
|
604
|
+
vault_id: string;
|
|
605
|
+
public_sharing_enabled: boolean;
|
|
606
|
+
all_ingots_public: boolean;
|
|
607
|
+
session_length_seconds: number;
|
|
608
|
+
enabled_at: number;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export interface DisableVaultSharingResponse {
|
|
612
|
+
vault_id: string;
|
|
613
|
+
public_sharing_enabled: false;
|
|
614
|
+
disabled_at: number;
|
|
615
|
+
shares_revoked: number;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export interface UpdateVaultSharingResponse {
|
|
619
|
+
vault_id: string;
|
|
620
|
+
public_sharing_enabled: boolean;
|
|
621
|
+
all_ingots_public: boolean;
|
|
622
|
+
session_length_seconds: number;
|
|
623
|
+
updated_at: number;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** How long a vault keeps its access log (GET /v1/vaults/{id}/access-log-retention). */
|
|
627
|
+
export interface AccessLogRetention {
|
|
628
|
+
vault_id: string;
|
|
629
|
+
access_log_retention_seconds: number;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export interface UpdateAccessLogRetentionResponse extends AccessLogRetention {
|
|
633
|
+
updated_at: number;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** A vault's upload portal and widget settings (GET /v1/vaults/{id}/upload). */
|
|
637
|
+
export interface UploadConfig {
|
|
638
|
+
vault_id: string;
|
|
639
|
+
upload_portal_enabled: boolean;
|
|
640
|
+
upload_portal_enabled_at: number | null;
|
|
641
|
+
upload_widget_enabled: boolean;
|
|
642
|
+
upload_widget_enabled_at: number | null;
|
|
643
|
+
max_size_bytes: number | null;
|
|
644
|
+
notification_email: string | null;
|
|
645
|
+
portal_url?: string;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export interface UploadPortalEnabled {
|
|
649
|
+
vault_id: string;
|
|
650
|
+
upload_portal_enabled: true;
|
|
651
|
+
enabled_at: number;
|
|
652
|
+
portal_url: string;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface UploadWidgetEnabled {
|
|
656
|
+
vault_id: string;
|
|
657
|
+
upload_widget_enabled: true;
|
|
658
|
+
enabled_at: number;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export interface UploadPortalDisabled {
|
|
662
|
+
vault_id: string;
|
|
663
|
+
upload_portal_enabled: false;
|
|
664
|
+
disabled_at: number;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export interface UploadWidgetDisabled {
|
|
668
|
+
vault_id: string;
|
|
669
|
+
upload_widget_enabled: false;
|
|
670
|
+
disabled_at: number;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export interface UploadConfigUpdate {
|
|
674
|
+
vault_id: string;
|
|
675
|
+
max_size_bytes: number;
|
|
676
|
+
notification_email: string | null;
|
|
677
|
+
updated_at: number;
|
|
548
678
|
}
|
|
549
679
|
|
|
550
680
|
export interface Ingot {
|
|
@@ -553,7 +683,8 @@ export interface Ingot {
|
|
|
553
683
|
content_type: string;
|
|
554
684
|
size_bytes: number;
|
|
555
685
|
storage_location: 'dynamodb' | 's3';
|
|
556
|
-
|
|
686
|
+
/** `uploading` until Forge activates the upload, then `active`. */
|
|
687
|
+
status?: 'uploading' | 'active';
|
|
557
688
|
folder_id?: string | null;
|
|
558
689
|
created_at: number;
|
|
559
690
|
accessed_at?: number;
|
|
@@ -586,16 +717,29 @@ export interface ListIngotsResponse {
|
|
|
586
717
|
next_cursor?: string;
|
|
587
718
|
}
|
|
588
719
|
|
|
720
|
+
/**
|
|
721
|
+
* GET /vaults/:id/contents: one folder whole, in one response — the folder
|
|
722
|
+
* (null at the vault root), the trail from the root to it, its direct
|
|
723
|
+
* subfolders (pinned first) and every ingot directly in it.
|
|
724
|
+
*/
|
|
725
|
+
export interface FolderContentsResponse {
|
|
726
|
+
folder: { folder_id: string; name: string; parent_id: string | null } | null;
|
|
727
|
+
breadcrumb: Array<{ folder_id: string | null; name: string }>;
|
|
728
|
+
folders: Folder[];
|
|
729
|
+
ingots: Ingot[];
|
|
730
|
+
}
|
|
731
|
+
|
|
589
732
|
export interface UploadResult {
|
|
590
733
|
ingot_id: string;
|
|
591
734
|
name: string;
|
|
592
735
|
size_bytes: number;
|
|
593
736
|
storage_location: 's3';
|
|
594
737
|
/**
|
|
595
|
-
* Whether this upload minted the ingot (true) or
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
738
|
+
* Whether this upload minted the ingot (true) or went into an existing row
|
|
739
|
+
* (false): the one holding the name, as the create's name-conflict policy
|
|
740
|
+
* allowed, a replace, or a resumed session. A caller that discards its
|
|
741
|
+
* ingot on a failure path needs this to tell "mine to discard" from
|
|
742
|
+
* "someone else's live row".
|
|
599
743
|
*/
|
|
600
744
|
created?: boolean;
|
|
601
745
|
/**
|
|
@@ -623,9 +767,19 @@ export interface IngotInvite {
|
|
|
623
767
|
created_at: number;
|
|
624
768
|
}
|
|
625
769
|
|
|
770
|
+
/**
|
|
771
|
+
* An ingot access-log entry: a download that delivered the file, a SparkLink
|
|
772
|
+
* open of it included. The server records an access as the download that
|
|
773
|
+
* delivered it, so `download` is the one action.
|
|
774
|
+
*/
|
|
626
775
|
export interface IngotAccessLog {
|
|
776
|
+
/**
|
|
777
|
+
* The entry's time (Unix seconds) as a string. The server issues no entry id,
|
|
778
|
+
* so entries in the same second share it.
|
|
779
|
+
*/
|
|
627
780
|
log_id: string;
|
|
628
|
-
action: 'download'
|
|
781
|
+
action: 'download';
|
|
782
|
+
/** Who accessed: a member's email, an API key's name, or a SparkLink visitor's identity */
|
|
629
783
|
identity: string;
|
|
630
784
|
ip_address: string;
|
|
631
785
|
user_agent: string;
|
|
@@ -638,18 +792,14 @@ export interface IngotAccessStats {
|
|
|
638
792
|
last_accessed_at?: number;
|
|
639
793
|
}
|
|
640
794
|
|
|
795
|
+
/** A folder as every folder route answers it (Core's FolderRepository.toPublicFormat). */
|
|
641
796
|
export interface Folder {
|
|
642
797
|
folder_id: string;
|
|
643
|
-
vault_id: string;
|
|
644
798
|
name: string;
|
|
645
799
|
parent_id: string | null;
|
|
646
|
-
is_system: boolean;
|
|
647
800
|
pinned: boolean;
|
|
648
801
|
created_at: number;
|
|
649
802
|
updated_at?: number;
|
|
650
|
-
path?: string;
|
|
651
|
-
depth?: number;
|
|
652
|
-
ingot_count?: number;
|
|
653
803
|
}
|
|
654
804
|
|
|
655
805
|
export interface CreateFolderRequest {
|
|
@@ -663,9 +813,9 @@ export interface UpdateFolderRequest {
|
|
|
663
813
|
pinned?: boolean;
|
|
664
814
|
}
|
|
665
815
|
|
|
816
|
+
/** GET /v1/vaults/{id}/folders: the vault's whole folder list. */
|
|
666
817
|
export interface ListFoldersResponse {
|
|
667
818
|
folders: Folder[];
|
|
668
|
-
total: number;
|
|
669
819
|
}
|
|
670
820
|
|
|
671
821
|
export interface FolderBreadcrumbItem {
|
|
@@ -919,7 +1069,7 @@ export type SparkShareStatus =
|
|
|
919
1069
|
link_code: string;
|
|
920
1070
|
visibility: string;
|
|
921
1071
|
invites: string[];
|
|
922
|
-
/** Single-use lifecycle: 'active' → 'consumed' (opened once)
|
|
1072
|
+
/** Single-use lifecycle: 'active' → 'consumed' (opened once). */
|
|
923
1073
|
status: string;
|
|
924
1074
|
expires_at?: number;
|
|
925
1075
|
created_at?: number;
|
package/src/users.ts
CHANGED
|
@@ -7,15 +7,17 @@ import type {
|
|
|
7
7
|
RemoveMemberResponse,
|
|
8
8
|
ResendInviteResponse,
|
|
9
9
|
} from './types.js';
|
|
10
|
+
import { apiPath } from './request-path.js';
|
|
11
|
+
import { validateResourceId } from './validation.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Organization team client (member admins only — the API gates these on the
|
|
13
15
|
* admin role and returns 403 otherwise).
|
|
14
16
|
*
|
|
15
|
-
* Licensing is a billing action (
|
|
16
|
-
*
|
|
17
|
-
* operation exposed here is `invite`, which defaults new members
|
|
18
|
-
* `unlicensed` and never charges. Assigning or changing a paid license is
|
|
17
|
+
* Licensing is a billing action (licenses are prepaid: assigning or upgrading
|
|
18
|
+
* one charges the rest of the current billing period at once), so the only
|
|
19
|
+
* seat-affecting operation exposed here is `invite`, which defaults new members
|
|
20
|
+
* to `unlicensed` and never charges. Assigning or changing a paid license is
|
|
19
21
|
* intentionally absent — clients route that to the web dashboard so native
|
|
20
22
|
* apps never initiate an out-of-band purchase.
|
|
21
23
|
*/
|
|
@@ -28,33 +30,41 @@ export class MobileUsersClient {
|
|
|
28
30
|
|
|
29
31
|
/** List organization members (active and invited), with optional search/filter. */
|
|
30
32
|
async list(options: ListMembersOptions = {}): Promise<ListMembersResponse> {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
33
|
+
const response = await this.http.get<ListMembersResponse>(apiPath`/users`, {
|
|
34
|
+
query: {
|
|
35
|
+
search: options.search,
|
|
36
|
+
status: options.status,
|
|
37
|
+
limit: typeof options.limit === 'number' ? options.limit : undefined,
|
|
38
|
+
cursor: options.cursor,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
40
41
|
return response.data;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/** Invite a coworker by email. Defaults to an unlicensed seat (no charge). */
|
|
44
45
|
async invite(request: InviteMemberRequest): Promise<InviteMemberResponse> {
|
|
45
|
-
const response = await this.http.post<InviteMemberResponse>(
|
|
46
|
+
const response = await this.http.post<InviteMemberResponse>(apiPath`/users/invite`, request);
|
|
46
47
|
return response.data;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Remove a member and revoke their DVAKs. Access ends immediately. A license
|
|
52
|
+
* they held stays paid until the end of the billing period — nothing is
|
|
53
|
+
* refunded — and is not renewed; until then it can be given to someone else
|
|
54
|
+
* at no charge. `wind_down` is true when this removal ends the
|
|
55
|
+
* subscription — theirs was the last license it renews, and it was not
|
|
56
|
+
* already set to end: it ends at the period end.
|
|
57
|
+
*/
|
|
50
58
|
async remove(userId: string): Promise<RemoveMemberResponse> {
|
|
51
|
-
|
|
59
|
+
validateResourceId(userId, 'user ID', 'userId');
|
|
60
|
+
const response = await this.http.delete<RemoveMemberResponse>(apiPath`/users/${userId}`);
|
|
52
61
|
return response.data;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** Resend a pending invitation email (resets the 7-day expiry). */
|
|
56
65
|
async resendInvite(userId: string): Promise<ResendInviteResponse> {
|
|
57
|
-
|
|
66
|
+
validateResourceId(userId, 'user ID', 'userId');
|
|
67
|
+
const response = await this.http.post<ResendInviteResponse>(apiPath`/users/${userId}/resend-invite`);
|
|
58
68
|
return response.data;
|
|
59
69
|
}
|
|
60
70
|
}
|
package/src/validation.ts
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
|
+
import { isResourceId } from './app-sdk/ids.js';
|
|
1
2
|
import { SparkVaultValidationError } from './errors.js';
|
|
3
|
+
import { SAVED_FILENAME_FALLBACK, toStorableFilename } from './app-sdk/filename.js';
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** Whether a value is a well-formed SparkVault resource id (the platform's one rule). */
|
|
6
|
+
export { isResourceId as isSafeId };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Refuse a value that is not a SparkVault resource id before it names a route.
|
|
10
|
+
*
|
|
11
|
+
* @param label - How the message names the id, e.g. 'spark ID'.
|
|
12
|
+
* @param field - The argument's name, reported in the error details.
|
|
13
|
+
*/
|
|
14
|
+
export function validateResourceId(value: unknown, label: string, field: string): void {
|
|
15
|
+
if (!isResourceId(value)) {
|
|
16
|
+
throw new SparkVaultValidationError(`Invalid ${label} format`, { [field]: value });
|
|
17
|
+
}
|
|
8
18
|
}
|
|
9
19
|
|
|
10
20
|
export function validateVaultId(vaultId: string): void {
|
|
11
|
-
|
|
12
|
-
throw new SparkVaultValidationError('Invalid vault ID format', { vaultId });
|
|
13
|
-
}
|
|
21
|
+
validateResourceId(vaultId, 'vault ID', 'vaultId');
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export function validateIngotId(ingotId: string): void {
|
|
17
|
-
|
|
18
|
-
throw new SparkVaultValidationError('Invalid ingot ID format', { ingotId });
|
|
19
|
-
}
|
|
25
|
+
validateResourceId(ingotId, 'ingot ID', 'ingotId');
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export function validateFolderId(folderId: string): void {
|
|
23
|
-
|
|
24
|
-
throw new SparkVaultValidationError('Invalid folder ID format', { folderId });
|
|
25
|
-
}
|
|
29
|
+
validateResourceId(folderId, 'folder ID', 'folderId');
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
/** Windows reserved filenames that cannot be used. */
|
|
@@ -33,17 +37,19 @@ const WINDOWS_RESERVED_NAMES = new Set([
|
|
|
33
37
|
]);
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
+
* The file name to store or save text under, by the platform's one file-name
|
|
41
|
+
* rule (@sparkvault/app-sdk/filename, mirrored into ./app-sdk/filename.js):
|
|
42
|
+
* path separators, control characters and the bidi controls that disguise an
|
|
43
|
+
* extension (`invoice<U+202E>fdp.exe` shows as `invoiceexe.pdf`) become `_`,
|
|
44
|
+
* and so do the characters SparkVault refuses in a name (: * ? " < > |), so a
|
|
45
|
+
* name derived from a note's title uploads. With every separator gone no name
|
|
46
|
+
* can climb a directory, so a `..` inside a name is kept. On top of the rule,
|
|
47
|
+
* for the device's file system: leading dots are stripped (a hidden file) and
|
|
48
|
+
* Windows reserved names are prefixed. Falls back to the platform's one
|
|
49
|
+
* fallback name (SAVED_FILENAME_FALLBACK) when nothing remains.
|
|
40
50
|
*/
|
|
41
51
|
export function sanitizeFilename(filename: string): string {
|
|
42
|
-
let sanitized = filename
|
|
43
|
-
.replace(/\0/g, '')
|
|
44
|
-
.replace(/[\x00-\x1f\x7f]/g, '')
|
|
45
|
-
.replace(/\.\./g, '')
|
|
46
|
-
.replace(/[\/\\]/g, '_')
|
|
52
|
+
let sanitized = toStorableFilename(filename)
|
|
47
53
|
.replace(/^\.+/, '')
|
|
48
54
|
.trim();
|
|
49
55
|
|
|
@@ -52,5 +58,5 @@ export function sanitizeFilename(filename: string): string {
|
|
|
52
58
|
sanitized = `_${sanitized}`;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
return sanitized ||
|
|
61
|
+
return sanitized || SAVED_FILENAME_FALLBACK;
|
|
56
62
|
}
|