@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/notify.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { MobileHttpClient } from './http.js';
2
+ import { apiPath } from './request-path.js';
3
+ import { validateResourceId } from './validation.js';
2
4
 
3
5
  /** Inbox views the feed can be filtered to. `all` excludes archived rows. */
4
6
  export type NotificationInboxState = 'all' | 'unseen' | 'unread' | 'archived';
@@ -34,10 +36,15 @@ export interface NotificationRow {
34
36
  * The grant to open. Present only while a live reveal stands behind it — a
35
37
  * SEALED (secure) send that has not been recalled. A cleartext send delivers its
36
38
  * content inline in the channel and mints no grant; a recalled row's grant is
37
- * revoked and the pointer is withheld rather than echoed. Guard before building
38
- * a URL from it.
39
+ * revoked and the pointer is withheld rather than echoed.
39
40
  */
40
41
  sparklink_code?: string;
42
+ /**
43
+ * Where the grant opens: the URL every Notify channel carries for it, built by
44
+ * the server. Present exactly when `sparklink_code` is; open this rather than
45
+ * building a URL from the code.
46
+ */
47
+ pointer_url?: string;
41
48
  /**
42
49
  * True only when there is a secure part left to reveal — exactly when
43
50
  * `sparklink_code` is present. `false` does NOT mean "cleartext row": a recalled
@@ -150,21 +157,15 @@ export class MobileNotifyClient {
150
157
 
151
158
  /** The caller's own inbox feed (newest first). */
152
159
  async getInbox(options: ListNotificationsOptions = {}): Promise<ListNotificationsResponse> {
153
- const params = new URLSearchParams();
154
- if (options.state) params.set('state', options.state);
155
- if (options.limit) params.set('limit', String(options.limit));
156
- if (options.cursor) params.set('cursor', options.cursor);
157
-
158
- const query = params.toString();
159
- const response = await this.http.get<ListNotificationsResponse>(
160
- query ? `/me/notifications?${query}` : '/me/notifications'
161
- );
160
+ const response = await this.http.get<ListNotificationsResponse>(apiPath`/me/notifications`, {
161
+ query: { state: options.state, limit: options.limit || undefined, cursor: options.cursor },
162
+ });
162
163
  return response.data;
163
164
  }
164
165
 
165
166
  /** Bounded unseen badge count for the caller's own inbox. */
166
167
  async getUnreadCount(): Promise<UnreadCountResponse> {
167
- const response = await this.http.get<UnreadCountResponse>('/me/notifications/unread-count');
168
+ const response = await this.http.get<UnreadCountResponse>(apiPath`/me/notifications/unread-count`);
168
169
  return response.data;
169
170
  }
170
171
 
@@ -176,8 +177,9 @@ export class MobileNotifyClient {
176
177
  notificationId: string,
177
178
  options: MarkNotificationStateOptions
178
179
  ): Promise<MarkNotificationStateResponse> {
180
+ validateResourceId(notificationId, 'notification ID', 'notificationId');
179
181
  const response = await this.http.post<MarkNotificationStateResponse>(
180
- `/me/notifications/${encodeURIComponent(notificationId)}/state`,
182
+ apiPath`/me/notifications/${notificationId}/state`,
181
183
  { created_at: options.createdAt, state: options.state }
182
184
  );
183
185
  return response.data;
@@ -198,7 +200,7 @@ export class MobileNotifyClient {
198
200
  if (options.cursor) body.cursor = options.cursor;
199
201
 
200
202
  const response = await this.http.post<MarkAllNotificationsStateResponse>(
201
- '/me/notifications/state',
203
+ apiPath`/me/notifications/state`,
202
204
  body
203
205
  );
204
206
  return response.data;
@@ -232,7 +234,7 @@ export class MobileNotifyClient {
232
234
 
233
235
  /** Register/refresh this device's Expo push token under the session user. */
234
236
  async registerDevice(pushToken: string): Promise<RegisterDeviceResponse> {
235
- const response = await this.http.post<RegisterDeviceResponse>('/me/notifications/devices', {
237
+ const response = await this.http.post<RegisterDeviceResponse>(apiPath`/me/notifications/devices`, {
236
238
  push_token: pushToken,
237
239
  });
238
240
  return response.data;
@@ -244,7 +246,7 @@ export class MobileNotifyClient {
244
246
  * itself, so the caller never reimplements that derivation.
245
247
  */
246
248
  async unregisterDeviceToken(pushToken: string): Promise<void> {
247
- await this.http.delete('/me/notifications/devices', { body: { token: pushToken } });
249
+ await this.http.delete(apiPath`/me/notifications/devices`, { body: { token: pushToken } });
248
250
  }
249
251
 
250
252
  /**
@@ -252,6 +254,6 @@ export class MobileNotifyClient {
252
254
  * Use `unregisterDeviceToken` when the caller holds the token itself.
253
255
  */
254
256
  async unregisterDevice(tokenHash: string): Promise<void> {
255
- await this.http.delete(`/me/notifications/devices/${encodeURIComponent(tokenHash)}`);
257
+ await this.http.delete(apiPath`/me/notifications/devices/${tokenHash}`);
256
258
  }
257
259
  }
@@ -1,6 +1,8 @@
1
1
  import { identityUrl, type ResolvedMobileConfig } from '../config.js';
2
2
  import { base64UrlToBytes, base64UrlToUtf8, utf8ToBytes } from '../encoding.js';
3
3
  import { SparkVaultAuthenticationError, SparkVaultMobileError, SparkVaultValidationError } from '../errors.js';
4
+ import { inferIdentityType, normalizeIdentifier } from '../identity-input.js';
5
+ import { apiPath, type ApiPath } from '../request-path.js';
4
6
  import type {
5
7
  ApiErrorBody,
6
8
  BackupSendResponse,
@@ -105,7 +107,7 @@ export class MobileIdentityClient {
105
107
  }
106
108
 
107
109
  getJwksUri(): string {
108
- return identityUrl(this.config, '/.well-known/jwks.json');
110
+ return identityUrl(this.config, apiPath`/.well-known/jwks.json`);
109
111
  }
110
112
 
111
113
  async getConfig(): Promise<IdentityConfig> {
@@ -133,7 +135,7 @@ export class MobileIdentityClient {
133
135
  const now = Date.now();
134
136
  const expired = now - this.jwksCachedAt > JWKS_CACHE_TTL_MS;
135
137
  if (!this.jwksCache || expired || options.forceRefresh) {
136
- this.jwksCache = this.getIdentity<IdentityJwks>('/.well-known/jwks.json');
138
+ this.jwksCache = this.getIdentity<IdentityJwks>(apiPath`/.well-known/jwks.json`);
137
139
  this.jwksCachedAt = now;
138
140
  }
139
141
 
@@ -158,25 +160,18 @@ export class MobileIdentityClient {
158
160
  throw new SparkVaultValidationError('Identity token is required', { field: 'token' });
159
161
  }
160
162
 
161
- const parts = token.split('.');
162
- if (parts.length !== 3) {
163
- throw new SparkVaultValidationError('Invalid identity token format');
164
- }
165
-
166
- const [headerPart, payloadPart, signaturePart] = parts;
167
- let header: JwtHeader;
168
- let claims: IdentityTokenClaims;
169
-
163
+ const claims = this.decodeIdentityTokenClaims(token);
164
+ const [headerPart, payloadPart, signaturePart] = token.split('.');
165
+ let parsedHeader: unknown = null;
170
166
  try {
171
- header = JSON.parse(base64UrlToUtf8(headerPart)) as JwtHeader;
172
- const parsedClaims = JSON.parse(base64UrlToUtf8(payloadPart)) as unknown;
173
- if (!this.isIdentityTokenClaims(parsedClaims)) {
174
- throw new Error('Invalid claims');
175
- }
176
- claims = parsedClaims;
167
+ parsedHeader = JSON.parse(base64UrlToUtf8(headerPart));
177
168
  } catch {
169
+ // Refused below with every other unusable header.
170
+ }
171
+ if (typeof parsedHeader !== 'object' || parsedHeader === null || Array.isArray(parsedHeader)) {
178
172
  throw new SparkVaultValidationError('Invalid identity token payload');
179
173
  }
174
+ const header = parsedHeader as JwtHeader;
180
175
 
181
176
  if (header.alg !== 'EdDSA') {
182
177
  throw new SparkVaultValidationError('Unsupported identity token algorithm', { alg: header.alg });
@@ -258,7 +253,7 @@ export class MobileIdentityClient {
258
253
  kindling: string;
259
254
  expires_at?: number;
260
255
  retry_after?: number;
261
- }>('/otp/send', {
256
+ }>(apiPath`/otp/send`, {
262
257
  recipient: identity,
263
258
  method: request.method,
264
259
  });
@@ -331,7 +326,7 @@ export class MobileIdentityClient {
331
326
  * identity result. A wrong code throws.
332
327
  */
333
328
  async submitSecondFactor(request: SubmitSecondFactorRequest): Promise<IdentityVerifyResult> {
334
- const result = await this.postIdentity<IdentityVerificationResponse>('/second-factor/verify', {
329
+ const result = await this.postIdentity<IdentityVerificationResponse>(apiPath`/second-factor/verify`, {
335
330
  ticket: request.ticket,
336
331
  code: request.code,
337
332
  });
@@ -367,7 +362,7 @@ export class MobileIdentityClient {
367
362
  async checkPasskeyStatus(identity: string, identityType?: IdentityType): Promise<{ hasPasskey: boolean }> {
368
363
  try {
369
364
  const result = await this.postIdentity<{ has_passkey: boolean }>(
370
- '/passkey/check',
365
+ apiPath`/passkey/check`,
371
366
  this.identityLookupBody(identity, identityType)
372
367
  );
373
368
  return { hasPasskey: Boolean(result.has_passkey) };
@@ -377,7 +372,7 @@ export class MobileIdentityClient {
377
372
  }
378
373
 
379
374
  async getPasskeyAuthOptions(_identity?: string, _identityType?: IdentityType): Promise<PasskeyAuthChallenge> {
380
- return this.postIdentity<PasskeyAuthChallenge>('/passkey/verify', {});
375
+ return this.postIdentity<PasskeyAuthChallenge>(apiPath`/passkey/verify`, {});
381
376
  }
382
377
 
383
378
  async completePasskeyAuthentication(
@@ -385,7 +380,7 @@ export class MobileIdentityClient {
385
380
  session: PasskeySession
386
381
  ): Promise<IdentityVerifyResult> {
387
382
  const result = await this.postIdentity<IdentityPasskeyCompletionResponse>(
388
- '/passkey/verify/complete',
383
+ apiPath`/passkey/verify/complete`,
389
384
  { session, credential }
390
385
  );
391
386
 
@@ -422,7 +417,7 @@ export class MobileIdentityClient {
422
417
  const normalized = this.normalizeIdentity(identity, identityType);
423
418
  const identityBody = identityType === 'phone' ? { phone: normalized } : { email: normalized };
424
419
 
425
- return this.postIdentity<PasskeyRegisterChallenge>('/passkey/register', {
420
+ return this.postIdentity<PasskeyRegisterChallenge>(apiPath`/passkey/register`, {
426
421
  ...identityBody,
427
422
  registration_token: params.registrationToken,
428
423
  device_name: params.deviceName ?? this.config.userAgent,
@@ -434,7 +429,7 @@ export class MobileIdentityClient {
434
429
  session: PasskeySession
435
430
  ): Promise<{ success: boolean; credential_id: string }> {
436
431
  const result = await this.postIdentity<IdentityPasskeyCompletionResponse>(
437
- '/passkey/register/complete',
432
+ apiPath`/passkey/register/complete`,
438
433
  { session, credential }
439
434
  );
440
435
 
@@ -464,7 +459,7 @@ export class MobileIdentityClient {
464
459
  * Authorized by the login's verification token. Best-effort.
465
460
  */
466
461
  async dismissRecommendation(params: { verificationToken: string; key: RecommendationKey }): Promise<void> {
467
- await this.postIdentity('/recommendations/dismiss', {
462
+ await this.postIdentity(apiPath`/recommendations/dismiss`, {
468
463
  verification_token: params.verificationToken,
469
464
  key: params.key
470
465
  });
@@ -482,10 +477,10 @@ export class MobileIdentityClient {
482
477
  value: string;
483
478
  method?: 'email' | 'sms' | 'voice';
484
479
  }): Promise<BackupSendResponse> {
485
- return this.postIdentity<BackupSendResponse>('/backup-verification/send', {
480
+ return this.postIdentity<BackupSendResponse>(apiPath`/backup-verification/send`, {
486
481
  verification_token: params.verificationToken,
487
482
  type: params.type,
488
- value: params.value,
483
+ value: this.normalizeIdentity(params.value, params.type),
489
484
  ...(params.method ? { method: params.method } : {}),
490
485
  });
491
486
  }
@@ -498,7 +493,7 @@ export class MobileIdentityClient {
498
493
  sessionId: string;
499
494
  code: string;
500
495
  }): Promise<BackupVerifyResponse> {
501
- return this.postIdentity<BackupVerifyResponse>('/backup-verification/verify', {
496
+ return this.postIdentity<BackupVerifyResponse>(apiPath`/backup-verification/verify`, {
502
497
  session_id: params.sessionId,
503
498
  code: params.code,
504
499
  });
@@ -506,10 +501,10 @@ export class MobileIdentityClient {
506
501
 
507
502
  private async verifyOtpWithRetryErrors(request: VerifyOtpRequest): Promise<IdentityVerificationResponse> {
508
503
  try {
509
- return await this.postIdentity<IdentityVerificationResponse>('/otp/verify', {
504
+ return await this.postIdentity<IdentityVerificationResponse>(apiPath`/otp/verify`, {
510
505
  kindling: request.kindling,
511
506
  pin: request.pin,
512
- recipient: request.recipient.trim().toLowerCase(),
507
+ recipient: this.normalizeIdentity(request.recipient, inferIdentityType(request.recipient)),
513
508
  });
514
509
  } catch (err) {
515
510
  const apiError = err as SparkVaultMobileError & {
@@ -535,7 +530,7 @@ export class MobileIdentityClient {
535
530
  }
536
531
 
537
532
  private startConfigLoad(): Promise<IdentityConfig> {
538
- const request = this.getIdentity<IdentityConfig>('/config').catch((error: unknown) => {
533
+ const request = this.getIdentity<IdentityConfig>(apiPath`/config`).catch((error: unknown) => {
539
534
  this.configCache = null;
540
535
  throw error;
541
536
  });
@@ -582,7 +577,7 @@ export class MobileIdentityClient {
582
577
  }
583
578
  }
584
579
 
585
- private async getIdentity<T>(path: string): Promise<T> {
580
+ private async getIdentity<T>(path: ApiPath): Promise<T> {
586
581
  const response = await this.config.fetch(identityUrl(this.config, path), {
587
582
  method: 'GET',
588
583
  headers: this.identityHeaders(),
@@ -605,7 +600,7 @@ export class MobileIdentityClient {
605
600
  }
606
601
 
607
602
  private async postIdentity<T>(
608
- path: string,
603
+ path: ApiPath,
609
604
  body: Record<string, unknown>,
610
605
  options: { bearerToken?: string } = {}
611
606
  ): Promise<T> {
@@ -647,17 +642,28 @@ export class MobileIdentityClient {
647
642
  }
648
643
 
649
644
  private identityLookupBody(identity: string, identityType?: IdentityType): Record<string, string> {
650
- const inferredType = identityType ?? (identity.includes('@') ? 'email' : 'phone');
651
- const normalized = this.normalizeIdentity(identity, inferredType);
652
- return inferredType === 'phone' ? { phone: normalized } : { email: normalized };
645
+ const type = identityType ?? inferIdentityType(identity);
646
+ const normalized = this.normalizeIdentity(identity, type);
647
+ return type === 'phone' ? { phone: normalized } : { email: normalized };
653
648
  }
654
649
 
650
+ /**
651
+ * An identity as the Identity Product keys it, read as the given type: the
652
+ * sign-in dialog's rule, so a number or address resolves to the same identity
653
+ * through every method.
654
+ */
655
655
  private normalizeIdentity(identity: string, identityType: IdentityType): string {
656
- const trimmed = identity.trim();
657
- if (!trimmed) {
656
+ if (!identity.trim()) {
658
657
  throw new SparkVaultValidationError('Identity is required', { field: 'identity' });
659
658
  }
660
- return identityType === 'email' ? trimmed.toLowerCase() : trimmed;
659
+ const normalized = normalizeIdentifier(identity, identityType);
660
+ if (normalized === null) {
661
+ throw new SparkVaultValidationError(
662
+ identityType === 'phone' ? 'Invalid phone number' : 'Invalid email address',
663
+ { field: 'identity' }
664
+ );
665
+ }
666
+ return normalized;
661
667
  }
662
668
 
663
669
  private selectJwk(jwks: IdentityJwks, kid?: string): { x: string } {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Request paths: the one way a module names a SparkVault route.
3
+ *
4
+ * Every request the SDK sends carries the caller's credentials (the session's
5
+ * Bearer token, plus a Vault Access Token on vault data), so a value that
6
+ * smuggled '/' or a dot-segment into a path would re-target that credentialed
7
+ * request at another route. `apiPath` closes that by construction: each
8
+ * interpolated value becomes exactly one percent-encoded segment, and a value
9
+ * that cannot be one segment is refused before any request leaves. The
10
+ * transport accepts only an `ApiPath`, so no module can name a route any other
11
+ * way.
12
+ *
13
+ * The rules are the platform's one copy, mirrored from
14
+ * `@sparkvault/app-sdk/request-path` into `./app-sdk/request-path.js`; this
15
+ * module only types them and raises this SDK's error.
16
+ */
17
+
18
+ import { buildRequestPath, encodePathSegment, queryString } from './app-sdk/request-path.js';
19
+ import { SparkVaultValidationError } from './errors.js';
20
+
21
+ declare const API_PATH: unique symbol;
22
+
23
+ /** A route path whose every interpolated value is exactly one encoded segment. */
24
+ export type ApiPath = string & { readonly [API_PATH]: true };
25
+
26
+ /** Query parameters; `undefined`, `null` and `''` are left out of the query. */
27
+ export type QueryParams = Record<string, string | number | boolean | null | undefined>;
28
+
29
+ /**
30
+ * Build a route path from a template: `apiPath\`/vaults/${vaultId}\``.
31
+ *
32
+ * @throws SparkVaultValidationError when an interpolated value cannot be
33
+ * exactly one path segment ('', '.', '..', a non-string, or malformed UTF-16).
34
+ */
35
+ export function apiPath(literals: TemplateStringsArray, ...values: string[]): ApiPath {
36
+ const path = buildRequestPath(literals, values);
37
+ if (path === null) {
38
+ throw new SparkVaultValidationError('A path value must be exactly one path segment', {
39
+ received: values.find(value => encodePathSegment(value) === null),
40
+ });
41
+ }
42
+ return path as ApiPath;
43
+ }
44
+
45
+ export { queryString };
package/src/sparks.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { MobileHttpClient } from './http.js';
2
2
  import type { ShareSparkResponse, SparkShareStatus, UnshareSparkResponse } from './types.js';
3
+ import { apiPath } from './request-path.js';
4
+ import { validateResourceId } from './validation.js';
3
5
 
4
6
  export interface Spark {
5
7
  spark_id: string;
@@ -55,23 +57,20 @@ export class MobileSparksClient {
55
57
  }
56
58
 
57
59
  async list(options: ListSparksOptions = {}): Promise<ListSparksResponse> {
58
- const params = new URLSearchParams();
59
- if (options.kindling) params.set('kindling', options.kindling);
60
- if (options.limit) params.set('limit', String(options.limit));
61
- if (options.cursor) params.set('cursor', options.cursor);
62
-
63
- const query = params.toString();
64
- const response = await this.http.get<ListSparksResponse>(query ? `/sparks?${query}` : '/sparks');
60
+ const response = await this.http.get<ListSparksResponse>(apiPath`/sparks`, {
61
+ query: { kindling: options.kindling, limit: options.limit || undefined, cursor: options.cursor },
62
+ });
65
63
  return response.data;
66
64
  }
67
65
 
68
66
  async get(sparkId: string): Promise<SparkResponse> {
69
- const response = await this.http.get<SparkResponse>(`/sparks/${encodeURIComponent(sparkId)}`);
67
+ validateResourceId(sparkId, 'spark ID', 'sparkId');
68
+ const response = await this.http.get<SparkResponse>(apiPath`/sparks/${sparkId}`);
70
69
  return response.data;
71
70
  }
72
71
 
73
72
  async create(options: CreateSparkOptions): Promise<CreateSparkResponse> {
74
- const response = await this.http.post<CreateSparkResponse>('/sparks', {
73
+ const response = await this.http.post<CreateSparkResponse>(apiPath`/sparks`, {
75
74
  payload: options.payload,
76
75
  content_type: options.contentType,
77
76
  ttl_minutes: options.ttlMinutes,
@@ -83,7 +82,8 @@ export class MobileSparksClient {
83
82
 
84
83
  /** Burn a spark before its natural expiry. An already-burned spark resolves (the server returns 204); a missing spark rejects with a 404. */
85
84
  async delete(sparkId: string): Promise<void> {
86
- await this.http.delete(`/sparks/${encodeURIComponent(sparkId)}`);
85
+ validateResourceId(sparkId, 'spark ID', 'sparkId');
86
+ await this.http.delete(apiPath`/sparks/${sparkId}`);
87
87
  }
88
88
 
89
89
  /**
@@ -92,13 +92,14 @@ export class MobileSparksClient {
92
92
  * shared.
93
93
  */
94
94
  async share(sparkId: string, options: ShareSparkOptions = {}): Promise<ShareSparkResponse> {
95
+ validateResourceId(sparkId, 'spark ID', 'sparkId');
95
96
  const body: Record<string, unknown> = {};
96
97
  if (options.visibility) body.visibility = options.visibility;
97
98
  if (options.invites) body.invites = options.invites;
98
99
  if (options.expiresInSeconds !== undefined) body.expires_in_seconds = options.expiresInSeconds;
99
100
 
100
101
  const response = await this.http.patch<ShareSparkResponse>(
101
- `/sparks/${encodeURIComponent(sparkId)}/share`,
102
+ apiPath`/sparks/${sparkId}/share`,
102
103
  body
103
104
  );
104
105
  return response.data;
@@ -106,16 +107,18 @@ export class MobileSparksClient {
106
107
 
107
108
  /** Current sharing status for a spark. `shared` is false when no SparkLink exists. */
108
109
  async getShare(sparkId: string): Promise<SparkShareStatus> {
110
+ validateResourceId(sparkId, 'spark ID', 'sparkId');
109
111
  const response = await this.http.get<SparkShareStatus>(
110
- `/sparks/${encodeURIComponent(sparkId)}/share`
112
+ apiPath`/sparks/${sparkId}/share`
111
113
  );
112
114
  return response.data;
113
115
  }
114
116
 
115
117
  /** Revoke a spark's SparkLink. Resolves whether or not a link existed. */
116
118
  async unshare(sparkId: string): Promise<UnshareSparkResponse> {
119
+ validateResourceId(sparkId, 'spark ID', 'sparkId');
117
120
  const response = await this.http.delete<UnshareSparkResponse>(
118
- `/sparks/${encodeURIComponent(sparkId)}/share`
121
+ apiPath`/sparks/${sparkId}/share`
119
122
  );
120
123
  return response.data;
121
124
  }