@sovaria/sdk 0.0.0-stage → 0.4.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/dist/index.js ADDED
@@ -0,0 +1,2287 @@
1
+ import { SovariaError, newTraceId, traceparentFor } from './errors.js';
2
+ export { SovariaError, userMessageFor } from './errors.js';
3
+ import { createAdminApi, createGovernanceApi, createKeysApi } from './complete-api.js';
4
+ import { createGeneratedApi } from './generated.js';
5
+ import { DocumentReference, Query, WriteBatch, runTransaction as runDocumentTransaction, } from './collections.js';
6
+ export { DocumentReference, FieldValue, LiveQuery, Query, Transaction, WriteBatch, newDocumentId, splitTransforms, } from './collections.js';
7
+ import { createOfflineTransport } from './offline.js';
8
+ import { CrashReporter } from './crashes.js';
9
+ export { CrashReporter, parseStack } from './crashes.js';
10
+ export { evaluateQuery, indexedDbStorage, keyValueStorage, memoryStorage, } from './offline.js';
11
+ import { decryptDocument, decryptDocumentWithKek, deriveKek, encryptDocument, encryptDocumentWithKek, hexToBytes, bytesToHex, zeroBytes, } from './crypto.js';
12
+ export { ARGON2_HASH_LEN, ARGON2_MEMORY_COST, ARGON2_PARALLELISM, ARGON2_SALT_LEN, ARGON2_TIME_COST, computePayloadHash, decryptDocument, decryptDocumentWithKek, deriveKek, DEK_SIZE_BYTES, encryptDocument, encryptDocumentWithKek, generateSalt, hexToBytes, bytesToHex, NONCE_SIZE_BYTES, zeroBytes, } from './crypto.js';
13
+ function storageObjectPath(bucket, objectPath) {
14
+ return [bucket, ...objectPath.split('/')].map((p) => safePathSegment(p)).join('/');
15
+ }
16
+ function resolveFetchImpl(fetchImpl) {
17
+ const candidate = fetchImpl ?? globalThis.fetch;
18
+ if (typeof candidate !== 'function') {
19
+ throw SovariaError.config('No fetch implementation available. Provide fetchImpl or run in an environment with global fetch.', 500);
20
+ }
21
+ // Browser fetch requires the Window context in some runtimes.
22
+ if (candidate === globalThis.fetch) {
23
+ return candidate.bind(globalThis);
24
+ }
25
+ return candidate;
26
+ }
27
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
28
+ const SCOPE_REQUIRED_PREFIXES = ['/api/v1/sql/', '/api/v1/data/rel/', '/api/v1/rt/'];
29
+ const SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
30
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
31
+ const REFRESH_MARGIN_MS = 60_000;
32
+ function sleep(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+ function getHeaderCaseInsensitive(headers, headerName) {
36
+ const wanted = headerName.toLowerCase();
37
+ for (const [key, value] of Object.entries(headers)) {
38
+ if (key.toLowerCase() === wanted) {
39
+ return value;
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ function generateIdempotencyKey() {
45
+ const cryptoObj = globalThis.crypto;
46
+ if (typeof cryptoObj?.randomUUID === 'function') {
47
+ return cryptoObj.randomUUID();
48
+ }
49
+ if (typeof cryptoObj?.getRandomValues === 'function') {
50
+ const bytes = new Uint8Array(16);
51
+ cryptoObj.getRandomValues(bytes);
52
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
53
+ }
54
+ throw SovariaError.config('No CSPRNG available for idempotency key generation', 500);
55
+ }
56
+ function existingIdempotencyKey(headers) {
57
+ const existing = getHeaderCaseInsensitive(headers, 'Idempotency-Key') ??
58
+ getHeaderCaseInsensitive(headers, 'X-Idempotency-Key');
59
+ return existing?.trim() || undefined;
60
+ }
61
+ /** Sets Idempotency-Key, dropping a differently-cased copy so the header is sent once. */
62
+ function setIdempotencyKeyHeader(headers, key) {
63
+ for (const name of Object.keys(headers)) {
64
+ if (name.toLowerCase() === 'idempotency-key')
65
+ delete headers[name];
66
+ }
67
+ headers['Idempotency-Key'] = key;
68
+ }
69
+ function redactRefreshToken(tokens) {
70
+ const redacted = { ...tokens };
71
+ delete redacted.refresh_token;
72
+ return redacted;
73
+ }
74
+ function looksLikeJwt(token) {
75
+ const parts = token.split('.');
76
+ return parts.length === 3 && parts.every(Boolean) && token.startsWith('eyJ');
77
+ }
78
+ function parseRetryAfterMs(retryAfterHeader) {
79
+ if (!retryAfterHeader) {
80
+ return null;
81
+ }
82
+ const seconds = Number.parseFloat(retryAfterHeader);
83
+ if (Number.isFinite(seconds) && seconds >= 0) {
84
+ return Math.floor(seconds * 1000);
85
+ }
86
+ const retryAtMs = Date.parse(retryAfterHeader);
87
+ if (Number.isNaN(retryAtMs)) {
88
+ return null;
89
+ }
90
+ return Math.max(0, retryAtMs - Date.now());
91
+ }
92
+ function computeRetryDelayMs(attempt, retryBaseDelayMs, retryAfterMs = null) {
93
+ const jitterBackoffMs = Math.min(10_000, retryBaseDelayMs * 2 ** attempt * Math.random());
94
+ if (retryAfterMs === null) {
95
+ return jitterBackoffMs;
96
+ }
97
+ return Math.max(jitterBackoffMs, Math.min(60_000, retryAfterMs));
98
+ }
99
+ function safePathSegment(value) {
100
+ return encodeURIComponent(value);
101
+ }
102
+ function docCollectionPath(collection, documentId) {
103
+ const collectionPath = collection
104
+ .split('/')
105
+ .map((segment) => safePathSegment(segment))
106
+ .join('/');
107
+ return `/api/v1/data/doc/${collectionPath}/${safePathSegment(documentId)}`;
108
+ }
109
+ function normalizeSubpath(path) {
110
+ return path.replace(/^\/+/, '');
111
+ }
112
+ function mergeQuery(query) {
113
+ const params = new URLSearchParams();
114
+ if (!query) {
115
+ return params;
116
+ }
117
+ for (const [key, value] of Object.entries(query)) {
118
+ if (value === undefined || value === null) {
119
+ continue;
120
+ }
121
+ params.set(key, String(value));
122
+ }
123
+ return params;
124
+ }
125
+ /**
126
+ * Inside an Edge Function invoked in simulation mode (Forge sets SOVARIA_SIMULATION=1): every
127
+ * Sovaria call is checked as usual and its writes are rolled back or refused, so nothing changes.
128
+ */
129
+ function simulationRun() {
130
+ const env = globalThis.process?.env;
131
+ const deno = globalThis.Deno;
132
+ let value;
133
+ try {
134
+ value = env?.SOVARIA_SIMULATION ?? deno?.env?.get('SOVARIA_SIMULATION');
135
+ }
136
+ catch {
137
+ value = undefined; // Deno without --allow-env
138
+ }
139
+ return (value ?? '').trim() === '1';
140
+ }
141
+ export class SovariaSDK {
142
+ baseUrl;
143
+ fetchImpl;
144
+ retryCount;
145
+ retryBaseDelayMs;
146
+ accessToken;
147
+ /** Project anon/publishable key: the token used when no user is signed in. */
148
+ publishableKey = '';
149
+ /** PKCE verifier for an in-flight provider sign-in (fallback when sessionStorage is unavailable). */
150
+ ssoVerifier;
151
+ /** PKCE verifier for an email link that opens an app (magic link / password reset). */
152
+ linkVerifier;
153
+ device;
154
+ attestationToken;
155
+ refreshToken;
156
+ expiresAtMs;
157
+ refreshInFlight;
158
+ scope;
159
+ /** In-flight lookup of the project/org behind a project URL (see resolveScope). */
160
+ scopeLookup;
161
+ onTokenRefresh;
162
+ onTokenRefreshSensitive;
163
+ sessionStore;
164
+ authListeners = new Set();
165
+ /** Session-cached Argon2id salt (hex from login/register). */
166
+ argon2Salt;
167
+ /** Session-cached KEK derived at login — reused for E2EE document ops. */
168
+ cachedKek;
169
+ constructor(options) {
170
+ this.baseUrl = options.baseUrl.replace(/\/$/, '');
171
+ this.fetchImpl = resolveFetchImpl(options.fetchImpl);
172
+ this.retryCount = options.retryCount ?? 4;
173
+ this.retryBaseDelayMs = options.retryBaseDelayMs ?? 100;
174
+ this.accessToken = options.accessToken ?? '';
175
+ this.refreshToken = options.refreshToken ?? '';
176
+ const ttlMs = Math.max((options.expiresIn ?? 3600) * 1000, REFRESH_MARGIN_MS + 1);
177
+ this.expiresAtMs = this.accessToken ? Date.now() + ttlMs : 0;
178
+ this.refreshInFlight = null;
179
+ this.onTokenRefresh = options.onTokenRefresh;
180
+ this.onTokenRefreshSensitive = options.onTokenRefreshSensitive;
181
+ this.device = options.device;
182
+ this.attestationToken = options.attestationToken;
183
+ this.configureSessionStore(options.persistSession);
184
+ }
185
+ /** Device + attestation headers (mobile apps); empty for a plain web or server client. */
186
+ async clientHeaders() {
187
+ const out = {};
188
+ if (this.device?.id) {
189
+ out['X-Sovaria-Device-ID'] = this.device.id;
190
+ if (this.device.name)
191
+ out['X-Sovaria-Device-Name'] = this.device.name;
192
+ if (this.device.platform)
193
+ out['X-Sovaria-Device-Platform'] = this.device.platform;
194
+ if (this.device.appVersion)
195
+ out['X-Sovaria-App-Version'] = this.device.appVersion;
196
+ }
197
+ if (this.attestationToken) {
198
+ const token = await this.attestationToken();
199
+ if (token)
200
+ out['X-Sovaria-Attestation'] = token;
201
+ }
202
+ return out;
203
+ }
204
+ /** Start a PKCE pair for an email link; the verifier is kept (session storage too) until the link is opened. */
205
+ async startLinkPkce() {
206
+ const verifier = randomVerifier();
207
+ this.linkVerifier = verifier;
208
+ try {
209
+ ;
210
+ (this.sessionStore?.storage ?? globalThis.sessionStorage)?.setItem('sovaria.link.verifier', verifier);
211
+ }
212
+ catch {
213
+ /* kept in memory only */
214
+ }
215
+ return s256(verifier);
216
+ }
217
+ takeLinkVerifier(explicit) {
218
+ if (explicit)
219
+ return explicit;
220
+ let stored;
221
+ try {
222
+ const storage = this.sessionStore?.storage ?? globalThis.sessionStorage;
223
+ stored = storage?.getItem('sovaria.link.verifier') ?? undefined;
224
+ storage?.removeItem('sovaria.link.verifier');
225
+ }
226
+ catch {
227
+ stored = undefined;
228
+ }
229
+ const verifier = stored ?? this.linkVerifier;
230
+ this.linkVerifier = undefined;
231
+ return verifier;
232
+ }
233
+ configureSessionStore(option) {
234
+ if (!option)
235
+ return;
236
+ const custom = typeof option === 'object' ? option : {};
237
+ let storage = custom.storage;
238
+ if (!storage) {
239
+ try {
240
+ storage = globalThis.localStorage;
241
+ }
242
+ catch {
243
+ storage = undefined; // blocked storage (privacy mode): the session lasts for this page only
244
+ }
245
+ }
246
+ if (!storage)
247
+ return;
248
+ let host = 'default';
249
+ try {
250
+ host = new URL(this.baseUrl).host;
251
+ }
252
+ catch {
253
+ /* relative base URL */
254
+ }
255
+ this.sessionStore = { storage, key: custom.key ?? `sovaria.session.${host}` };
256
+ }
257
+ /** Load a stored session (called once the publishable key is known, so sign-out falls back to it). */
258
+ restoreSession() {
259
+ const store = this.sessionStore;
260
+ if (!store)
261
+ return;
262
+ let saved = null;
263
+ try {
264
+ const raw = store.storage.getItem(store.key);
265
+ saved = raw ? JSON.parse(raw) : null;
266
+ }
267
+ catch {
268
+ saved = null;
269
+ }
270
+ if (!saved?.access_token)
271
+ return;
272
+ if (!saved.refresh_token && saved.expires_at <= Date.now()) {
273
+ this.writeSession(null);
274
+ return;
275
+ }
276
+ this.accessToken = saved.access_token;
277
+ this.refreshToken = saved.refresh_token ?? '';
278
+ this.expiresAtMs = saved.expires_at;
279
+ }
280
+ writeSession(session) {
281
+ const store = this.sessionStore;
282
+ if (!store)
283
+ return;
284
+ try {
285
+ if (session)
286
+ store.storage.setItem(store.key, JSON.stringify(session));
287
+ else
288
+ store.storage.removeItem(store.key);
289
+ }
290
+ catch {
291
+ /* storage full or blocked: keep working in memory */
292
+ }
293
+ }
294
+ currentSession() {
295
+ if (!this.accessToken || this.accessToken === this.publishableKey)
296
+ return null;
297
+ return {
298
+ access_token: this.accessToken,
299
+ ...(this.refreshToken ? { refresh_token: this.refreshToken } : {}),
300
+ expires_at: this.expiresAtMs,
301
+ };
302
+ }
303
+ emitAuth(event) {
304
+ const session = this.currentSession();
305
+ if (event !== 'INITIAL_SESSION')
306
+ this.writeSession(session);
307
+ for (const listener of [...this.authListeners]) {
308
+ try {
309
+ listener(event, session);
310
+ }
311
+ catch (error) {
312
+ console.error('SovariaSDK: onAuthStateChange listener failed', error);
313
+ }
314
+ }
315
+ }
316
+ static fromPortalConfig(config) {
317
+ const baseUrl = String(config.url ?? config.baseUrl ?? '').trim();
318
+ if (!baseUrl) {
319
+ throw SovariaError.config('Missing portal URL. Provide config.url or config.baseUrl.');
320
+ }
321
+ const accessToken = String(config.accessToken ??
322
+ config.access_token ??
323
+ config.publishableKey ??
324
+ config.publishable_key ??
325
+ config.anonKey ??
326
+ config.anon_key ??
327
+ '').trim();
328
+ const expiresInRaw = config.expiresIn ?? config.expires_in;
329
+ const sdk = new SovariaSDK({
330
+ baseUrl,
331
+ accessToken: accessToken || undefined,
332
+ expiresIn: typeof expiresInRaw === 'number' ? expiresInRaw : undefined,
333
+ onTokenRefresh: config.onTokenRefresh,
334
+ onTokenRefreshSensitive: config.onTokenRefreshSensitive,
335
+ });
336
+ const hasAppKey = Boolean(String(config.publishableKey ?? config.publishable_key ?? config.anonKey ?? config.anon_key ?? '').trim());
337
+ const persist = config.persistSession ?? (hasAppKey && typeof globalThis.window !== 'undefined');
338
+ sdk.configureSessionStore(persist);
339
+ const publishable = String(config.publishableKey ?? config.publishable_key ?? '').trim();
340
+ const fallbackKey = publishable || String(config.anonKey ?? config.anon_key ?? '').trim();
341
+ if (fallbackKey && !looksLikeJwt(fallbackKey)) {
342
+ sdk.publishableKey = fallbackKey;
343
+ }
344
+ if (publishable && looksLikeJwt(publishable)) {
345
+ console.warn('SovariaSDK: publishableKey looks like a user JWT. Use a portal publishable/anon key for frontend bootstrap; session JWTs belong in accessToken with expiresIn.');
346
+ }
347
+ const orgId = String(config.orgId ?? config.org_id ?? '').trim();
348
+ const projectId = String(config.projectId ?? config.project_id ?? '').trim();
349
+ const envId = String(config.envId ?? config.env_id ?? '').trim();
350
+ if (orgId && projectId) {
351
+ sdk.setScope({
352
+ orgId,
353
+ projectId,
354
+ envId: envId || undefined,
355
+ });
356
+ }
357
+ sdk.restoreSession();
358
+ return sdk;
359
+ }
360
+ static fromEnv(env = {}) {
361
+ const baseUrl = String(env.SOVARIA_URL ?? env.NEXT_PUBLIC_SOVARIA_URL ?? env.VITE_SOVARIA_URL ?? '').trim();
362
+ if (!baseUrl) {
363
+ throw SovariaError.config('Missing SOVARIA_URL (or NEXT_PUBLIC_SOVARIA_URL / VITE_SOVARIA_URL).');
364
+ }
365
+ return SovariaSDK.fromPortalConfig({
366
+ baseUrl,
367
+ orgId: env.SOVARIA_ORG_ID,
368
+ projectId: env.SOVARIA_PROJECT_ID,
369
+ envId: env.SOVARIA_ENV_ID,
370
+ accessToken: env.SOVARIA_ACCESS_TOKEN,
371
+ publishableKey: env.SOVARIA_PUBLISHABLE_KEY,
372
+ anonKey: env.SOVARIA_ANON_KEY,
373
+ expiresIn: env.SOVARIA_EXPIRES_IN ? Number(env.SOVARIA_EXPIRES_IN) : undefined,
374
+ });
375
+ }
376
+ setAccessToken(token, options = {}) {
377
+ this.accessToken = token.trim();
378
+ if (this.accessToken) {
379
+ const ttlMs = Math.max((options.expiresIn ?? 3600) * 1000, REFRESH_MARGIN_MS + 1);
380
+ this.expiresAtMs = Date.now() + ttlMs;
381
+ }
382
+ else {
383
+ this.expiresAtMs = 0;
384
+ }
385
+ return this;
386
+ }
387
+ setRefreshToken(token) {
388
+ this.refreshToken = token.trim();
389
+ return this;
390
+ }
391
+ clearAccessToken() {
392
+ const hadSession = this.currentSession() !== null;
393
+ this.accessToken = this.publishableKey;
394
+ this.refreshToken = '';
395
+ this.expiresAtMs = 0;
396
+ this.refreshInFlight = null;
397
+ this.clearKekCache();
398
+ if (hadSession)
399
+ this.emitAuth('SIGNED_OUT');
400
+ else
401
+ this.writeSession(null);
402
+ return this;
403
+ }
404
+ clearKekCache() {
405
+ if (this.cachedKek) {
406
+ zeroBytes(this.cachedKek);
407
+ }
408
+ this.cachedKek = undefined;
409
+ this.argon2Salt = undefined;
410
+ }
411
+ cacheArgon2SaltFromResponse(tokens) {
412
+ const saltHex = String(tokens.argon2_salt ?? '').trim();
413
+ if (!saltHex || Boolean(tokens.mfa_required)) {
414
+ return;
415
+ }
416
+ try {
417
+ this.argon2Salt = hexToBytes(saltHex);
418
+ }
419
+ catch {
420
+ // Non-fatal: E2EE calls will fall back to explicit password.
421
+ }
422
+ }
423
+ async tryDeriveKekFromPassword(password, tokens) {
424
+ if (!this.argon2Salt || Boolean(tokens.mfa_required)) {
425
+ return;
426
+ }
427
+ try {
428
+ const kek = await deriveKek(password, this.argon2Salt);
429
+ if (this.cachedKek) {
430
+ zeroBytes(this.cachedKek);
431
+ }
432
+ this.cachedKek = kek;
433
+ }
434
+ catch {
435
+ // Non-fatal: session remains valid; E2EE requires explicit password.
436
+ }
437
+ }
438
+ applyAuthResponse(tokens, event = 'SIGNED_IN') {
439
+ const accessToken = String(tokens.access_token ?? '');
440
+ const refreshToken = String(tokens.refresh_token ?? '');
441
+ const mfaRequired = Boolean(tokens.mfa_required);
442
+ if (accessToken) {
443
+ this.setAccessToken(accessToken, {
444
+ expiresIn: Number(tokens.expires_in ?? (mfaRequired ? 300 : 3600)),
445
+ });
446
+ }
447
+ if (refreshToken) {
448
+ this.setRefreshToken(refreshToken);
449
+ }
450
+ else if (mfaRequired) {
451
+ this.refreshToken = '';
452
+ }
453
+ this.cacheArgon2SaltFromResponse(tokens);
454
+ this.onTokenRefreshSensitive?.(tokens);
455
+ this.onTokenRefresh?.(redactRefreshToken(tokens));
456
+ if (accessToken && !mfaRequired)
457
+ this.emitAuth(event);
458
+ return tokens;
459
+ }
460
+ tokenNeedsRefresh() {
461
+ if (!this.accessToken || !this.refreshToken) {
462
+ return false;
463
+ }
464
+ return Date.now() >= this.expiresAtMs - REFRESH_MARGIN_MS;
465
+ }
466
+ /** Refresh when the token is near expiry, or always with `force` (the server said 401). */
467
+ async ensureAccessTokenFresh(force = false) {
468
+ if (!force && !this.tokenNeedsRefresh()) {
469
+ return;
470
+ }
471
+ if (this.refreshInFlight) {
472
+ await this.refreshInFlight;
473
+ return;
474
+ }
475
+ this.refreshInFlight = (async () => {
476
+ let refreshed;
477
+ try {
478
+ refreshed = await this.auth.refresh(this.refreshToken);
479
+ }
480
+ catch (error) {
481
+ // The refresh token was revoked or expired: the user is signed out, not "broken".
482
+ if (error instanceof SovariaError && [400, 401, 403].includes(error.statusCode)) {
483
+ this.clearAccessToken();
484
+ throw new SovariaError(401, error.detail, 'UNAUTHENTICATED');
485
+ }
486
+ throw error;
487
+ }
488
+ this.applyAuthResponse(refreshed, 'TOKEN_REFRESHED');
489
+ })();
490
+ try {
491
+ await this.refreshInFlight;
492
+ }
493
+ finally {
494
+ this.refreshInFlight = null;
495
+ }
496
+ }
497
+ setScope(scope) {
498
+ this.scope = {
499
+ orgId: scope.orgId.trim(),
500
+ projectId: scope.projectId.trim(),
501
+ envId: scope.envId?.trim(),
502
+ };
503
+ return this;
504
+ }
505
+ clearScope() {
506
+ this.scope = undefined;
507
+ this.scopeLookup = undefined;
508
+ return this;
509
+ }
510
+ /**
511
+ * The org/project this client talks to. Set explicitly with setScope(), or discovered once from
512
+ * the project URL (https://<ref>.sovarias.in) — so `createClient(projectUrl, anonKey)` is enough.
513
+ */
514
+ async resolveScope() {
515
+ if (this.scope?.orgId && this.scope?.projectId)
516
+ return this.scope;
517
+ this.scopeLookup ??= this.request('GET', '/api/v1/auth/public-config', {
518
+ requiresAuth: false,
519
+ requiresScope: false,
520
+ })
521
+ .then((cfg) => {
522
+ const project = (cfg?.project ?? {});
523
+ if (project.id && project.orgId) {
524
+ this.setScope({ orgId: String(project.orgId), projectId: String(project.id) });
525
+ }
526
+ return this.scope;
527
+ })
528
+ .catch(() => {
529
+ this.scopeLookup = undefined;
530
+ return undefined;
531
+ });
532
+ return this.scopeLookup;
533
+ }
534
+ getScopeHeaders() {
535
+ if (!this.scope) {
536
+ return {};
537
+ }
538
+ const headers = {
539
+ 'X-Sovaria-Org-ID': this.scope.orgId,
540
+ 'X-Sovaria-Project-ID': this.scope.projectId,
541
+ };
542
+ if (this.scope.envId) {
543
+ headers['X-Sovaria-Env-ID'] = this.scope.envId;
544
+ }
545
+ return headers;
546
+ }
547
+ getRealtimeWebSocketUrl(channel, ticket) {
548
+ const protocolSwapped = this.baseUrl
549
+ .replace(/^https:\/\//, 'wss://')
550
+ .replace(/^http:\/\//, 'ws://');
551
+ const params = new URLSearchParams();
552
+ const resolvedTicket = (ticket ?? '').trim();
553
+ if (resolvedTicket) {
554
+ params.set('ticket', resolvedTicket);
555
+ }
556
+ if (this.scope?.orgId) {
557
+ params.set('org_id', this.scope.orgId);
558
+ }
559
+ if (this.scope?.projectId) {
560
+ params.set('project_id', this.scope.projectId);
561
+ }
562
+ if (this.scope?.envId) {
563
+ params.set('env_id', this.scope.envId);
564
+ }
565
+ const suffix = params.toString();
566
+ const base = `${protocolSwapped}/api/v1/rt/ws/${safePathSegment(channel)}`;
567
+ return suffix ? `${base}?${suffix}` : base;
568
+ }
569
+ async issueRealtimeWebSocketTicket(channel) {
570
+ await this.resolveScope();
571
+ this.assertScopePresent();
572
+ const response = await this.request('POST', '/api/v1/auth/ws-ticket', {
573
+ body: {
574
+ channel,
575
+ org_id: this.scope?.orgId,
576
+ project_id: this.scope?.projectId,
577
+ env_id: this.scope?.envId,
578
+ },
579
+ requiresScope: true,
580
+ });
581
+ const ticket = String(response.ticket ?? '').trim();
582
+ if (!ticket) {
583
+ throw new SovariaError(502, 'Gateway did not return a websocket ticket');
584
+ }
585
+ return ticket;
586
+ }
587
+ async getRealtimeWebSocketUrlWithTicket(channel) {
588
+ const ticket = await this.issueRealtimeWebSocketTicket(channel);
589
+ return this.getRealtimeWebSocketUrl(channel, ticket);
590
+ }
591
+ offlineTransport;
592
+ /**
593
+ * Offline-first documents: reads fall back to a local cache, offline writes are applied locally at
594
+ * once and queued, and everything syncs in order when the connection is back. Call once, before
595
+ * reading or writing documents.
596
+ *
597
+ * ```ts
598
+ * const offline = sov.enableOffline({ storage: indexedDbStorage(), onSyncError: (e, writes) => console.warn(e, writes) })
599
+ * ```
600
+ */
601
+ enableOffline(options = {}) {
602
+ if (!this.offlineTransport) {
603
+ this.offlineTransport = createOfflineTransport(this.onlineDocumentsTransport(), {
604
+ namespace: this.scope?.projectId || 'default', ...options,
605
+ });
606
+ }
607
+ return this.offlineTransport.controller;
608
+ }
609
+ documentsTransport() {
610
+ return this.offlineTransport ?? this.onlineDocumentsTransport();
611
+ }
612
+ onlineDocumentsTransport() {
613
+ return {
614
+ request: (method, path, options) => this.request(method, path, options),
615
+ openChannel: (name, handlers) => this.realtime.channel(name, {
616
+ onMessage: (payload) => handlers.onMessage?.(payload),
617
+ onStatus: (status) => handlers.onStatus?.(status),
618
+ onError: handlers.onError,
619
+ }),
620
+ };
621
+ }
622
+ /**
623
+ * A document collection (or `users/alice/posts` subcollection): field queries, cursors,
624
+ * live listeners and `add`.
625
+ *
626
+ * ```ts
627
+ * const snap = await sov.collection('posts').where('tags', 'array-contains', 'news').orderBy('at', 'desc').limit(20).get()
628
+ * ```
629
+ */
630
+ collection(path) {
631
+ return new Query(this.documentsTransport(), path);
632
+ }
633
+ /** A document reference: `get`, `set` (merge), `create`, `update` with FieldValue transforms, `delete`, `onSnapshot`. */
634
+ doc(path) {
635
+ return new DocumentReference(this.documentsTransport(), path);
636
+ }
637
+ /** Writes committed together: every one or none. */
638
+ batch() {
639
+ return new WriteBatch(this.documentsTransport());
640
+ }
641
+ /** Read then write atomically; retried (up to `maxAttempts`) when a document it read changes meanwhile. */
642
+ runTransaction(fn, maxAttempts = 5) {
643
+ return runDocumentTransaction(this.documentsTransport(), fn, maxAttempts);
644
+ }
645
+ /**
646
+ * App crash reporting: `sov.crashes.install({ release })` reports uncaught errors (browser and React
647
+ * Native); `sov.crashes.capture(error)` reports one you caught. Issues appear in the console under
648
+ * Observability › Crashes.
649
+ */
650
+ crashes = new CrashReporter((events) => this.request('POST', '/crashes/v1/events', { body: { events }, requiresScope: false }));
651
+ async request(method, path, options = {}) {
652
+ if (path.startsWith('/crashes/v1'))
653
+ return this.requestImpl(method, path, options);
654
+ let status = 0;
655
+ try {
656
+ const out = await this.requestImpl(method, path, options);
657
+ status = 200;
658
+ return out;
659
+ }
660
+ catch (error) {
661
+ status = error instanceof SovariaError ? error.statusCode : 0;
662
+ throw error;
663
+ }
664
+ finally {
665
+ // Breadcrumb for crash reports: which Sovaria calls led up to an error (no bodies or tokens).
666
+ this.crashes.addBreadcrumb({ category: 'sovaria', message: `${method.toUpperCase()} ${path.split('?')[0]} ${status || 'network error'}` });
667
+ }
668
+ }
669
+ async requestImpl(method, path, options = {}) {
670
+ const normalizedMethod = method.toUpperCase();
671
+ const requiresAuth = options.requiresAuth ?? true;
672
+ const requiresScope = options.requiresScope ?? this.pathRequiresScope(path);
673
+ if (requiresAuth && !this.accessToken) {
674
+ throw new SovariaError(401, 'Missing access token; call auth.consoleLogin() / auth.projectLogin() or setAccessToken() first');
675
+ }
676
+ if (requiresAuth) {
677
+ await this.ensureAccessTokenFresh();
678
+ }
679
+ if (requiresAuth && requiresScope) {
680
+ if (!this.scope)
681
+ await this.resolveScope();
682
+ this.assertScopePresent();
683
+ }
684
+ const queryParams = mergeQuery(options.query);
685
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
686
+ const url = `${this.baseUrl}${normalizedPath}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
687
+ const headers = {
688
+ ...this.getScopeHeaders(),
689
+ ...(await this.clientHeaders()),
690
+ ...(simulationRun() ? { 'X-Echo-Mode': '1' } : {}),
691
+ ...(options.headers ?? {}),
692
+ };
693
+ if (requiresAuth) {
694
+ headers.Authorization = `Bearer ${this.accessToken}`;
695
+ }
696
+ const hasBody = options.body !== undefined;
697
+ if (hasBody && !(options.body instanceof FormData) && !headers['Content-Type']) {
698
+ headers['Content-Type'] = 'application/json';
699
+ }
700
+ // One Idempotency-Key for every attempt of a write (the caller's, or a fresh one): if the first
701
+ // try was applied but its response was lost, the retry gets the stored answer instead of
702
+ // running the change twice.
703
+ const idempotencyKey = options.idempotencyKey?.trim() ||
704
+ existingIdempotencyKey(headers) ||
705
+ (MUTATING_METHODS.has(normalizedMethod) ? generateIdempotencyKey() : undefined);
706
+ if (idempotencyKey) {
707
+ setIdempotencyKeyHeader(headers, idempotencyKey);
708
+ }
709
+ const canRetry = SAFE_RETRY_METHODS.has(normalizedMethod) || MUTATING_METHODS.has(normalizedMethod);
710
+ // One trace per call (every retry shares it), so a refused request can be explained later.
711
+ const callerTrace = Object.keys(headers).find((k) => k.toLowerCase() === 'traceparent');
712
+ const traceId = callerTrace ? headers[callerTrace].split('-')[1] ?? newTraceId() : newTraceId();
713
+ for (let attempt = 0; attempt < this.retryCount; attempt += 1) {
714
+ if (!callerTrace)
715
+ headers.traceparent = traceparentFor(traceId);
716
+ try {
717
+ const fetchImpl = this.fetchImpl;
718
+ const response = await fetchImpl(url, {
719
+ method: normalizedMethod,
720
+ headers,
721
+ body: hasBody
722
+ ? options.body instanceof FormData
723
+ ? options.body
724
+ : JSON.stringify(options.body)
725
+ : undefined,
726
+ });
727
+ if (response.ok && options.responseType === 'blob') {
728
+ return (await response.blob());
729
+ }
730
+ const contentType = response.headers.get('content-type') ?? '';
731
+ const isJson = contentType.includes('application/json');
732
+ const payload = isJson
733
+ ? await response.json().catch(() => ({}))
734
+ : await response.text().catch(() => '');
735
+ if (response.ok) {
736
+ options.onResponseHeaders?.(response.headers);
737
+ if (response.status === 204) {
738
+ return {};
739
+ }
740
+ return payload;
741
+ }
742
+ if (response.status === 401 &&
743
+ requiresAuth &&
744
+ this.refreshToken &&
745
+ attempt === 0) {
746
+ await this.ensureAccessTokenFresh(true);
747
+ headers.Authorization = `Bearer ${this.accessToken}`;
748
+ continue;
749
+ }
750
+ // "The same Idempotency-Key is still being processed": wait, then the retry gets the first
751
+ // try's stored answer instead of running the change twice.
752
+ const inProgress = response.status === 409 && payload?.code === 'IDEMPOTENCY_IN_PROGRESS';
753
+ if (canRetry && (RETRYABLE_STATUSES.has(response.status) || inProgress) && attempt < this.retryCount - 1) {
754
+ const retryAfterMs = response.status === 429 || inProgress ? parseRetryAfterMs(response.headers.get('Retry-After')) : null;
755
+ const delay = computeRetryDelayMs(attempt, this.retryBaseDelayMs, retryAfterMs);
756
+ await sleep(delay);
757
+ continue;
758
+ }
759
+ throw new SovariaError(response.status, payload).withTraceId(response.headers.get('X-Request-ID') ?? traceId);
760
+ }
761
+ catch (error) {
762
+ if (error instanceof SovariaError) {
763
+ throw error;
764
+ }
765
+ if (attempt >= this.retryCount - 1 || !canRetry) {
766
+ throw new SovariaError(0, String(error));
767
+ }
768
+ const delay = computeRetryDelayMs(attempt, this.retryBaseDelayMs);
769
+ await sleep(delay);
770
+ }
771
+ }
772
+ throw new SovariaError(0, 'Max retries exceeded');
773
+ }
774
+ pathRequiresScope(path) {
775
+ return SCOPE_REQUIRED_PREFIXES.some((prefix) => path.startsWith(prefix));
776
+ }
777
+ assertScopePresent() {
778
+ if (this.scope?.orgId && this.scope?.projectId) {
779
+ return;
780
+ }
781
+ throw SovariaError.config('This endpoint requires org/project scope. Call setScope({ orgId, projectId, envId? }).');
782
+ }
783
+ async productAuthRequest(method, suffix, options = {}) {
784
+ await this.resolveScope();
785
+ return this.request(method, this.productAuthPath(suffix), options);
786
+ }
787
+ productAuthPath(suffix) {
788
+ this.assertScopePresent();
789
+ return `/api/v1/projects/${safePathSegment(this.scope.projectId)}/auth${suffix}`;
790
+ }
791
+ auth = {
792
+ /**
793
+ * Listen for sign-in, token refresh and sign-out. Fires once right away with the current
794
+ * session (`INITIAL_SESSION`). Returns a function that stops listening.
795
+ */
796
+ onAuthStateChange: (callback) => {
797
+ this.authListeners.add(callback);
798
+ queueMicrotask(() => {
799
+ if (!this.authListeners.has(callback))
800
+ return;
801
+ try {
802
+ callback('INITIAL_SESSION', this.currentSession());
803
+ }
804
+ catch (error) {
805
+ console.error('SovariaSDK: onAuthStateChange listener failed', error);
806
+ }
807
+ });
808
+ return () => {
809
+ this.authListeners.delete(callback);
810
+ };
811
+ },
812
+ /** The signed-in app user's session, or null. */
813
+ getSession: () => this.currentSession(),
814
+ /** Sign the app user out (revokes the refresh token) and fall back to the anon key. */
815
+ signOut: async () => {
816
+ await this.auth.logout();
817
+ },
818
+ /**
819
+ * Create an app user and sign them in. When the project requires email confirmation, the
820
+ * result has `email_verification_required: true` and no session until the user confirms.
821
+ */
822
+ signUp: async (email, password, name = '') => {
823
+ const created = await this.auth.register(email, password, name);
824
+ if (created.access_token || created.email_verification_required)
825
+ return created;
826
+ const session = await this.auth.projectLogin(email, password);
827
+ return { ...created, ...session };
828
+ },
829
+ /** Sign an app user in with email and password (same as projectLogin). */
830
+ signIn: async (email, password) => this.auth.projectLogin(email, password),
831
+ /** Create an app user (no session). Most apps want signUp(), which also signs them in. */
832
+ register: async (email, password, name) => {
833
+ await this.resolveScope();
834
+ const scopePayload = this.scope
835
+ ? {
836
+ org_id: this.scope.orgId,
837
+ project_id: this.scope.projectId,
838
+ ...(this.scope.envId ? { env_id: this.scope.envId } : {}),
839
+ }
840
+ : {};
841
+ const tokens = await this.request('POST', '/api/v1/auth/register', {
842
+ requiresAuth: false,
843
+ body: { email, password, name, ...scopePayload },
844
+ });
845
+ if (tokens.access_token) {
846
+ this.applyAuthResponse(tokens);
847
+ await this.tryDeriveKekFromPassword(password, tokens);
848
+ }
849
+ return tokens;
850
+ },
851
+ /**
852
+ * @deprecated Use {@link consoleLogin} for operators or {@link projectLogin}
853
+ * for product users. Unscoped calls post to `/api/v1/auth/console/login`.
854
+ */
855
+ login: async (email, password) => {
856
+ console.warn('auth.login() is deprecated; use auth.consoleLogin() for operators or auth.projectLogin() for product users. Unscoped login() posts to /api/v1/auth/console/login (not legacy /api/v1/auth/login).');
857
+ await this.resolveScope();
858
+ const scopePayload = this.scope
859
+ ? {
860
+ org_id: this.scope.orgId,
861
+ project_id: this.scope.projectId,
862
+ ...(this.scope.envId ? { env_id: this.scope.envId } : {}),
863
+ }
864
+ : {};
865
+ const path = this.scope?.orgId && this.scope?.projectId
866
+ ? '/api/v1/auth/project/login'
867
+ : '/api/v1/auth/console/login';
868
+ const tokens = await this.request('POST', path, {
869
+ requiresAuth: false,
870
+ body: { email, password, ...scopePayload },
871
+ });
872
+ this.applyAuthResponse(tokens);
873
+ await this.tryDeriveKekFromPassword(password, tokens);
874
+ return tokens;
875
+ },
876
+ /** Strict console login — rejects every project-scoped credential. */
877
+ consoleLogin: async (email, password) => {
878
+ const tokens = await this.request('POST', '/api/v1/auth/console/login', {
879
+ requiresAuth: false,
880
+ body: { email, password },
881
+ });
882
+ this.applyAuthResponse(tokens);
883
+ await this.tryDeriveKekFromPassword(password, tokens);
884
+ return tokens;
885
+ },
886
+ /** Alias for consoleLogin() matching lane terminology in docs. */
887
+ loginConsole: async (email, password) => this.auth.consoleLogin(email, password),
888
+ /** Strict project login — requires X-Sovaria-Org-ID + X-Sovaria-Project-ID. */
889
+ projectLogin: async (email, password) => {
890
+ await this.resolveScope();
891
+ if (!this.scope?.orgId || !this.scope?.projectId) {
892
+ throw SovariaError.config('projectLogin() needs the project: create the client with your project URL, or call setScope({ orgId, projectId }).');
893
+ }
894
+ const tokens = await this.request('POST', '/api/v1/auth/project/login', {
895
+ requiresAuth: false,
896
+ body: {
897
+ email,
898
+ password,
899
+ org_id: this.scope.orgId,
900
+ project_id: this.scope.projectId,
901
+ ...(this.scope.envId ? { env_id: this.scope.envId } : {}),
902
+ },
903
+ });
904
+ this.applyAuthResponse(tokens);
905
+ await this.tryDeriveKekFromPassword(password, tokens);
906
+ return tokens;
907
+ },
908
+ /** Alias for projectLogin() matching lane terminology in docs. */
909
+ loginProduct: async (email, password) => this.auth.projectLogin(email, password),
910
+ refresh: async (refreshToken) => this.request('POST', '/api/v1/auth/refresh', {
911
+ requiresAuth: false,
912
+ body: { refresh_token: refreshToken },
913
+ }),
914
+ logout: async (refreshToken) => {
915
+ const token = (refreshToken ?? this.refreshToken).trim();
916
+ try {
917
+ if (this.accessToken && token) {
918
+ return await this.request('POST', '/api/v1/auth/logout', {
919
+ body: { refresh_token: token },
920
+ });
921
+ }
922
+ return {};
923
+ }
924
+ finally {
925
+ this.clearAccessToken();
926
+ }
927
+ },
928
+ me: async () => {
929
+ const me = await this.request('GET', '/api/v1/auth/me');
930
+ const orgId = String(me.org_id ?? '');
931
+ const projectId = String(me.project_id ?? '');
932
+ const envId = String(me.env_id ?? '');
933
+ if (orgId && projectId && !this.scope) {
934
+ this.setScope({ orgId, projectId, envId: envId || undefined });
935
+ }
936
+ return me;
937
+ },
938
+ verifyMfa: async (code, method = 'totp') => {
939
+ const tokens = await this.request('POST', '/api/v1/auth/mfa/verify', {
940
+ body: { code, method },
941
+ });
942
+ return this.applyAuthResponse(tokens);
943
+ },
944
+ /** Send a one-time login link by email. Returns 200 even for unknown emails. */
945
+ /**
946
+ * Email a sign-in link (plus a 6-digit code). A link that opens a mobile app (`myapp://…` or a
947
+ * registered app link) is bound to this app with PKCE automatically; pass `pkce: true` to force it.
948
+ */
949
+ sendMagicLink: async (email, opts = {}) => {
950
+ const orgId = opts.orgId ?? this.scope?.orgId;
951
+ const projectId = opts.projectId ?? this.scope?.projectId;
952
+ const challenge = opts.pkce || isAppRedirect(opts.redirectTo) ? await this.startLinkPkce() : undefined;
953
+ return this.request('POST', '/api/v1/auth/magic-link/send', {
954
+ requiresAuth: false,
955
+ body: {
956
+ email,
957
+ ...(orgId ? { org_id: orgId } : {}),
958
+ ...(projectId ? { project_id: projectId } : {}),
959
+ // Must be your Site URL or in the project's redirect allow-list; otherwise the Site URL is used.
960
+ ...(opts.redirectTo ? { redirect_to: opts.redirectTo } : {}),
961
+ ...(challenge ? { code_challenge: challenge } : {}),
962
+ },
963
+ });
964
+ },
965
+ /** Email a password-reset link. Returns 200 even for unknown emails. */
966
+ resetPasswordForEmail: async (email, opts = {}) => {
967
+ const orgId = opts.orgId ?? this.scope?.orgId;
968
+ const projectId = opts.projectId ?? this.scope?.projectId;
969
+ const challenge = opts.pkce || isAppRedirect(opts.redirectTo) ? await this.startLinkPkce() : undefined;
970
+ return this.request('POST', '/api/v1/auth/password-reset/send', {
971
+ requiresAuth: false,
972
+ body: {
973
+ email,
974
+ ...(orgId ? { org_id: orgId } : {}),
975
+ ...(projectId ? { project_id: projectId } : {}),
976
+ ...(opts.redirectTo ? { redirect_to: opts.redirectTo } : {}),
977
+ ...(challenge ? { code_challenge: challenge } : {}),
978
+ },
979
+ });
980
+ },
981
+ /** Set a new password with the token from a reset link (`type=recovery`). */
982
+ confirmPasswordReset: (token, newPassword, codeVerifier) => {
983
+ const verifier = this.takeLinkVerifier(codeVerifier);
984
+ return this.request('POST', '/api/v1/auth/password-reset/confirm', {
985
+ requiresAuth: false,
986
+ body: { token, new_password: newPassword, ...(verifier ? { code_verifier: verifier } : {}) },
987
+ });
988
+ },
989
+ /**
990
+ * Handle the link an auth email opened (`?token=…&type=…` on your Site URL).
991
+ * Magic links sign the user in; email confirmations are confirmed; for `recovery`
992
+ * the token is returned so you can show a "new password" form and call
993
+ * `confirmPasswordReset`. Returns null when the URL isn't an auth link.
994
+ */
995
+ handleEmailLink: async (url) => {
996
+ const href = url ?? (typeof window !== 'undefined' ? window.location.href : '');
997
+ if (!href)
998
+ return null;
999
+ const params = new URL(href).searchParams;
1000
+ const type = params.get('type');
1001
+ if (type === 'sso') {
1002
+ const error = params.get('error');
1003
+ if (error)
1004
+ return { type, error, description: params.get('error_description') ?? undefined };
1005
+ const code = params.get('code');
1006
+ return code ? { type, session: await this.auth.exchangeCodeForSession(code) } : null;
1007
+ }
1008
+ const token = params.get('token');
1009
+ if (!token || !type)
1010
+ return null;
1011
+ if (type === 'magiclink')
1012
+ return { type, session: await this.auth.verifyMagicLink(token) };
1013
+ if (type === 'signup')
1014
+ return { type, result: await this.auth.confirmVerificationEmail(token) };
1015
+ if (type === 'email_change_current')
1016
+ return { type, result: await this.request('POST', '/api/v1/auth/email-change/confirm', { requiresAuth: false, body: { token } }) };
1017
+ if (type === 'email_change_new')
1018
+ return { type, result: await this.request('POST', '/api/v1/auth/email-change/finalize', { requiresAuth: false, body: { token } }) };
1019
+ return { type, token };
1020
+ },
1021
+ /**
1022
+ * "Sign in with Google / GitHub / …" (providers connected in Authentication › Sign in / Providers).
1023
+ * Sends the browser to the provider; it comes back to `redirectTo` (or your Site URL) with
1024
+ * `?code=…&type=sso`, which `handleEmailLink()` / `exchangeCodeForSession()` turn into a session.
1025
+ * A PKCE verifier is kept in sessionStorage so only this browser can redeem the code.
1026
+ */
1027
+ signInWithProvider: async (providerId, opts = {}) => {
1028
+ const projectId = opts.projectId ?? (await this.resolveScope())?.projectId;
1029
+ if (!projectId)
1030
+ throw SovariaError.config('signInWithProvider() needs a project id (use the project URL or setScope first).');
1031
+ const verifier = randomVerifier();
1032
+ const challenge = await s256(verifier);
1033
+ this.ssoVerifier = verifier;
1034
+ try {
1035
+ globalThis.sessionStorage?.setItem('sovaria.sso.verifier', verifier);
1036
+ }
1037
+ catch {
1038
+ /* storage unavailable: the code can still be exchanged without PKCE */
1039
+ }
1040
+ const params = new URLSearchParams({ project_id: projectId, code_challenge: challenge, code_challenge_method: 'S256', mode: 'json' });
1041
+ if (opts.redirectTo)
1042
+ params.set('redirect_to', opts.redirectTo);
1043
+ const res = await this.request('GET', `/api/v1/auth/sso/${encodeURIComponent(providerId)}/authorize?${params}`, { requiresAuth: false });
1044
+ const url = String(res.authorization_url ?? '');
1045
+ if (typeof window !== 'undefined' && url)
1046
+ window.location.assign(url);
1047
+ return { url };
1048
+ },
1049
+ /** Exchange the one-time `code` from a provider sign-in redirect for a session. */
1050
+ exchangeCodeForSession: async (code, codeVerifier) => {
1051
+ let verifier = codeVerifier;
1052
+ if (!verifier) {
1053
+ try {
1054
+ verifier = globalThis.sessionStorage?.getItem('sovaria.sso.verifier') ?? undefined;
1055
+ globalThis.sessionStorage?.removeItem('sovaria.sso.verifier');
1056
+ }
1057
+ catch {
1058
+ verifier = undefined;
1059
+ }
1060
+ verifier = verifier ?? this.ssoVerifier;
1061
+ }
1062
+ this.ssoVerifier = undefined;
1063
+ const tokens = await this.request('POST', '/api/v1/auth/sso/exchange', {
1064
+ requiresAuth: false,
1065
+ body: { code, ...(verifier ? { code_verifier: verifier } : {}) },
1066
+ });
1067
+ return this.applyAuthResponse(tokens);
1068
+ },
1069
+ /** Enabled social / enterprise providers for your sign-in screen. */
1070
+ listProviders: async (projectId) => {
1071
+ const pid = projectId ?? (await this.resolveScope())?.projectId;
1072
+ if (!pid)
1073
+ throw SovariaError.config('listProviders() needs a project id (use the project URL or setScope first).');
1074
+ return this.request('GET', `/api/v1/auth/sso/providers?project_id=${encodeURIComponent(pid)}`, { requiresAuth: false });
1075
+ },
1076
+ /** Public sign-in config for a project: enabled providers, password rules, MFA factors, passkeys. */
1077
+ getPublicConfig: (projectId) => {
1078
+ const pid = projectId ?? this.scope?.projectId;
1079
+ // Without an id the project host (https://<ref>.sovarias.in) identifies the project.
1080
+ const query = pid ? `?project_id=${encodeURIComponent(pid)}` : '';
1081
+ return this.request('GET', `/api/v1/auth/public-config${query}`, {
1082
+ requiresAuth: false,
1083
+ requiresScope: false,
1084
+ });
1085
+ },
1086
+ /** Exchange a magic-link token for an access + refresh token pair. */
1087
+ verifyMagicLink: async (token, codeVerifier) => {
1088
+ const verifier = this.takeLinkVerifier(codeVerifier);
1089
+ const tokens = await this.request('POST', '/api/v1/auth/magic-link/verify', {
1090
+ requiresAuth: false,
1091
+ body: { token, ...(verifier ? { code_verifier: verifier } : {}) },
1092
+ });
1093
+ return this.applyAuthResponse(tokens);
1094
+ },
1095
+ /**
1096
+ * Sign in with the 6-digit code from the sign-in email (the same email carries the magic link).
1097
+ * Uses the client scope's org/project unless given.
1098
+ */
1099
+ verifyEmailCode: async (email, code, opts = {}) => {
1100
+ const orgId = opts.orgId ?? this.scope?.orgId;
1101
+ const projectId = opts.projectId ?? this.scope?.projectId;
1102
+ const tokens = await this.request('POST', '/api/v1/auth/magic-link/verify-code', {
1103
+ requiresAuth: false,
1104
+ body: { email, code: code.replace(/\s+/g, ''), ...(orgId ? { org_id: orgId } : {}), ...(projectId ? { project_id: projectId } : {}) },
1105
+ });
1106
+ return this.applyAuthResponse(tokens);
1107
+ },
1108
+ /** Send an SMS OTP code for phone-based login. */
1109
+ sendOtp: (phone, opts = {}) => {
1110
+ const body = { phone };
1111
+ const orgId = opts.orgId ?? this.scope?.orgId;
1112
+ const projectId = opts.projectId ?? this.scope?.projectId;
1113
+ const envId = opts.envId ?? this.scope?.envId;
1114
+ if (orgId) {
1115
+ body.org_id = orgId;
1116
+ }
1117
+ if (projectId) {
1118
+ body.project_id = projectId;
1119
+ }
1120
+ if (envId) {
1121
+ body.env_id = envId;
1122
+ }
1123
+ return this.request('POST', '/api/v1/auth/otp/send', {
1124
+ requiresAuth: false,
1125
+ body,
1126
+ });
1127
+ },
1128
+ /** Verify an SMS OTP code and establish a session.
1129
+ * Passwordless by default — pass `password` when argon2_salt is present to
1130
+ * populate the session KEK without a follow-up password login.
1131
+ */
1132
+ verifyOtp: async (phone, code, opts = {}) => {
1133
+ const body = { phone, code };
1134
+ const orgId = opts.orgId ?? this.scope?.orgId;
1135
+ const projectId = opts.projectId ?? this.scope?.projectId;
1136
+ const envId = opts.envId ?? this.scope?.envId;
1137
+ if (orgId) {
1138
+ body.org_id = orgId;
1139
+ }
1140
+ if (projectId) {
1141
+ body.project_id = projectId;
1142
+ }
1143
+ if (envId) {
1144
+ body.env_id = envId;
1145
+ }
1146
+ const tokens = await this.request('POST', '/api/v1/auth/otp/verify', {
1147
+ requiresAuth: false,
1148
+ body,
1149
+ });
1150
+ this.applyAuthResponse(tokens);
1151
+ if (opts.password) {
1152
+ await this.tryDeriveKekFromPassword(opts.password, tokens);
1153
+ }
1154
+ return tokens;
1155
+ },
1156
+ /** Exchange an OAuth handoff token for a session (console OAuth callback).
1157
+ * Passwordless: session KEK is not derived unless `password` is supplied.
1158
+ */
1159
+ oauthHandoff: async (token, password) => {
1160
+ const tokens = await this.request('POST', '/api/v1/auth/oauth/handoff', {
1161
+ requiresAuth: false,
1162
+ body: { token },
1163
+ });
1164
+ this.applyAuthResponse(tokens);
1165
+ if (password) {
1166
+ await this.tryDeriveKekFromPassword(password, tokens);
1167
+ }
1168
+ return tokens;
1169
+ },
1170
+ /** Console-operator listing of product users across accessible projects. Path: GET /api/v1/auth/product-users (console lane). */
1171
+ listConsoleProductUsers: () => this.request('GET', '/api/v1/auth/product-users'),
1172
+ /** Trigger a verification email for the authenticated user. */
1173
+ sendVerificationEmail: () => this.request('POST', '/api/v1/auth/verify-email/send', {
1174
+ body: {},
1175
+ }),
1176
+ /** Confirm an email-verification token. */
1177
+ confirmVerificationEmail: (token) => this.request('POST', '/api/v1/auth/verify-email/confirm', {
1178
+ requiresAuth: false,
1179
+ body: { token },
1180
+ }),
1181
+ /**
1182
+ * Auth event hooks (per-project HTTP webhooks). The project is the client's scope
1183
+ * (`X-Sovaria-Project-ID`); the caller must be an owner or admin of it.
1184
+ */
1185
+ eventHooks: {
1186
+ list: () => this.request('GET', '/api/v1/auth/event-hooks', {
1187
+ requiresScope: false,
1188
+ }),
1189
+ create: (payload) => this.request('POST', '/api/v1/auth/event-hooks', {
1190
+ body: {
1191
+ name: payload.name,
1192
+ url: payload.url,
1193
+ events: payload.events,
1194
+ secret: payload.secret ?? '',
1195
+ enabled: payload.enabled ?? true,
1196
+ },
1197
+ // A project other than the client's scope is named by the header, never the body.
1198
+ ...(payload.projectId ? { headers: { 'X-Sovaria-Project-ID': payload.projectId } } : {}),
1199
+ requiresScope: false,
1200
+ }),
1201
+ delete: (hookId) => this.request('DELETE', `/api/v1/auth/event-hooks/${safePathSegment(hookId)}`, { requiresScope: false }),
1202
+ },
1203
+ /**
1204
+ * @deprecated Legacy GET `/api/v1/auth/users`. Use `productUsers.list()` for
1205
+ * project-scoped product users or `listConsoleProductUsers()` for the console
1206
+ * product-user admin path. Use `consoleMembers.list()` for org console members.
1207
+ */
1208
+ listUsers: () => {
1209
+ console.warn('auth.listUsers() is deprecated (legacy GET /api/v1/auth/users). ' +
1210
+ 'For product users use auth.productUsers.list() ' +
1211
+ '(GET /api/v1/projects/{id}/auth/users) or auth.listConsoleProductUsers() ' +
1212
+ '(GET /api/v1/auth/product-users). For org console members use auth.consoleMembers.list() ' +
1213
+ '(GET /api/v1/org/{orgId}/members).');
1214
+ if (this.scope?.orgId && this.scope?.projectId) {
1215
+ return this.auth.productUsers.list();
1216
+ }
1217
+ return this.auth.listConsoleProductUsers();
1218
+ },
1219
+ /**
1220
+ * @deprecated Legacy DELETE `/api/v1/auth/users/{id}`. Use `productUsers.delete()`
1221
+ * or `consoleMembers.delete()`.
1222
+ */
1223
+ deleteUser: (userId) => {
1224
+ console.warn('auth.deleteUser() is deprecated (legacy DELETE /api/v1/auth/users/{id}). ' +
1225
+ 'Use auth.productUsers.delete() for product users or auth.consoleMembers.delete() for console members.');
1226
+ if (this.scope?.orgId && this.scope?.projectId) {
1227
+ return this.auth.productUsers.delete(userId);
1228
+ }
1229
+ return this.request('DELETE', `/api/v1/auth/product-users/${safePathSegment(userId)}`);
1230
+ },
1231
+ /**
1232
+ * @deprecated Legacy POST `/api/v1/auth/users/{id}/ban`. Use `productUsers.ban()`.
1233
+ */
1234
+ banUser: (userId) => {
1235
+ console.warn('auth.banUser() is deprecated (legacy POST /api/v1/auth/users/{id}/ban). ' +
1236
+ 'Use auth.productUsers.ban() for product users.');
1237
+ if (this.scope?.orgId && this.scope?.projectId) {
1238
+ return this.auth.productUsers.ban(userId);
1239
+ }
1240
+ return this.request('POST', `/api/v1/auth/product-users/${safePathSegment(userId)}/ban`);
1241
+ },
1242
+ /** Org console-member management. Canonical path: GET /api/v1/org/{orgId}/members. Not product users. */
1243
+ consoleMembers: {
1244
+ list: () => {
1245
+ const orgId = this.scope?.orgId;
1246
+ if (!orgId) {
1247
+ throw SovariaError.config('consoleMembers.list() requires setScope({ orgId, projectId }). Canonical path: GET /api/v1/org/{orgId}/members. For product users use auth.productUsers.list() or auth.listConsoleProductUsers().');
1248
+ }
1249
+ return this.request('GET', `/api/v1/org/${safePathSegment(orgId)}/members`);
1250
+ },
1251
+ delete: (userId) => {
1252
+ const orgId = this.scope?.orgId;
1253
+ if (!orgId) {
1254
+ throw SovariaError.config('consoleMembers.delete() requires setScope({ orgId, projectId }). Canonical path: DELETE /api/v1/org/{orgId}/member/{userId}.');
1255
+ }
1256
+ return this.request('DELETE', `/api/v1/org/${safePathSegment(orgId)}/member/${safePathSegment(userId)}`);
1257
+ },
1258
+ ban: (userId) => {
1259
+ console.warn('auth.consoleMembers.ban() is not a console-member route. Use auth.productUsers.ban() for product users. Legacy POST /api/v1/auth/users/{id}/ban is deprecated.');
1260
+ if (this.scope?.orgId && this.scope?.projectId) {
1261
+ return this.auth.productUsers.ban(userId);
1262
+ }
1263
+ return this.request('POST', `/api/v1/auth/product-users/${safePathSegment(userId)}/ban`);
1264
+ },
1265
+ },
1266
+ /** Product auth admin (project-scoped identities). Path: GET /api/v1/projects/{id}/auth/users. Requires setScope. */
1267
+ productUsers: {
1268
+ /** Every product user: follows all pages. Use `listPage()` for big projects. */
1269
+ list: async () => {
1270
+ const all = [];
1271
+ let cursor = null;
1272
+ do {
1273
+ const page = await this.auth.productUsers.listPage({ limit: 500, cursor });
1274
+ all.push(...page.users);
1275
+ cursor = page.nextCursor;
1276
+ } while (cursor);
1277
+ return all;
1278
+ },
1279
+ /** One page of product users, newest first. `search` matches part of an email or an exact id. */
1280
+ listPage: async (params = {}) => {
1281
+ let headers = null;
1282
+ const limit = Math.max(1, Math.min(Math.trunc(params.limit ?? 50), 500));
1283
+ const body = await this.productAuthRequest('GET', '/users', {
1284
+ requiresScope: true,
1285
+ query: { limit, cursor: params.cursor || undefined, q: params.search || undefined, status: params.status },
1286
+ onResponseHeaders: (h) => {
1287
+ headers = h;
1288
+ },
1289
+ });
1290
+ const h = headers;
1291
+ const total = h?.get('x-total-count') ?? '';
1292
+ return {
1293
+ users: Array.isArray(body) ? body : [],
1294
+ nextCursor: h?.get('x-next-cursor') || null,
1295
+ total: /^\d+$/.test(total) ? Number(total) : null,
1296
+ };
1297
+ },
1298
+ delete: (userId) => this.productAuthRequest('DELETE', `/users/${safePathSegment(userId)}`, {
1299
+ requiresScope: true,
1300
+ }),
1301
+ ban: (userId) => this.productAuthRequest('POST', `/users/${safePathSegment(userId)}/ban`, {
1302
+ requiresScope: true,
1303
+ }),
1304
+ roles: (userId) => this.productAuthRequest('GET', `/users/${safePathSegment(userId)}/roles`, {
1305
+ requiresScope: true,
1306
+ }),
1307
+ setRoles: (userId, roles) => this.productAuthRequest('PUT', `/users/${safePathSegment(userId)}/roles`, {
1308
+ requiresScope: true,
1309
+ body: { roles },
1310
+ }),
1311
+ create: (payload) => this.productAuthRequest('POST', '/users', {
1312
+ requiresScope: true,
1313
+ body: payload,
1314
+ }),
1315
+ /**
1316
+ * Bulk-import app users (up to 1,000 per call) with their ids and password hashes: `bcrypt`
1317
+ * (Supabase) as is, `firebase_scrypt` (Firebase) checked with `hashConfig` and upgraded to bcrypt
1318
+ * at first sign-in. No emails, hooks or triggers fire; existing emails are skipped. Org owners/admins.
1319
+ */
1320
+ import: (users, hashConfig) => this.productAuthRequest('POST', '/users/import', {
1321
+ requiresScope: true,
1322
+ body: hashConfig ? { users, hash_config: hashConfig } : { users },
1323
+ }),
1324
+ },
1325
+ sessions: {
1326
+ list: () => this.request('GET', '/api/v1/auth/sessions'),
1327
+ revoke: (sessionId) => this.request('DELETE', `/api/v1/auth/sessions/${safePathSegment(sessionId)}`),
1328
+ revokeOthers: () => this.request('POST', '/api/v1/auth/sessions/revoke-others', { body: {} }),
1329
+ getConfig: () => this.request('GET', '/api/v1/auth/sessions/config'),
1330
+ setConfig: (body) => this.request('PUT', '/api/v1/auth/sessions/config', { body }),
1331
+ },
1332
+ providersConfig: {
1333
+ get: () => this.request('GET', '/api/v1/auth/providers/config'),
1334
+ set: (body) => this.request('PUT', '/api/v1/auth/providers/config', { body }),
1335
+ },
1336
+ mfa: {
1337
+ status: () => this.request('GET', '/api/v1/auth/mfa/status'),
1338
+ enroll: () => this.request('POST', '/api/v1/auth/mfa/enroll', { body: {} }),
1339
+ disable: (totpCode) => this.request('DELETE', '/api/v1/auth/mfa', { body: { totp_code: totpCode } }),
1340
+ },
1341
+ github: {
1342
+ connectionState: () => this.request('GET', '/api/v1/auth/github/connection-state'),
1343
+ disconnect: () => this.request('DELETE', '/api/v1/auth/github/connection-state'),
1344
+ },
1345
+ };
1346
+ graph = {
1347
+ createNode: (nodeType, name, metadata = {}) => this.request('POST', '/api/v1/org', {
1348
+ body: { type: nodeType, name, metadata },
1349
+ }),
1350
+ createEdge: (sourceId, targetId, relationType, role) => this.request('POST', '/api/v1/edge', {
1351
+ body: { source_id: sourceId, target_id: targetId, relation_type: relationType, role },
1352
+ }),
1353
+ addMember: (orgId, userId, role = 'MEMBER') => this.request('POST', `/api/v1/org/${safePathSegment(orgId)}/member`, {
1354
+ body: { user_id: userId, role },
1355
+ }),
1356
+ getContext: (userId, resourceId) => this.request('GET', `/api/v1/context/${safePathSegment(userId)}/${safePathSegment(resourceId)}`),
1357
+ listNodes: (nodeType) => this.request('GET', '/api/v1/org', {
1358
+ query: nodeType ? { type: nodeType } : undefined,
1359
+ }),
1360
+ getNode: (nodeId) => this.request('GET', `/api/v1/org/${safePathSegment(nodeId)}`),
1361
+ getChildren: (parentId, relation = 'OWNS') => this.request('GET', `/api/v1/org/${safePathSegment(parentId)}/children`, {
1362
+ query: { relation },
1363
+ }),
1364
+ updateOrg: (orgId, body) => this.request('PUT', `/api/v1/org/${safePathSegment(orgId)}`, { body }),
1365
+ deleteOrg: (orgId) => this.request('DELETE', `/api/v1/org/${safePathSegment(orgId)}`),
1366
+ setOrgPlan: (orgId, body) => this.request('PUT', `/api/v1/org/${safePathSegment(orgId)}/plan`, { body }),
1367
+ listMembers: (orgId) => this.request('GET', `/api/v1/org/${safePathSegment(orgId)}/members`),
1368
+ removeMember: (orgId, userId) => this.request('DELETE', `/api/v1/org/${safePathSegment(orgId)}/member/${safePathSegment(userId)}`),
1369
+ updateMember: (orgId, userId, body) => this.request('PUT', `/api/v1/org/${safePathSegment(orgId)}/member/${safePathSegment(userId)}`, { body }),
1370
+ createInvite: (orgId, body) => this.request('POST', `/api/v1/org/${safePathSegment(orgId)}/invites`, { body }),
1371
+ acceptInvite: (body) => this.request('POST', '/api/v1/org/invites/accept', { body }),
1372
+ getRoleMatrix: () => this.request('GET', '/api/v1/roles/matrix'),
1373
+ setRoleMatrix: (body) => this.request('PUT', '/api/v1/roles/matrix', { body }),
1374
+ getRoleCatalog: () => this.request('GET', '/api/v1/roles/catalog'),
1375
+ getProjectRef: (projectId) => this.request('GET', `/api/v1/org/${safePathSegment(projectId)}/ref`),
1376
+ getProjectSettings: () => this.request('GET', '/api/v1/project-settings', { requiresScope: true }),
1377
+ setProjectSettings: (body) => this.request('PUT', '/api/v1/project-settings', { body, requiresScope: true }),
1378
+ };
1379
+ data = {
1380
+ docPut: (collection, documentId, data) => this.request('POST', `/api/v1/data/doc/${collection
1381
+ .split('/')
1382
+ .map((segment) => safePathSegment(segment))
1383
+ .join('/')}/${safePathSegment(documentId)}`, { body: { data } }),
1384
+ docGet: (collection, documentId, atTxid) => this.request('GET', `/api/v1/data/doc/${collection
1385
+ .split('/')
1386
+ .map((segment) => safePathSegment(segment))
1387
+ .join('/')}/${safePathSegment(documentId)}`, { query: atTxid ? { at_txid: atTxid } : undefined }),
1388
+ docDelete: (collection, documentId) => this.request('DELETE', docCollectionPath(collection, documentId)),
1389
+ /** Encrypt client-side and write a Zero-Knowledge document envelope to Atlas-Doc. */
1390
+ putDocumentE2ee: async (collection, documentId, plaintext, password) => {
1391
+ const plainBytes = typeof plaintext === 'string' ? new TextEncoder().encode(plaintext) : plaintext;
1392
+ let envelope;
1393
+ if (this.cachedKek && this.argon2Salt) {
1394
+ envelope = await encryptDocumentWithKek(plainBytes, this.cachedKek);
1395
+ envelope = { ...envelope, salt: bytesToHex(this.argon2Salt) };
1396
+ }
1397
+ else if (password) {
1398
+ if (!this.argon2Salt) {
1399
+ throw SovariaError.config('No server Argon2 salt is available for this session. Password-only E2EE writes will not generate an ephemeral salt. Log in/register against a Phase 2 server that returns argon2_salt.');
1400
+ }
1401
+ envelope = await encryptDocument(plainBytes, password, this.argon2Salt);
1402
+ envelope = { ...envelope, salt: bytesToHex(this.argon2Salt) };
1403
+ }
1404
+ else {
1405
+ throw SovariaError.config('No cached KEK found. Either log in with a Phase 2–enabled server or supply the password argument explicitly.');
1406
+ }
1407
+ return this.request('POST', docCollectionPath(collection, documentId), {
1408
+ body: {
1409
+ document: {
1410
+ collection_id: collection,
1411
+ document_id: documentId,
1412
+ ...envelope,
1413
+ },
1414
+ },
1415
+ headers: { 'X-Payload-Hash': envelope.payload_hash },
1416
+ });
1417
+ },
1418
+ /** Retrieve and decrypt a Zero-Knowledge document from Atlas-Doc. */
1419
+ getDocumentE2ee: async (collection, documentId, password, atTxid) => {
1420
+ const resp = await this.request('GET', docCollectionPath(collection, documentId), { query: atTxid ? { at_txid: atTxid } : undefined });
1421
+ if (!resp.found) {
1422
+ throw new SovariaError(404, `Document ${documentId} not found`);
1423
+ }
1424
+ const doc = resp.document;
1425
+ const envelope = {
1426
+ ciphertext: String(doc.ciphertext ?? ''),
1427
+ nonce: String(doc.nonce ?? ''),
1428
+ auth_tag: String(doc.auth_tag ?? ''),
1429
+ encrypted_dek: String(doc.encrypted_dek ?? ''),
1430
+ dek_nonce: String(doc.dek_nonce ?? ''),
1431
+ payload_hash: String(doc.payload_hash ?? ''),
1432
+ };
1433
+ let plaintext;
1434
+ if (this.cachedKek && this.argon2Salt) {
1435
+ plaintext = await decryptDocumentWithKek(envelope, this.cachedKek);
1436
+ }
1437
+ else if (password) {
1438
+ const saltHex = String(doc.salt ?? '');
1439
+ const salt = this.argon2Salt ?? (saltHex ? hexToBytes(saltHex) : undefined);
1440
+ if (!salt) {
1441
+ throw SovariaError.config('No Argon2 salt available to decrypt. The document envelope must embed salt, or the session must have a cached server salt.');
1442
+ }
1443
+ plaintext = await decryptDocument(envelope, password, salt);
1444
+ }
1445
+ else {
1446
+ throw SovariaError.config('No cached KEK. Supply the password argument to decrypt.');
1447
+ }
1448
+ return { ...resp, plaintext };
1449
+ },
1450
+ /**
1451
+ * List the documents in one collection (newest writes last): `[{ id, data, txid }]`.
1452
+ * Subcollections are not included — list `collection/<docId>/<sub>` for those.
1453
+ */
1454
+ docList: async (collection, options = {}) => {
1455
+ const prefix = `${collection.replace(/^\/+|\/+$/g, '')}:`;
1456
+ const res = await this.request('POST', '/api/v1/data/query/scan', {
1457
+ body: { prefix, limit: options.limit ?? 500 },
1458
+ });
1459
+ const docs = res.documents ?? [];
1460
+ return docs
1461
+ .map((d) => ({ id: String(d.key ?? '').slice(prefix.length), data: (d.data ?? {}), txid: Number(d.txid ?? 0) }))
1462
+ .filter((d) => d.id && !d.id.includes('/'));
1463
+ },
1464
+ /** Field query (`where`, `order_by`, `limit`, cursors) — see also `sov.collection(...)`. */
1465
+ docQuery: (collection, query = {}) => this.request('POST', `/api/v1/data/doc/_query/${collection.split('/').map(safePathSegment).join('/')}`, { body: query }),
1466
+ docCount: (collection, query = {}) => this.request('POST', `/api/v1/data/doc/_count/${collection.split('/').map(safePathSegment).join('/')}`, { body: query }),
1467
+ /** Atomic commit: `{ op, path, data, merge, transforms, precondition }` writes; `reads` make it a transaction. */
1468
+ docCommit: (writes, reads = []) => this.request('POST', '/api/v1/data/doc/_commit', { body: { writes, reads } }),
1469
+ docIndexes: (collection) => this.request('GET', '/api/v1/data/doc/_indexes', { query: { collection } }),
1470
+ createDocIndex: (collection, fields) => this.request('POST', '/api/v1/data/doc/_indexes', { body: { collection, fields } }),
1471
+ dropDocIndex: (name) => this.request('DELETE', `/api/v1/data/doc/_indexes/${safePathSegment(name)}`),
1472
+ collectionRules: (collection) => this.request('GET', `/api/v1/data/doc/_rules/${collection.split('/').map(safePathSegment).join('/')}`),
1473
+ docScan: (args) => this.request('POST', '/api/v1/data/query/scan', { body: args }),
1474
+ /**
1475
+ * Run one SQL statement. Pass values for `$1, $2, …` as `params` — they are bound by the
1476
+ * database, so user input never becomes SQL: `sql('SELECT * FROM books WHERE id = $1', [id])`.
1477
+ * A number as the second argument is still read as `txid` (older signature).
1478
+ */
1479
+ sql: (sql, params, txid = 0) => {
1480
+ const bound = Array.isArray(params) ? params : undefined;
1481
+ const snapshot = typeof params === 'number' ? params : txid;
1482
+ return this.request('POST', '/api/v1/sql/query', {
1483
+ body: { sql, ...(bound && bound.length ? { params: bound } : {}), ...(snapshot > 0 ? { txid: snapshot } : {}) },
1484
+ });
1485
+ },
1486
+ tableCreate: (payload) => this.request('POST', '/api/v1/data/rel/create', { body: payload }),
1487
+ tableDrop: (tableName) => this.request('POST', '/api/v1/data/rel/drop', {
1488
+ body: { table_name: tableName },
1489
+ }),
1490
+ tableAddColumn: (payload) => this.request('POST', '/api/v1/data/rel/column/add', { body: payload }),
1491
+ tableDropColumn: (tableName, columnName) => this.request('POST', '/api/v1/data/rel/column/drop', {
1492
+ body: { table_name: tableName, column_name: columnName },
1493
+ }),
1494
+ tableAddForeignKey: (payload) => this.request('POST', '/api/v1/data/rel/foreign-key/add', { body: payload }),
1495
+ tableDropForeignKey: (payload) => this.request('POST', '/api/v1/data/rel/foreign-key/drop', { body: payload }),
1496
+ tableMigrations: (query) => this.request('GET', '/api/v1/data/rel/migrations', { query }),
1497
+ /** The schema as one short line per table: types, keys, →references, allowed values, indexes, owner-only rows. */
1498
+ schemaDigest: (options = {}) => this.request('GET', '/api/v1/data/rel/schema/digest', {
1499
+ query: { tables: options.tables?.join(',') || undefined, max_chars: options.maxChars, since: options.since },
1500
+ requiresScope: true,
1501
+ }),
1502
+ /**
1503
+ * Create or extend tables from one declarative plan: checked first (a 422 lists every problem and nothing
1504
+ * changes), then applied as one reversible migration and verified. `dryRun` returns the SQL instead.
1505
+ */
1506
+ applySchemaPlan: (plan) => this.request('POST', '/api/v1/data/rel/schema/plan', {
1507
+ body: { tables: plan.tables, dry_run: plan.dryRun, name: plan.name },
1508
+ requiresScope: true,
1509
+ }),
1510
+ /** Apply a migration atomically and record it in the project's history. `downSql` makes it reversible. */
1511
+ applyMigration: (migration) => this.request('POST', '/api/v1/data/rel/migrations/apply', {
1512
+ body: { name: migration.name, up_sql: migration.upSql, down_sql: migration.downSql, version: migration.version },
1513
+ requiresScope: true,
1514
+ }),
1515
+ /** Run a migration inside a transaction and roll it back: reports whether it would apply. */
1516
+ dryRunMigration: (migration) => this.request('POST', '/api/v1/data/rel/migrations/dry-run', {
1517
+ body: { name: migration.name, up_sql: migration.upSql, down_sql: migration.downSql },
1518
+ requiresScope: true,
1519
+ }),
1520
+ /**
1521
+ * Check several migrations the way they will be applied (what `sov db migrations push --dry-run` does):
1522
+ * all run in order inside one transaction that is rolled back, so later ones see what earlier ones
1523
+ * create. Up to 500 and 5 MB of SQL; `failed.index` is the first that fails. Nothing is kept.
1524
+ */
1525
+ dryRunMigrations: (migrations) => this.request('POST', '/api/v1/data/rel/migrations/dry-run-sequence', {
1526
+ body: { migrations: migrations.map((m) => ({ name: m.name, up_sql: m.upSql })) },
1527
+ requiresScope: true,
1528
+ }),
1529
+ /** Undo the latest migration (or `version` with `force`) using its down SQL. */
1530
+ rollbackMigration: (opts = {}) => this.request('POST', '/api/v1/data/rel/migrations/rollback', {
1531
+ query: { ...(opts.version ? { version: opts.version } : {}), ...(opts.force ? { force: 'true' } : {}) },
1532
+ requiresScope: true,
1533
+ }),
1534
+ beginTransaction: () => this.request('POST', '/api/v1/data/rel/transaction/begin', { requiresScope: true }),
1535
+ commitTransaction: (txid) => this.request('POST', '/api/v1/data/rel/transaction/commit', {
1536
+ body: { txid },
1537
+ requiresScope: true,
1538
+ }),
1539
+ rollbackTransaction: (txid) => this.request('POST', '/api/v1/data/rel/transaction/rollback', {
1540
+ body: { txid },
1541
+ requiresScope: true,
1542
+ }),
1543
+ };
1544
+ realtime = {
1545
+ subscribe: (channel, clientId) => this.request('POST', '/api/v1/rt/stream/subscribe', {
1546
+ body: { channel, client_id: clientId },
1547
+ }),
1548
+ unsubscribe: (channel, clientId) => this.request('POST', '/api/v1/rt/stream/unsubscribe', {
1549
+ body: { channel, client_id: clientId },
1550
+ }),
1551
+ presence: (channel) => this.request('GET', `/api/v1/rt/stream/presence/${safePathSegment(channel)}`),
1552
+ channels: () => this.request('GET', '/api/v1/rt/stream/channels'),
1553
+ replay: (channel, after = 0, limit = 200) => this.request('GET', `/api/v1/rt/stream/replay/${safePathSegment(channel)}`, {
1554
+ query: { after, limit },
1555
+ }),
1556
+ publish: (channel, payload, clock, clockDelta) => this.request('POST', `/api/v1/rt/stream/publish/${safePathSegment(channel)}`, {
1557
+ body: {
1558
+ payload,
1559
+ clock,
1560
+ clock_delta: clockDelta,
1561
+ },
1562
+ }),
1563
+ e2eeBootstrap: (channel, ttlSeconds = 86400, forceNew = false) => this.request('POST', '/api/v1/rt/e2ee/keys/bootstrap', {
1564
+ body: { channel, ttl_seconds: ttlSeconds, force_new: forceNew },
1565
+ }),
1566
+ e2eeRotate: (channel, ttlSeconds = 86400) => this.request('POST', '/api/v1/rt/e2ee/keys/rotate', {
1567
+ body: { channel, ttl_seconds: ttlSeconds },
1568
+ }),
1569
+ e2eeRevoke: (channel, keyId, reason) => this.request('POST', '/api/v1/rt/e2ee/keys/revoke', {
1570
+ body: { channel, key_id: keyId, reason },
1571
+ }),
1572
+ /** Pass your RSA-OAEP (SHA-256) public key — PEM or base64 SPKI — to get the key wrapped for you. */
1573
+ e2eeActiveKey: (channel, clientPublicKey) => this.request('GET', `/api/v1/rt/e2ee/keys/active/${safePathSegment(channel)}`, {
1574
+ query: { client_public_key_pem: clientPublicKey },
1575
+ }),
1576
+ e2eeGetKey: (channel, keyId, clientPublicKey) => this.request('GET', `/api/v1/rt/e2ee/keys/${safePathSegment(channel)}/${safePathSegment(keyId)}`, { query: { client_public_key_pem: clientPublicKey } }),
1577
+ e2eeListKeys: (channel) => this.request('GET', `/api/v1/rt/e2ee/keys/${safePathSegment(channel)}`),
1578
+ wsTicket: (channel) => this.issueRealtimeWebSocketTicket(channel),
1579
+ wsUrl: (channel, ticket) => this.getRealtimeWebSocketUrl(channel, ticket),
1580
+ wsUrlWithTicket: (channel) => this.getRealtimeWebSocketUrlWithTicket(channel),
1581
+ /**
1582
+ * Listen on a channel over WebSocket. Reconnects with backoff (a fresh ticket each time) and
1583
+ * hands you each message's payload. Call `close()` when done.
1584
+ *
1585
+ * ```ts
1586
+ * const ch = sov.realtime.channel('book:42', { onMessage: (m) => console.log(m), onStatus: setStatus })
1587
+ * await ch.send({ type: 'typing' })
1588
+ * ch.close()
1589
+ * ```
1590
+ */
1591
+ channel: (name, handlers = {}) => openRealtimeChannel(this, name, handlers),
1592
+ };
1593
+ policy = {
1594
+ templates: () => this.request('GET', '/api/v1/policies/templates'),
1595
+ list: () => this.request('GET', '/api/v1/policies/'),
1596
+ get: (policyId) => this.request('GET', `/api/v1/policies/${safePathSegment(policyId)}`),
1597
+ create: (payload) => this.request('POST', '/api/v1/policies/', {
1598
+ body: payload,
1599
+ requiresScope: false,
1600
+ }),
1601
+ update: (policyId, payload) => this.request('PUT', `/api/v1/policies/${safePathSegment(policyId)}`, {
1602
+ body: payload,
1603
+ requiresScope: false,
1604
+ }),
1605
+ delete: (policyId) => this.request('DELETE', `/api/v1/policies/${safePathSegment(policyId)}`, {
1606
+ requiresScope: false,
1607
+ }),
1608
+ evaluate: (policyId, context) => this.request('POST', `/api/v1/policies/${safePathSegment(policyId)}/evaluate`, {
1609
+ body: { context },
1610
+ requiresScope: false,
1611
+ }),
1612
+ check: (payload) => this.request('POST', '/api/v1/policies/check', {
1613
+ body: {
1614
+ org_id: payload.orgId,
1615
+ project_id: payload.projectId,
1616
+ action: payload.action,
1617
+ resource: payload.resource,
1618
+ context: payload.context ?? {},
1619
+ },
1620
+ requiresScope: false,
1621
+ }),
1622
+ preflight: async (action, resource, context = {}) => {
1623
+ await this.resolveScope();
1624
+ this.assertScopePresent();
1625
+ return this.policy.check({
1626
+ orgId: this.scope.orgId,
1627
+ projectId: this.scope.projectId,
1628
+ action,
1629
+ resource,
1630
+ context,
1631
+ });
1632
+ },
1633
+ history: (policyId) => this.request('GET', `/api/v1/policies/${safePathSegment(policyId)}/history`, {
1634
+ requiresScope: false,
1635
+ }),
1636
+ rollback: (policyId, version) => this.request('POST', `/api/v1/policies/${safePathSegment(policyId)}/rollback`, {
1637
+ body: { version },
1638
+ requiresScope: false,
1639
+ }),
1640
+ simulate: (splSource, context = {}) => this.request('POST', '/api/v1/policies/simulate', {
1641
+ body: { spl_source: splSource, context },
1642
+ requiresScope: true,
1643
+ }),
1644
+ };
1645
+ audit = {
1646
+ append: (payload) => this.request('POST', '/api/v1/audit/', {
1647
+ body: payload,
1648
+ }),
1649
+ list: (query) => this.request('GET', '/api/v1/audit/', { query }),
1650
+ verify: () => this.request('GET', '/api/v1/audit/verify'),
1651
+ count: () => this.request('GET', '/api/v1/audit/count'),
1652
+ };
1653
+ simulate = {
1654
+ /**
1655
+ * Who can do what in the live project, as visitors, the app's users and the team; nothing is
1656
+ * written. Narrow with `{ filter: { resource, action, role } }` (role: `__guest__`,
1657
+ * `__app_user__`, `app:<role>` or a team role).
1658
+ */
1659
+ run: async (payload = {}) => {
1660
+ const filter = payload.filter;
1661
+ if (filter && Object.keys(filter).length > 0) {
1662
+ return this.request('POST', '/api/v1/sim/run', { body: payload, requiresScope: true });
1663
+ }
1664
+ // A full run takes longer than one request should wait: start it in the background, poll it.
1665
+ let run = await this.request('POST', '/api/v1/sim/run', {
1666
+ body: { ...payload, background: true }, requiresScope: true,
1667
+ });
1668
+ const deadline = Date.now() + 10 * 60_000;
1669
+ while (run.status === 'running') {
1670
+ if (Date.now() > deadline)
1671
+ throw new Error(`Simulation ${String(run.run_id)} is still running after 10 minutes`);
1672
+ await sleep(2_000);
1673
+ run = await this.request('GET', `/api/v1/sim/runs/${encodeURIComponent(String(run.run_id))}`, {
1674
+ requiresScope: true,
1675
+ });
1676
+ }
1677
+ if (run.status === 'failed')
1678
+ throw new Error(String(run.error ?? 'Simulation failed'));
1679
+ return run;
1680
+ },
1681
+ /**
1682
+ * Try a change before making it; nothing is written. `{ kind: 'policy', policy: { spl_source,
1683
+ * name?, replaces? }, disable_policy_ids? }` lists who gains and loses access; `{ kind: 'sql',
1684
+ * sql }` runs the script with every check, rolls it back and lists what it changed.
1685
+ */
1686
+ change: (payload) => this.request('POST', '/api/v1/sim/change', { body: payload, requiresScope: true }),
1687
+ plan: (payload) => this.request('POST', '/api/v1/sim/plan', { body: payload }),
1688
+ discover: () => this.request('POST', '/api/v1/sim/discover', { body: {}, requiresScope: true }),
1689
+ listRuns: (query) => this.request('GET', '/api/v1/sim/runs', { query, requiresScope: true }),
1690
+ getRun: (runId) => this.request('GET', `/api/v1/sim/runs/${safePathSegment(runId)}`, {
1691
+ requiresScope: true,
1692
+ }),
1693
+ compareRuns: (targetRunId, baselineRunId) => this.request('GET', `/api/v1/sim/runs/${safePathSegment(targetRunId)}/compare/${safePathSegment(baselineRunId)}`, { requiresScope: true }),
1694
+ };
1695
+ chrono = {
1696
+ /** The project's configuration (policies, schema, functions, settings, buckets, keys) at a commit or ISO time. */
1697
+ state: (query = {}) => this.request('GET', '/api/v1/chrono/state', { query: { timeline: 'main', ...query }, requiresScope: true }),
1698
+ /** What going back to `at` changes, file by file, with who would gain or lose access. Writes nothing. */
1699
+ restorePlan: (at, timeline = 'main') => this.request('POST', '/api/v1/chrono/restore-plan', { body: { at, timeline }, requiresScope: true }),
1700
+ /**
1701
+ * Go back to `at`: applies the plan's automated changes with the caller's own permissions.
1702
+ * `includeDatabase` also restores the database from the closest backup before `at` (replaces all
1703
+ * data after a safety backup; undo within 7 days).
1704
+ */
1705
+ restore: (at, timeline = 'main', options = {}) => this.request('POST', '/api/v1/chrono/restore', {
1706
+ body: { at, timeline, confirm: true, ...(options.includeDatabase ? { include_database: true } : {}) },
1707
+ requiresScope: true,
1708
+ }),
1709
+ commit: (payload) => this.request('POST', '/api/v1/chrono/commit', { body: payload }),
1710
+ timelineSpawn: (payload) => this.request('POST', '/api/v1/chrono/timelines/spawn', { body: payload }),
1711
+ timelineList: () => this.request('GET', '/api/v1/chrono/timelines'),
1712
+ timelineDelete: (name) => this.request('DELETE', `/api/v1/chrono/timelines/${safePathSegment(name)}`),
1713
+ timelineJump: (name, targetHex) => this.request('POST', `/api/v1/chrono/timelines/${safePathSegment(name)}/jump`, {
1714
+ body: { target_hex: targetHex },
1715
+ }),
1716
+ timelineHistory: (name, limit = 50, offset = 0) => this.request('GET', `/api/v1/chrono/timelines/${safePathSegment(name)}/history`, {
1717
+ query: { limit, offset },
1718
+ }),
1719
+ topology: (timeline = 'main', limit = 50) => this.request('GET', '/api/v1/chrono/timelines/topology', {
1720
+ query: { timeline, limit },
1721
+ }),
1722
+ converge: (payload) => this.request('POST', '/api/v1/chrono/timelines/converge', {
1723
+ body: payload,
1724
+ }),
1725
+ variance: (payload) => this.request('POST', '/api/v1/chrono/variance', {
1726
+ body: payload,
1727
+ }),
1728
+ snapshotCreate: (payload) => this.request('POST', '/api/v1/chrono/snapshots/create', {
1729
+ body: payload,
1730
+ }),
1731
+ snapshotList: () => this.request('GET', '/api/v1/chrono/snapshots'),
1732
+ snapshotRestore: (snapshotId) => this.request('POST', `/api/v1/chrono/snapshots/${snapshotId}/restore`),
1733
+ };
1734
+ functions = {
1735
+ list: (options) => this.request('GET', '/api/v1/fn/functions', {
1736
+ query: {
1737
+ project_id: options.projectId,
1738
+ limit: options.limit,
1739
+ offset: options.offset,
1740
+ },
1741
+ requiresScope: false,
1742
+ }),
1743
+ /** Validate a bundle and get a signed plan bound to its exact bytes. */
1744
+ plan: (bundle, name, projectId) => {
1745
+ const form = new FormData();
1746
+ form.append('bundle', bundle, 'bundle.zip');
1747
+ form.append('name', name);
1748
+ if (projectId) {
1749
+ form.append('project_id', projectId);
1750
+ }
1751
+ return this.request('POST', '/api/v1/fn/plan', {
1752
+ body: form,
1753
+ requiresScope: false,
1754
+ });
1755
+ },
1756
+ /** Plans, then applies exactly the planned bundle (Forge refuses anything else). */
1757
+ deploy: async (bundle, name, projectId) => {
1758
+ const plan = await this.functions.plan(bundle, name, projectId);
1759
+ const token = typeof plan.token === 'string' ? plan.token : '';
1760
+ const form = new FormData();
1761
+ form.append('bundle', bundle, 'bundle.zip');
1762
+ form.append('name', name);
1763
+ if (projectId) {
1764
+ form.append('project_id', projectId);
1765
+ }
1766
+ return this.request('POST', '/api/v1/fn/deploy', {
1767
+ body: form,
1768
+ headers: token ? { 'X-Sovaria-Deploy-Plan': token } : undefined,
1769
+ requiresScope: false,
1770
+ });
1771
+ },
1772
+ status: (deployId, projectId) => this.request('GET', `/api/v1/fn/${safePathSegment(deployId)}/status`, {
1773
+ query: projectId ? { project_id: projectId } : undefined,
1774
+ requiresScope: false,
1775
+ }),
1776
+ invoke: (functionName, payload, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/invoke`, {
1777
+ body: payload,
1778
+ query: options?.projectId || options?.deployId || options?.executionProfile
1779
+ ? {
1780
+ project_id: options?.projectId,
1781
+ deploy_id: options?.deployId,
1782
+ execution_profile: options?.executionProfile,
1783
+ }
1784
+ : undefined,
1785
+ requiresScope: false,
1786
+ }),
1787
+ /**
1788
+ * Call a function the way an app does: POST {projectUrl}/functions/v1/{name} with the signed-in
1789
+ * user's token (or no token for functions marked public). Returns the function's result.
1790
+ */
1791
+ call: (functionName, payload = {}) => this.request('POST', `/functions/v1/${safePathSegment(functionName)}`, { body: payload, requiresScope: false }),
1792
+ simulate: (functionName, payload, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/simulate`, {
1793
+ body: payload,
1794
+ query: options?.projectId || options?.deployId || options?.executionProfile
1795
+ ? {
1796
+ project_id: options?.projectId,
1797
+ deploy_id: options?.deployId,
1798
+ execution_profile: options?.executionProfile,
1799
+ }
1800
+ : undefined,
1801
+ requiresScope: false,
1802
+ }),
1803
+ invokeAsync: (functionName, payload, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/invoke/async`, {
1804
+ body: payload,
1805
+ query: options?.projectId ||
1806
+ options?.deployId ||
1807
+ options?.executionProfile ||
1808
+ options?.simulation !== undefined
1809
+ ? {
1810
+ project_id: options?.projectId,
1811
+ deploy_id: options?.deployId,
1812
+ execution_profile: options?.executionProfile,
1813
+ simulation: options?.simulation,
1814
+ }
1815
+ : undefined,
1816
+ requiresScope: false,
1817
+ }),
1818
+ invocationStatus: (invocationId, projectId) => this.request('GET', `/api/v1/fn/invocations/${safePathSegment(invocationId)}`, {
1819
+ query: { project_id: projectId },
1820
+ requiresScope: false,
1821
+ }),
1822
+ jobs: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/jobs`, {
1823
+ query: {
1824
+ project_id: options.projectId,
1825
+ status: options.status,
1826
+ limit: options.limit,
1827
+ offset: options.offset,
1828
+ },
1829
+ requiresScope: false,
1830
+ }),
1831
+ versions: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/versions`, {
1832
+ query: {
1833
+ project_id: options.projectId,
1834
+ limit: options.limit,
1835
+ offset: options.offset,
1836
+ },
1837
+ requiresScope: false,
1838
+ }),
1839
+ history: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/history/invocations`, {
1840
+ query: {
1841
+ project_id: options.projectId,
1842
+ mode: options.mode,
1843
+ limit: options.limit,
1844
+ offset: options.offset,
1845
+ },
1846
+ requiresScope: false,
1847
+ }),
1848
+ simulationDiff: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/history/simulation-diff`, {
1849
+ query: {
1850
+ project_id: options.projectId,
1851
+ left_invocation_id: options.leftInvocationId,
1852
+ right_invocation_id: options.rightInvocationId,
1853
+ },
1854
+ requiresScope: false,
1855
+ }),
1856
+ promote: (functionName, options) => {
1857
+ if (options.version === undefined && !options.deployId) {
1858
+ throw SovariaError.config('Provide either version or deployId for promote.');
1859
+ }
1860
+ return this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/promote`, {
1861
+ query: {
1862
+ project_id: options.projectId,
1863
+ },
1864
+ body: {
1865
+ version: options.version,
1866
+ deploy_id: options.deployId,
1867
+ reason: options.reason ?? '',
1868
+ },
1869
+ requiresScope: false,
1870
+ });
1871
+ },
1872
+ rollback: (functionName, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/rollback`, {
1873
+ query: {
1874
+ project_id: options.projectId,
1875
+ },
1876
+ body: {
1877
+ version: options.version,
1878
+ reason: options.reason ?? '',
1879
+ },
1880
+ requiresScope: false,
1881
+ }),
1882
+ request: (method, path, options = {}) => this.request(method, `/api/v1/fn/${normalizeSubpath(path)}`, {
1883
+ ...options,
1884
+ requiresScope: options.requiresScope ?? false,
1885
+ }),
1886
+ get: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}`, {
1887
+ requiresScope: true,
1888
+ }),
1889
+ getSource: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/source`, {
1890
+ requiresScope: true,
1891
+ }),
1892
+ update: (name, body) => this.request('PATCH', `/api/v1/fn/functions/${safePathSegment(name)}`, {
1893
+ body,
1894
+ requiresScope: true,
1895
+ }),
1896
+ delete: (name) => this.request('DELETE', `/api/v1/fn/functions/${safePathSegment(name)}`, {
1897
+ requiresScope: true,
1898
+ }),
1899
+ logs: (name, query) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/logs`, {
1900
+ query,
1901
+ requiresScope: true,
1902
+ }),
1903
+ getPolicy: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/policy`, {
1904
+ requiresScope: true,
1905
+ }),
1906
+ setPolicy: (name, body) => this.request('PUT', `/api/v1/fn/functions/${safePathSegment(name)}/policy`, {
1907
+ body,
1908
+ requiresScope: true,
1909
+ }),
1910
+ getBindings: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/bindings`, {
1911
+ requiresScope: true,
1912
+ }),
1913
+ setBindings: (name, body) => this.request('PUT', `/api/v1/fn/functions/${safePathSegment(name)}/bindings`, {
1914
+ body,
1915
+ requiresScope: true,
1916
+ }),
1917
+ githubRepositories: (body) => this.request('POST', '/api/v1/fn/github/repositories', { body, requiresScope: true }),
1918
+ githubImport: (body) => this.request('POST', '/api/v1/fn/github/import', { body, requiresScope: true }),
1919
+ githubCommit: (body) => this.request('POST', '/api/v1/fn/github/commit', { body, requiresScope: true }),
1920
+ };
1921
+ storage = {
1922
+ request: (method, path, options = {}) => this.request(method, `/api/v1/store/${normalizeSubpath(path)}`, {
1923
+ ...options,
1924
+ requiresScope: options.requiresScope ?? false,
1925
+ }),
1926
+ putJson: (path, payload) => this.request('POST', `/api/v1/store/${normalizeSubpath(path)}`, {
1927
+ body: payload,
1928
+ requiresScope: false,
1929
+ }),
1930
+ get: (path, query) => this.request('GET', `/api/v1/store/${normalizeSubpath(path)}`, {
1931
+ query,
1932
+ requiresScope: false,
1933
+ }),
1934
+ delete: (path) => this.request('DELETE', `/api/v1/store/${normalizeSubpath(path)}`, {
1935
+ requiresScope: false,
1936
+ }),
1937
+ /** Public URL of an object in a public bucket (no token needed; safe to put in <img src>). */
1938
+ publicUrl: (bucket, objectPath, projectId) => {
1939
+ const pid = projectId ?? this.scope?.projectId;
1940
+ if (!pid)
1941
+ throw SovariaError.config('publicUrl() needs a projectId or setScope() first');
1942
+ const path = objectPath.split('/').map((p) => encodeURIComponent(p)).join('/');
1943
+ return `${this.baseUrl}/api/v1/store/public/${encodeURIComponent(pid)}/${encodeURIComponent(bucket)}/${path}`;
1944
+ },
1945
+ listBuckets: () => this.request('GET', '/api/v1/store/buckets', { requiresScope: true }),
1946
+ createBucket: (body) => this.request('POST', '/api/v1/store/buckets', { body, requiresScope: true }),
1947
+ getBucket: (name) => this.request('GET', `/api/v1/store/buckets/${safePathSegment(name)}`, {
1948
+ requiresScope: true,
1949
+ }),
1950
+ updateBucket: (name, body) => this.request('PATCH', `/api/v1/store/buckets/${safePathSegment(name)}`, {
1951
+ body,
1952
+ requiresScope: true,
1953
+ }),
1954
+ deleteBucket: (name) => this.request('DELETE', `/api/v1/store/buckets/${safePathSegment(name)}`, {
1955
+ requiresScope: true,
1956
+ }),
1957
+ listObjects: (bucket, query) => this.request('GET', `/api/v1/store/objects/${safePathSegment(bucket)}`, {
1958
+ query,
1959
+ requiresScope: true,
1960
+ }),
1961
+ signDownload: (bucket, objectPath, expiresIn = 3600) => this.request('POST', `/api/v1/store/objects/${safePathSegment(bucket)}/${objectPath
1962
+ .split('/')
1963
+ .map((p) => safePathSegment(p))
1964
+ .join('/')}/sign-download`, { body: { expires_in: expiresIn }, requiresScope: true }),
1965
+ /**
1966
+ * Signed upload URL. Pass `sizeBytes` (the file's exact size) to get a direct-to-storage URL bound
1967
+ * to that size; without it the URL uploads through Sovaria, which checks the size limits as the
1968
+ * bytes arrive.
1969
+ */
1970
+ signUpload: (bucket, objectPath, expiresIn = 3600, options = {}) => this.request('POST', `/api/v1/store/objects/${safePathSegment(bucket)}/${objectPath
1971
+ .split('/')
1972
+ .map((p) => safePathSegment(p))
1973
+ .join('/')}/sign-upload`, {
1974
+ body: {
1975
+ expires_in: expiresIn,
1976
+ ...(options.sizeBytes !== undefined ? { size_bytes: options.sizeBytes } : {}),
1977
+ ...(options.contentType ? { content_type: options.contentType } : {}),
1978
+ },
1979
+ requiresScope: true,
1980
+ }),
1981
+ moveObject: (body) => this.request('POST', '/api/v1/store/objects/move', { body, requiresScope: true }),
1982
+ /**
1983
+ * Upload a file. Bucket rules decide who may write where — e.g. with the "Own folder" rule a
1984
+ * user writes to `${userId}/…`. Pass `upsert: true` to replace an existing object.
1985
+ */
1986
+ upload: (bucket, objectPath, file, options = {}) => {
1987
+ const form = new FormData();
1988
+ const name = objectPath.split('/').pop() || 'file';
1989
+ // Without a type the server would store application/octet-stream; guess from the extension.
1990
+ const type = options.contentType || file.type || guessContentType(name);
1991
+ const blob = type && type !== file.type ? new Blob([file], { type }) : file;
1992
+ form.append('file', blob, name);
1993
+ return this.request('POST', `/api/v1/store/objects/${storageObjectPath(bucket, objectPath)}`, {
1994
+ body: form,
1995
+ query: options.upsert ? { upsert: true } : undefined,
1996
+ requiresScope: true,
1997
+ });
1998
+ },
1999
+ /** Download an object the caller may read, as a Blob. */
2000
+ download: (bucket, objectPath) => this.request('GET', `/api/v1/store/objects/${storageObjectPath(bucket, objectPath)}`, {
2001
+ requiresScope: true,
2002
+ responseType: 'blob',
2003
+ }),
2004
+ /** Delete an object. */
2005
+ remove: (bucket, objectPath) => this.request('DELETE', `/api/v1/store/objects/${storageObjectPath(bucket, objectPath)}`, {
2006
+ requiresScope: true,
2007
+ }),
2008
+ };
2009
+ system = {
2010
+ enginesHealth: () => this.request('GET', '/api/v1/system/engines/health', {
2011
+ requiresAuth: false,
2012
+ }),
2013
+ };
2014
+ /**
2015
+ * Billing + plan management (Ledger engine).
2016
+ *
2017
+ * The plan catalog is public; subscription and usage routes require a
2018
+ * valid auth token. Subscription writes require the caller to be an
2019
+ * org admin.
2020
+ */
2021
+ billing = {
2022
+ listPlans: () => this.request('GET', '/api/v1/plans', { requiresAuth: false }),
2023
+ getPlan: (name) => this.request('GET', `/api/v1/plans/${safePathSegment(name)}`, {
2024
+ requiresAuth: false,
2025
+ }),
2026
+ getSubscription: (orgId) => this.request('GET', `/api/v1/subscriptions/${safePathSegment(orgId)}`, { requiresScope: false }),
2027
+ setSubscription: (orgId, payload) => this.request('PUT', `/api/v1/subscriptions/${safePathSegment(orgId)}`, {
2028
+ body: {
2029
+ plan: payload.plan,
2030
+ billing_cycle: payload.billingCycle ?? 'monthly',
2031
+ ...(payload.stripeCustomerId
2032
+ ? { stripe_customer_id: payload.stripeCustomerId }
2033
+ : {}),
2034
+ metadata: payload.metadata ?? {},
2035
+ },
2036
+ requiresScope: false,
2037
+ }),
2038
+ cancelSubscription: (orgId, options = {}) => this.request('DELETE', `/api/v1/subscriptions/${safePathSegment(orgId)}`, {
2039
+ query: options.immediate ? { immediate: true } : undefined,
2040
+ requiresScope: false,
2041
+ }),
2042
+ listSubscriptions: () => this.request('GET', '/api/v1/subscriptions', { requiresScope: false }),
2043
+ getUsage: (orgId, period) => this.request('GET', `/api/v1/usage/${safePathSegment(orgId)}`, {
2044
+ query: period ? { period } : undefined,
2045
+ requiresScope: false,
2046
+ }),
2047
+ getUsageHistory: (orgId, months = 6) => this.request('GET', `/api/v1/usage/${safePathSegment(orgId)}/history`, {
2048
+ query: { months },
2049
+ requiresScope: false,
2050
+ }),
2051
+ listInvoices: (orgId) => this.request('GET', `/api/v1/invoices/${safePathSegment(orgId)}`),
2052
+ buildInvoice: (orgId, period) => this.request('POST', `/api/v1/invoices/${safePathSegment(orgId)}/build`, {
2053
+ query: period ? { period } : undefined,
2054
+ }),
2055
+ getCompute: (orgId, period) => this.request('GET', `/api/v1/compute/${safePathSegment(orgId)}`, {
2056
+ query: period ? { period } : undefined,
2057
+ }),
2058
+ checkout: (body) => this.request('POST', '/api/v1/billing/checkout', { body }),
2059
+ getProvider: () => this.request('GET', '/api/v1/billing/provider'),
2060
+ getFx: () => this.request('GET', '/api/v1/billing/fx'),
2061
+ getOverage: (orgId) => this.request('GET', `/api/v1/billing/overage/${safePathSegment(orgId)}`),
2062
+ getOpenInvoice: () => this.request('GET', '/api/v1/billing/invoices/open'),
2063
+ payInvoice: (orgId, period, body = {}) => this.request('POST', `/api/v1/billing/invoices/${safePathSegment(orgId)}/${safePathSegment(period)}/pay`, { body }),
2064
+ razorpayCreateOrder: (body) => this.request('POST', '/api/v1/billing/razorpay/create-order', { body }),
2065
+ razorpayVerifyPayment: (body) => this.request('POST', '/api/v1/billing/razorpay/verify-payment', { body }),
2066
+ getAccount: (accountId) => this.request('GET', `/api/v1/accounts/${safePathSegment(accountId)}`),
2067
+ cancelAccount: (accountId) => this.request('POST', `/api/v1/accounts/${safePathSegment(accountId)}/cancel`, {
2068
+ body: {},
2069
+ }),
2070
+ resumeAccount: (accountId) => this.request('POST', `/api/v1/accounts/${safePathSegment(accountId)}/resume`, {
2071
+ body: {},
2072
+ }),
2073
+ };
2074
+ /** The complete API, generated from spec/sovaria-api.json (same surface in every SDK language). */
2075
+ api = createGeneratedApi((method, path, options) => this.request(method, path, options), () => this.scope);
2076
+ keys = createKeysApi(this.request.bind(this));
2077
+ governance = createGovernanceApi(this.request.bind(this));
2078
+ admin = createAdminApi(this.request.bind(this));
2079
+ /**
2080
+ * Vector / embeddings (Nexus engine).
2081
+ *
2082
+ * All routes are project-scoped via the standard `X-Sovaria-Project-ID`
2083
+ * header so call `setScope({ orgId, projectId })` first.
2084
+ */
2085
+ vector = {
2086
+ embed: (texts) => this.request('POST', '/api/v1/embed', { body: { texts } }),
2087
+ upsert: (collection, docs) => this.request('POST', '/api/v1/vectors/upsert', {
2088
+ body: { collection, docs },
2089
+ }),
2090
+ /**
2091
+ * Search a collection. `mode`: `vector` (meaning), `keyword` (the words, BM25) or `hybrid` (both,
2092
+ * fused); the default is vector with a real embedding model and hybrid with the local hash model.
2093
+ * Apps (anon key, signed-in users) may only search collections opened with `setCollection(name, { appSearch: true })`.
2094
+ */
2095
+ search: (collection, query, options = {}) => this.request('POST', '/api/v1/vectors/search', {
2096
+ body: {
2097
+ collection,
2098
+ query,
2099
+ top_k: options.topK ?? 5,
2100
+ metadata_filter: options.metadataFilter ?? {},
2101
+ ...(options.mode ? { mode: options.mode } : {}),
2102
+ },
2103
+ }),
2104
+ /** Collection settings (team / service key): `appSearch` lets the project's apps search it. */
2105
+ setCollection: (collection, settings) => this.request('PATCH', `/api/v1/collections/${safePathSegment(collection)}`, {
2106
+ body: { ...(settings.appSearch !== undefined ? { app_search: settings.appSearch } : {}) },
2107
+ }),
2108
+ listCollections: () => this.request('GET', '/api/v1/collections'),
2109
+ listRecords: (collection, options = {}) => this.request('GET', `/api/v1/collections/${safePathSegment(collection)}/records`, {
2110
+ query: {
2111
+ limit: options.limit,
2112
+ offset: options.offset,
2113
+ },
2114
+ }),
2115
+ deleteRecord: (collection, recordId) => this.request('DELETE', `/api/v1/collections/${safePathSegment(collection)}/records/${safePathSegment(recordId)}`),
2116
+ deleteCollection: (collection) => this.request('DELETE', `/api/v1/collections/${safePathSegment(collection)}`),
2117
+ };
2118
+ }
2119
+ const CONTENT_TYPES = {
2120
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif',
2121
+ svg: 'image/svg+xml', ico: 'image/x-icon', pdf: 'application/pdf', json: 'application/json', txt: 'text/plain',
2122
+ csv: 'text/csv', md: 'text/markdown', html: 'text/html', css: 'text/css', js: 'text/javascript',
2123
+ mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', zip: 'application/zip',
2124
+ };
2125
+ function guessContentType(name) {
2126
+ const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
2127
+ return CONTENT_TYPES[ext] ?? '';
2128
+ }
2129
+ function openRealtimeChannel(sdk, name, handlers) {
2130
+ const WS = globalThis.WebSocket;
2131
+ let status = 'connecting';
2132
+ let socket = null;
2133
+ let closed = false;
2134
+ let attempt = 0;
2135
+ let timer;
2136
+ const setStatus = (next) => {
2137
+ if (status === next)
2138
+ return;
2139
+ status = next;
2140
+ handlers.onStatus?.(next);
2141
+ };
2142
+ const schedule = () => {
2143
+ if (closed)
2144
+ return;
2145
+ setStatus('reconnecting');
2146
+ const cap = handlers.maxBackoffMs ?? 15_000;
2147
+ const delay = Math.min(cap, 500 * 2 ** Math.min(attempt, 6)) * (0.75 + Math.random() * 0.5);
2148
+ attempt += 1;
2149
+ timer = setTimeout(() => void connect(), delay);
2150
+ };
2151
+ const connect = async () => {
2152
+ if (closed)
2153
+ return;
2154
+ if (!WS) {
2155
+ handlers.onError?.(SovariaError.config('No WebSocket implementation available in this runtime.'));
2156
+ setStatus('closed');
2157
+ return;
2158
+ }
2159
+ try {
2160
+ const url = await sdk.realtime.wsUrlWithTicket(name);
2161
+ if (closed)
2162
+ return;
2163
+ const ws = new WS(url);
2164
+ socket = ws;
2165
+ ws.onopen = () => {
2166
+ attempt = 0;
2167
+ setStatus('live');
2168
+ };
2169
+ ws.onmessage = (event) => {
2170
+ let frame;
2171
+ try {
2172
+ frame = JSON.parse(String(event.data));
2173
+ }
2174
+ catch {
2175
+ return; // pings and other non-JSON frames
2176
+ }
2177
+ if (frame.type === 'ping' || frame.type === 'pong')
2178
+ return;
2179
+ const payload = (frame.payload ?? frame.data ?? frame);
2180
+ try {
2181
+ handlers.onMessage?.(payload, frame);
2182
+ }
2183
+ catch (error) {
2184
+ console.error('SovariaSDK: realtime onMessage handler failed', error);
2185
+ }
2186
+ };
2187
+ ws.onclose = () => {
2188
+ socket = null;
2189
+ schedule();
2190
+ };
2191
+ ws.onerror = (error) => handlers.onError?.(error);
2192
+ }
2193
+ catch (error) {
2194
+ handlers.onError?.(error);
2195
+ // Signed out (session can't be refreshed) or no permission: retrying will not help, and
2196
+ // every retry is a refused ticket request. A new sign-in opens channels again.
2197
+ if (error instanceof SovariaError && (error.code === 'PERMISSION_DENIED' || error.code === 'CONFIG_ERROR' || error.code === 'UNAUTHENTICATED')) {
2198
+ setStatus('closed');
2199
+ return;
2200
+ }
2201
+ schedule();
2202
+ }
2203
+ };
2204
+ handlers.onStatus?.('connecting');
2205
+ void connect();
2206
+ return {
2207
+ name,
2208
+ get status() {
2209
+ return status;
2210
+ },
2211
+ send: (payload) => sdk.realtime.publish(name, payload),
2212
+ close: () => {
2213
+ closed = true;
2214
+ if (timer)
2215
+ clearTimeout(timer);
2216
+ const ws = socket;
2217
+ socket = null;
2218
+ ws?.close();
2219
+ setStatus('closed');
2220
+ },
2221
+ };
2222
+ }
2223
+ /** Map sovaria.config.json content (written by `sov init` / `sov link`) to SDK bootstrap config. */
2224
+ export function portalConfigFromProjectFile(raw, options = {}) {
2225
+ const ref = String(raw.project?.ref ?? '').trim();
2226
+ const gatewayUrl = String(raw.gateway?.url ?? '').trim() || (ref ? `https://${ref}.sovarias.in` : '');
2227
+ if (!gatewayUrl) {
2228
+ throw SovariaError.config('Missing gateway.url in sovaria.config.json — run `sov link` in your app folder.');
2229
+ }
2230
+ const scope = raw.scope ?? {};
2231
+ const env = globalThis.process?.env;
2232
+ const accessToken = options.accessToken ??
2233
+ options.access_token ??
2234
+ env?.SOVARIA_ACCESS_TOKEN;
2235
+ const publishableKey = options.publishableKey ??
2236
+ options.publishable_key ??
2237
+ options.anonKey ??
2238
+ options.anon_key ??
2239
+ env?.SOVARIA_PUBLISHABLE_KEY ??
2240
+ env?.SOVARIA_ANON_KEY;
2241
+ return {
2242
+ ...options,
2243
+ url: gatewayUrl,
2244
+ orgId: String(options.orgId ?? scope.org_id ?? scope.orgId ?? '').trim(),
2245
+ projectId: String(options.projectId ?? scope.project_id ?? scope.projectId ?? '').trim(),
2246
+ envId: String(options.envId ?? scope.env_id ?? scope.envId ?? '').trim(),
2247
+ accessToken: accessToken ? String(accessToken).trim() : undefined,
2248
+ publishableKey: publishableKey ? String(publishableKey).trim() : undefined,
2249
+ };
2250
+ }
2251
+ /**
2252
+ * Create a client from sovaria.config.json. In a bundler, import the file and pass the anon key:
2253
+ * `createClientFromProjectConfig(config, { publishableKey: import.meta.env.VITE_SOVARIA_ANON_KEY })`.
2254
+ */
2255
+ export function createClientFromProjectConfig(raw, options = {}) {
2256
+ return SovariaSDK.fromPortalConfig(portalConfigFromProjectFile(raw, options));
2257
+ }
2258
+ /**
2259
+ * Create a client. Either pass the two values from the console's **Connect** panel —
2260
+ * `createClient('https://<ref>.sovarias.in', '<anon-key>')` — or a config object.
2261
+ */
2262
+ export function createClient(urlOrConfig, key, options = {}) {
2263
+ if (typeof urlOrConfig === 'string') {
2264
+ return SovariaSDK.fromPortalConfig({ ...options, url: urlOrConfig, ...(key ? { publishableKey: key } : {}) });
2265
+ }
2266
+ return SovariaSDK.fromPortalConfig(urlOrConfig);
2267
+ }
2268
+ /** A redirect into an app (custom scheme), which the server only accepts with PKCE. */
2269
+ function isAppRedirect(url) {
2270
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec((url ?? '').trim());
2271
+ return !!match && !['http', 'https'].includes(match[1].toLowerCase());
2272
+ }
2273
+ function randomVerifier() {
2274
+ const bytes = new Uint8Array(32);
2275
+ globalThis.crypto.getRandomValues(bytes);
2276
+ return base64Url(bytes);
2277
+ }
2278
+ async function s256(verifier) {
2279
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
2280
+ return base64Url(new Uint8Array(digest));
2281
+ }
2282
+ function base64Url(bytes) {
2283
+ let bin = '';
2284
+ for (const b of bytes)
2285
+ bin += String.fromCharCode(b);
2286
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
2287
+ }