@sparkvault/sdk-mobile 6.0.2 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +5 -5
  2. package/dist/account.d.ts +4 -3
  3. package/dist/account.js +16 -11
  4. package/dist/account.js.map +1 -1
  5. package/dist/app-sdk/email-address.d.ts +37 -0
  6. package/dist/app-sdk/email-address.js +91 -0
  7. package/dist/app-sdk/email-address.js.map +1 -0
  8. package/dist/app-sdk/encoding.d.ts +64 -0
  9. package/dist/app-sdk/encoding.js +245 -0
  10. package/dist/app-sdk/encoding.js.map +1 -0
  11. package/dist/app-sdk/entropy-contract.d.ts +33 -0
  12. package/dist/app-sdk/entropy-contract.js +51 -0
  13. package/dist/app-sdk/entropy-contract.js.map +1 -0
  14. package/dist/app-sdk/filename.d.ts +93 -0
  15. package/dist/app-sdk/filename.js +161 -0
  16. package/dist/app-sdk/filename.js.map +1 -0
  17. package/dist/app-sdk/forge-url.d.ts +107 -0
  18. package/dist/app-sdk/forge-url.js +222 -0
  19. package/dist/app-sdk/forge-url.js.map +1 -0
  20. package/dist/app-sdk/ids.d.ts +48 -0
  21. package/dist/app-sdk/ids.js +65 -0
  22. package/dist/app-sdk/ids.js.map +1 -0
  23. package/dist/app-sdk/ingot-access-log.d.ts +29 -0
  24. package/dist/app-sdk/ingot-access-log.js +49 -0
  25. package/dist/app-sdk/ingot-access-log.js.map +1 -0
  26. package/dist/app-sdk/phone.d.ts +37 -0
  27. package/dist/app-sdk/phone.js +134 -0
  28. package/dist/app-sdk/phone.js.map +1 -0
  29. package/dist/app-sdk/platform-domains.d.ts +41 -0
  30. package/dist/app-sdk/platform-domains.js +82 -0
  31. package/dist/app-sdk/platform-domains.js.map +1 -0
  32. package/dist/app-sdk/request-path.d.ts +49 -0
  33. package/dist/app-sdk/request-path.js +81 -0
  34. package/dist/app-sdk/request-path.js.map +1 -0
  35. package/dist/app-sdk/transient.d.ts +23 -0
  36. package/dist/app-sdk/transient.js +29 -0
  37. package/dist/app-sdk/transient.js.map +1 -0
  38. package/dist/app-sdk/vat-refusal.d.ts +66 -0
  39. package/dist/app-sdk/vat-refusal.js +69 -0
  40. package/dist/app-sdk/vat-refusal.js.map +1 -0
  41. package/dist/billing.d.ts +9 -17
  42. package/dist/billing.js +14 -17
  43. package/dist/billing.js.map +1 -1
  44. package/dist/config.d.ts +7 -1
  45. package/dist/config.js +10 -17
  46. package/dist/config.js.map +1 -1
  47. package/dist/encoding.d.ts +30 -4
  48. package/dist/encoding.js +89 -83
  49. package/dist/encoding.js.map +1 -1
  50. package/dist/entropy.d.ts +25 -4
  51. package/dist/entropy.js +62 -15
  52. package/dist/entropy.js.map +1 -1
  53. package/dist/entropy.test-d.d.ts +40 -0
  54. package/dist/entropy.test-d.js +12 -0
  55. package/dist/entropy.test-d.js.map +1 -0
  56. package/dist/errors.d.ts +67 -17
  57. package/dist/errors.js +70 -27
  58. package/dist/errors.js.map +1 -1
  59. package/dist/folders.js +8 -7
  60. package/dist/folders.js.map +1 -1
  61. package/dist/http.d.ts +57 -15
  62. package/dist/http.js +125 -138
  63. package/dist/http.js.map +1 -1
  64. package/dist/identity-dialog.js +4 -30
  65. package/dist/identity-dialog.js.map +1 -1
  66. package/dist/identity-input.d.ts +39 -0
  67. package/dist/identity-input.js +59 -0
  68. package/dist/identity-input.js.map +1 -0
  69. package/dist/index.d.ts +6 -5
  70. package/dist/index.js +4 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/ingots.d.ts +51 -18
  73. package/dist/ingots.js +132 -122
  74. package/dist/ingots.js.map +1 -1
  75. package/dist/mutex.d.ts +1 -2
  76. package/dist/mutex.js +0 -6
  77. package/dist/mutex.js.map +1 -1
  78. package/dist/notify.d.ts +7 -2
  79. package/dist/notify.js +12 -15
  80. package/dist/notify.js.map +1 -1
  81. package/dist/notify.test-d.d.ts +3 -2
  82. package/dist/notify.test-d.js +1 -1
  83. package/dist/products/identity.d.ts +5 -0
  84. package/dist/products/identity.js +40 -35
  85. package/dist/products/identity.js.map +1 -1
  86. package/dist/request-path.d.ts +32 -0
  87. package/dist/request-path.js +35 -0
  88. package/dist/request-path.js.map +1 -0
  89. package/dist/sparks.js +16 -15
  90. package/dist/sparks.js.map +1 -1
  91. package/dist/tus.d.ts +19 -2
  92. package/dist/tus.js +86 -35
  93. package/dist/tus.js.map +1 -1
  94. package/dist/types.d.ts +167 -15
  95. package/dist/users.d.ts +12 -5
  96. package/dist/users.js +27 -19
  97. package/dist/users.js.map +1 -1
  98. package/dist/validation.d.ts +20 -5
  99. package/dist/validation.js +29 -27
  100. package/dist/validation.js.map +1 -1
  101. package/dist/vaults.d.ts +34 -83
  102. package/dist/vaults.js +64 -44
  103. package/dist/vaults.js.map +1 -1
  104. package/dist/vaults.test-d.d.ts +22 -0
  105. package/dist/vaults.test-d.js +14 -0
  106. package/dist/vaults.test-d.js.map +1 -0
  107. package/package.json +4 -2
  108. package/src/account.ts +16 -11
  109. package/src/app-sdk/email-address.js +94 -0
  110. package/src/app-sdk/encoding.js +250 -0
  111. package/src/app-sdk/entropy-contract.js +56 -0
  112. package/src/app-sdk/filename.js +174 -0
  113. package/src/app-sdk/forge-url.js +224 -0
  114. package/src/app-sdk/ids.js +71 -0
  115. package/src/app-sdk/ingot-access-log.js +51 -0
  116. package/src/app-sdk/phone.js +139 -0
  117. package/src/app-sdk/platform-domains.js +99 -0
  118. package/src/app-sdk/request-path.js +81 -0
  119. package/src/app-sdk/transient.js +30 -0
  120. package/src/app-sdk/vat-refusal.js +74 -0
  121. package/src/billing.ts +16 -25
  122. package/src/config.ts +16 -23
  123. package/src/encoding.ts +91 -103
  124. package/src/entropy.test-d.ts +47 -0
  125. package/src/entropy.ts +86 -20
  126. package/src/errors.ts +93 -33
  127. package/src/folders.ts +8 -7
  128. package/src/http.ts +145 -150
  129. package/src/identity-dialog.tsx +4 -37
  130. package/src/identity-input.ts +70 -0
  131. package/src/index.ts +11 -3
  132. package/src/ingots.ts +173 -159
  133. package/src/mutex.ts +1 -8
  134. package/src/notify.test-d.ts +3 -1
  135. package/src/notify.ts +19 -17
  136. package/src/products/identity.ts +45 -39
  137. package/src/request-path.ts +45 -0
  138. package/src/sparks.ts +16 -13
  139. package/src/tus.ts +90 -37
  140. package/src/types.ts +165 -15
  141. package/src/users.ts +27 -17
  142. package/src/validation.ts +30 -24
  143. package/src/vaults.test-d.ts +29 -0
  144. package/src/vaults.ts +87 -153
  145. package/dist/platform-domains.generated.d.ts +0 -2
  146. package/dist/platform-domains.generated.js +0 -16
  147. package/dist/platform-domains.generated.js.map +0 -1
  148. package/src/platform-domains.generated.ts +0 -17
package/src/tus.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedMobileConfig } from './config.js';
2
- import { base64EncodeUtf8 } from './encoding.js';
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
- export function parseForgeUrl(forgeUrl: string): ParsedForgeUrl {
105
- let url: URL;
106
- try {
107
- url = new URL(forgeUrl);
108
- } catch {
109
- throw new SparkVaultValidationError('Invalid Forge URL format');
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
- if (url.protocol !== 'https:') {
113
- throw new SparkVaultValidationError('Forge URL must use HTTPS protocol');
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
- const istk = url.searchParams.get('istk');
117
- if (!istk) {
118
- throw new SparkVaultValidationError('Forge URL missing ISTK parameter');
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
- const baseUrl = `${url.protocol}//${url.host}`;
122
- return {
123
- baseUrl,
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 istk = options.resume.istk ?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl).istk : null);
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: options.resume.uploadUrl, istk };
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
- const metadata = `filename ${base64EncodeUtf8(filename)},filetype ${base64EncodeUtf8(contentType || 'application/octet-stream')}`;
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
- const location = response.headers.get('Location');
353
- if (!location) {
354
- throw new TusUploadError('TUS create response missing Location header', {
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, session.uploadUrl, session.istk);
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, session.uploadUrl);
1084
+ await this.cancelBackgroundTransfer(backgroundTransfer, uploadUrl);
1032
1085
  }
1033
1086
 
1034
- const response = await this.requestTerminate(session.uploadUrl, session.istk);
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
- /** Cycle-unique billed counts (flex billing: what the renewal invoices). */
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
- total: number;
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
- status?: 'uploading' | 'encrypting' | 'active' | 'failed';
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 was handed an existing row
596
- * by the server's same-name upsert / a resumed session (false). A caller
597
- * that discards its ingot on a failure path needs this to tell "mine to
598
- * discard" from "someone else's live row".
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' | 'view';
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) | 'revoked'. */
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 (flex: nothing charges mid-cycle — the renewal
16
- * bills each cycle's unique license holders), so the only seat-affecting
17
- * operation exposed here is `invite`, which defaults new members to
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 params = new URLSearchParams();
32
- if (options.search) params.set('search', options.search);
33
- if (options.status) params.set('status', options.status);
34
- if (typeof options.limit === 'number') params.set('limit', String(options.limit));
35
- if (options.cursor) params.set('cursor', options.cursor);
36
- const query = params.toString();
37
- const response = await this.http.get<ListMembersResponse>(
38
- query ? `/users?${query}` : '/users'
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>('/users/invite', request);
46
+ const response = await this.http.post<InviteMemberResponse>(apiPath`/users/invite`, request);
46
47
  return response.data;
47
48
  }
48
49
 
49
- /** Remove a member and revoke their DVAKs. Access ends immediately; the current cycle still bills them, and they stop counting next cycle. */
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
- const response = await this.http.delete<RemoveMemberResponse>(`/users/${userId}`);
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
- const response = await this.http.post<ResendInviteResponse>(`/users/${userId}/resend-invite`);
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
- export function isSafeId(id: string): boolean {
4
- if (!id || typeof id !== 'string') return false;
5
- if (id.includes('/') || id.includes('\\') || id.includes('..') || id.includes('\0')) return false;
6
- if (id.length < 8 || id.length > 128) return false;
7
- return /^[a-zA-Z0-9_-]+$/.test(id);
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
- if (!isSafeId(vaultId)) {
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
- if (!isSafeId(ingotId)) {
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
- if (!folderId || typeof folderId !== 'string' || !folderId.startsWith('fld_') || !isSafeId(folderId)) {
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
- * Sanitize a filename to prevent path traversal and other security issues:
37
- * removes null bytes and control characters, strips path traversal sequences,
38
- * replaces directory separators, strips leading dots (hidden files), and
39
- * prefixes Windows reserved names. Falls back to 'file' when nothing remains.
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 || 'file';
61
+ return sanitized || SAVED_FILENAME_FALLBACK;
56
62
  }