@sovaria/sdk 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +248 -2
- package/dist/analytics.d.ts +92 -0
- package/dist/analytics.js +236 -0
- package/dist/client.d.ts +146 -0
- package/dist/client.js +583 -0
- package/dist/collections.d.ts +255 -0
- package/dist/collections.js +569 -0
- package/dist/complete-api.d.ts +54 -0
- package/dist/complete-api.js +71 -0
- package/dist/crashes.d.ts +111 -0
- package/dist/crashes.js +244 -0
- package/dist/crypto.d.ts +37 -0
- package/dist/crypto.js +189 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +258 -0
- package/dist/firestore.d.ts +239 -0
- package/dist/firestore.js +431 -0
- package/dist/generated.d.ts +2462 -0
- package/dist/generated.js +1962 -0
- package/dist/inapp.d.ts +52 -0
- package/dist/inapp.js +48 -0
- package/dist/index.d.ts +3562 -0
- package/dist/index.js +3018 -0
- package/dist/offline.d.ts +92 -0
- package/dist/offline.js +575 -0
- package/dist/passkeys.d.ts +17 -0
- package/dist/passkeys.js +76 -0
- package/dist/perf.d.ts +76 -0
- package/dist/perf.js +212 -0
- package/dist/remote-config.d.ts +87 -0
- package/dist/remote-config.js +182 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.js +1 -0
- package/dist/webpush.d.ts +46 -0
- package/dist/webpush.js +99 -0
- package/package.json +74 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,3018 @@
|
|
|
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 { credentialToJSON, creationOptionsFromJSON, passkeysSupported, requestOptionsFromJSON } from './passkeys.js';
|
|
6
|
+
export { base64urlToBuffer, bufferToBase64url, credentialToJSON, creationOptionsFromJSON, passkeysSupported, requestOptionsFromJSON } from './passkeys.js';
|
|
7
|
+
import { DocumentReference, Query, WriteBatch, runTransaction as runDocumentTransaction, } from './collections.js';
|
|
8
|
+
export { DocumentReference, FieldValue, LiveQuery, Query, Transaction, VectorQuery, WriteBatch, newDocumentId, splitTransforms, and, average, count, filter, or, sum, } from './collections.js';
|
|
9
|
+
import { createOfflineTransport } from './offline.js';
|
|
10
|
+
import { CrashReporter } from './crashes.js';
|
|
11
|
+
export { CrashReporter, distinctId, parseStack } from './crashes.js';
|
|
12
|
+
import { PerfMonitor } from './perf.js';
|
|
13
|
+
export { PerfMonitor } from './perf.js';
|
|
14
|
+
import { WebPushClient } from './webpush.js';
|
|
15
|
+
import { InAppMessages } from './inapp.js';
|
|
16
|
+
export { InAppMessages } from './inapp.js';
|
|
17
|
+
export { WebPushClient, serviceWorkerSource, urlBase64ToUint8Array } from './webpush.js';
|
|
18
|
+
import { Analytics } from './analytics.js';
|
|
19
|
+
import { RemoteConfig } from './remote-config.js';
|
|
20
|
+
export { Analytics } from './analytics.js';
|
|
21
|
+
export { RemoteConfig } from './remote-config.js';
|
|
22
|
+
export { evaluateQuery, indexedDbStorage, keyValueStorage, memoryStorage, } from './offline.js';
|
|
23
|
+
import { decryptDocument, decryptDocumentWithKek, deriveKek, encryptDocument, encryptDocumentWithKek, hexToBytes, bytesToHex, zeroBytes, } from './crypto.js';
|
|
24
|
+
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';
|
|
25
|
+
/** Arguments as query-string values: objects and arrays as JSON, null left out. */
|
|
26
|
+
function rpcQueryArgs(args) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const [key, value] of Object.entries(args)) {
|
|
29
|
+
if (value === null || value === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
out[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function storageObjectPath(bucket, objectPath) {
|
|
36
|
+
return [bucket, ...objectPath.split('/')].map((p) => safePathSegment(p)).join('/');
|
|
37
|
+
}
|
|
38
|
+
function resolveFetchImpl(fetchImpl) {
|
|
39
|
+
const candidate = fetchImpl ?? globalThis.fetch;
|
|
40
|
+
if (typeof candidate !== 'function') {
|
|
41
|
+
throw SovariaError.config('No fetch implementation available. Provide fetchImpl or run in an environment with global fetch.', 500);
|
|
42
|
+
}
|
|
43
|
+
// Browser fetch requires the Window context in some runtimes.
|
|
44
|
+
if (candidate === globalThis.fetch) {
|
|
45
|
+
return candidate.bind(globalThis);
|
|
46
|
+
}
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
50
|
+
const SCOPE_REQUIRED_PREFIXES = ['/api/v1/sql/', '/api/v1/data/rel/', '/api/v1/rt/'];
|
|
51
|
+
const SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
52
|
+
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
53
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
54
|
+
function sleep(ms) {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
57
|
+
function getHeaderCaseInsensitive(headers, headerName) {
|
|
58
|
+
const wanted = headerName.toLowerCase();
|
|
59
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
60
|
+
if (key.toLowerCase() === wanted) {
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
function generateIdempotencyKey() {
|
|
67
|
+
const cryptoObj = globalThis.crypto;
|
|
68
|
+
if (typeof cryptoObj?.randomUUID === 'function') {
|
|
69
|
+
return cryptoObj.randomUUID();
|
|
70
|
+
}
|
|
71
|
+
if (typeof cryptoObj?.getRandomValues === 'function') {
|
|
72
|
+
const bytes = new Uint8Array(16);
|
|
73
|
+
cryptoObj.getRandomValues(bytes);
|
|
74
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
75
|
+
}
|
|
76
|
+
throw SovariaError.config('No CSPRNG available for idempotency key generation', 500);
|
|
77
|
+
}
|
|
78
|
+
function existingIdempotencyKey(headers) {
|
|
79
|
+
const existing = getHeaderCaseInsensitive(headers, 'Idempotency-Key') ??
|
|
80
|
+
getHeaderCaseInsensitive(headers, 'X-Idempotency-Key');
|
|
81
|
+
return existing?.trim() || undefined;
|
|
82
|
+
}
|
|
83
|
+
/** Sets Idempotency-Key, dropping a differently-cased copy so the header is sent once. */
|
|
84
|
+
function setIdempotencyKeyHeader(headers, key) {
|
|
85
|
+
for (const name of Object.keys(headers)) {
|
|
86
|
+
if (name.toLowerCase() === 'idempotency-key')
|
|
87
|
+
delete headers[name];
|
|
88
|
+
}
|
|
89
|
+
headers['Idempotency-Key'] = key;
|
|
90
|
+
}
|
|
91
|
+
function redactRefreshToken(tokens) {
|
|
92
|
+
const redacted = { ...tokens };
|
|
93
|
+
delete redacted.refresh_token;
|
|
94
|
+
return redacted;
|
|
95
|
+
}
|
|
96
|
+
function looksLikeJwt(token) {
|
|
97
|
+
const parts = token.split('.');
|
|
98
|
+
return parts.length === 3 && parts.every(Boolean) && token.startsWith('eyJ');
|
|
99
|
+
}
|
|
100
|
+
function parseRetryAfterMs(retryAfterHeader) {
|
|
101
|
+
if (!retryAfterHeader) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const seconds = Number.parseFloat(retryAfterHeader);
|
|
105
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
106
|
+
return Math.floor(seconds * 1000);
|
|
107
|
+
}
|
|
108
|
+
const retryAtMs = Date.parse(retryAfterHeader);
|
|
109
|
+
if (Number.isNaN(retryAtMs)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return Math.max(0, retryAtMs - Date.now());
|
|
113
|
+
}
|
|
114
|
+
function computeRetryDelayMs(attempt, retryBaseDelayMs, retryAfterMs = null) {
|
|
115
|
+
const jitterBackoffMs = Math.min(10_000, retryBaseDelayMs * 2 ** attempt * Math.random());
|
|
116
|
+
if (retryAfterMs === null) {
|
|
117
|
+
return jitterBackoffMs;
|
|
118
|
+
}
|
|
119
|
+
return Math.max(jitterBackoffMs, Math.min(60_000, retryAfterMs));
|
|
120
|
+
}
|
|
121
|
+
function safePathSegment(value) {
|
|
122
|
+
return encodeURIComponent(value);
|
|
123
|
+
}
|
|
124
|
+
function docCollectionPath(collection, documentId) {
|
|
125
|
+
const collectionPath = collection
|
|
126
|
+
.split('/')
|
|
127
|
+
.map((segment) => safePathSegment(segment))
|
|
128
|
+
.join('/');
|
|
129
|
+
return `/api/v1/data/doc/${collectionPath}/${safePathSegment(documentId)}`;
|
|
130
|
+
}
|
|
131
|
+
function normalizeSubpath(path) {
|
|
132
|
+
return path.replace(/^\/+/, '');
|
|
133
|
+
}
|
|
134
|
+
function mergeQuery(query) {
|
|
135
|
+
const params = new URLSearchParams();
|
|
136
|
+
if (!query) {
|
|
137
|
+
return params;
|
|
138
|
+
}
|
|
139
|
+
for (const [key, value] of Object.entries(query)) {
|
|
140
|
+
if (value === undefined || value === null) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
params.set(key, String(value));
|
|
144
|
+
}
|
|
145
|
+
return params;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Inside an Edge Function invoked in simulation mode (Forge sets SOVARIA_SIMULATION=1): every
|
|
149
|
+
* Sovaria call is checked as usual and its writes are rolled back or refused, so nothing changes.
|
|
150
|
+
*/
|
|
151
|
+
function simulationRun() {
|
|
152
|
+
const env = globalThis.process?.env;
|
|
153
|
+
const deno = globalThis.Deno;
|
|
154
|
+
let value;
|
|
155
|
+
try {
|
|
156
|
+
value = env?.SOVARIA_SIMULATION ?? deno?.env?.get('SOVARIA_SIMULATION');
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
value = undefined; // Deno without --allow-env
|
|
160
|
+
}
|
|
161
|
+
return (value ?? '').trim() === '1';
|
|
162
|
+
}
|
|
163
|
+
export class SovariaSDK {
|
|
164
|
+
baseUrl;
|
|
165
|
+
fetchImpl;
|
|
166
|
+
retryCount;
|
|
167
|
+
retryBaseDelayMs;
|
|
168
|
+
accessToken;
|
|
169
|
+
/** Project anon/publishable key: the token used when no user is signed in. */
|
|
170
|
+
publishableKey = '';
|
|
171
|
+
/** PKCE verifier for an in-flight provider sign-in (fallback when sessionStorage is unavailable). */
|
|
172
|
+
ssoVerifier;
|
|
173
|
+
/** PKCE verifier for an email link that opens an app (magic link / password reset). */
|
|
174
|
+
linkVerifier;
|
|
175
|
+
device;
|
|
176
|
+
attestationToken;
|
|
177
|
+
refreshToken;
|
|
178
|
+
expiresAtMs;
|
|
179
|
+
refreshInFlight;
|
|
180
|
+
scope;
|
|
181
|
+
/** In-flight lookup of the project/org behind a project URL (see resolveScope). */
|
|
182
|
+
scopeLookup;
|
|
183
|
+
onTokenRefresh;
|
|
184
|
+
onTokenRefreshSensitive;
|
|
185
|
+
sessionStore;
|
|
186
|
+
authListeners = new Set();
|
|
187
|
+
/** Session-cached Argon2id salt (hex from login/register). */
|
|
188
|
+
argon2Salt;
|
|
189
|
+
/** Session-cached KEK derived at login — reused for E2EE document ops. */
|
|
190
|
+
cachedKek;
|
|
191
|
+
constructor(options) {
|
|
192
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
193
|
+
this.fetchImpl = resolveFetchImpl(options.fetchImpl);
|
|
194
|
+
this.retryCount = options.retryCount ?? 4;
|
|
195
|
+
this.retryBaseDelayMs = options.retryBaseDelayMs ?? 100;
|
|
196
|
+
this.accessToken = options.accessToken ?? '';
|
|
197
|
+
this.refreshToken = options.refreshToken ?? '';
|
|
198
|
+
const ttlMs = Math.max((options.expiresIn ?? 3600) * 1000, REFRESH_MARGIN_MS + 1);
|
|
199
|
+
this.expiresAtMs = this.accessToken ? Date.now() + ttlMs : 0;
|
|
200
|
+
this.refreshInFlight = null;
|
|
201
|
+
this.onTokenRefresh = options.onTokenRefresh;
|
|
202
|
+
this.onTokenRefreshSensitive = options.onTokenRefreshSensitive;
|
|
203
|
+
this.device = options.device;
|
|
204
|
+
this.attestationToken = options.attestationToken;
|
|
205
|
+
this.configureSessionStore(options.persistSession);
|
|
206
|
+
}
|
|
207
|
+
/** Device + attestation headers (mobile apps); empty for a plain web or server client. */
|
|
208
|
+
async clientHeaders() {
|
|
209
|
+
const out = {};
|
|
210
|
+
if (this.device?.id) {
|
|
211
|
+
out['X-Sovaria-Device-ID'] = this.device.id;
|
|
212
|
+
if (this.device.name)
|
|
213
|
+
out['X-Sovaria-Device-Name'] = this.device.name;
|
|
214
|
+
if (this.device.platform)
|
|
215
|
+
out['X-Sovaria-Device-Platform'] = this.device.platform;
|
|
216
|
+
if (this.device.appVersion)
|
|
217
|
+
out['X-Sovaria-App-Version'] = this.device.appVersion;
|
|
218
|
+
}
|
|
219
|
+
if (this.attestationToken) {
|
|
220
|
+
const token = await this.attestationToken();
|
|
221
|
+
if (token)
|
|
222
|
+
out['X-Sovaria-Attestation'] = token;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
/** Start a PKCE pair for an email link; the verifier is kept (session storage too) until the link is opened. */
|
|
227
|
+
async startLinkPkce() {
|
|
228
|
+
const verifier = randomVerifier();
|
|
229
|
+
this.linkVerifier = verifier;
|
|
230
|
+
try {
|
|
231
|
+
;
|
|
232
|
+
(this.sessionStore?.storage ?? globalThis.sessionStorage)?.setItem('sovaria.link.verifier', verifier);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
/* kept in memory only */
|
|
236
|
+
}
|
|
237
|
+
return s256(verifier);
|
|
238
|
+
}
|
|
239
|
+
takeLinkVerifier(explicit) {
|
|
240
|
+
if (explicit)
|
|
241
|
+
return explicit;
|
|
242
|
+
let stored;
|
|
243
|
+
try {
|
|
244
|
+
const storage = this.sessionStore?.storage ?? globalThis.sessionStorage;
|
|
245
|
+
stored = storage?.getItem('sovaria.link.verifier') ?? undefined;
|
|
246
|
+
storage?.removeItem('sovaria.link.verifier');
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
stored = undefined;
|
|
250
|
+
}
|
|
251
|
+
const verifier = stored ?? this.linkVerifier;
|
|
252
|
+
this.linkVerifier = undefined;
|
|
253
|
+
return verifier;
|
|
254
|
+
}
|
|
255
|
+
configureSessionStore(option) {
|
|
256
|
+
if (!option)
|
|
257
|
+
return;
|
|
258
|
+
const custom = typeof option === 'object' ? option : {};
|
|
259
|
+
let storage = custom.storage;
|
|
260
|
+
if (!storage) {
|
|
261
|
+
try {
|
|
262
|
+
storage = globalThis.localStorage;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
storage = undefined; // blocked storage (privacy mode): the session lasts for this page only
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!storage)
|
|
269
|
+
return;
|
|
270
|
+
let host = 'default';
|
|
271
|
+
try {
|
|
272
|
+
host = new URL(this.baseUrl).host;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
/* relative base URL */
|
|
276
|
+
}
|
|
277
|
+
this.sessionStore = { storage, key: custom.key ?? `sovaria.session.${host}` };
|
|
278
|
+
}
|
|
279
|
+
/** Load a stored session (called once the publishable key is known, so sign-out falls back to it). */
|
|
280
|
+
restoreSession() {
|
|
281
|
+
const store = this.sessionStore;
|
|
282
|
+
if (!store)
|
|
283
|
+
return;
|
|
284
|
+
let saved = null;
|
|
285
|
+
try {
|
|
286
|
+
const raw = store.storage.getItem(store.key);
|
|
287
|
+
saved = raw ? JSON.parse(raw) : null;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
saved = null;
|
|
291
|
+
}
|
|
292
|
+
if (!saved?.access_token)
|
|
293
|
+
return;
|
|
294
|
+
if (!saved.refresh_token && saved.expires_at <= Date.now()) {
|
|
295
|
+
this.writeSession(null);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this.accessToken = saved.access_token;
|
|
299
|
+
this.refreshToken = saved.refresh_token ?? '';
|
|
300
|
+
this.expiresAtMs = saved.expires_at;
|
|
301
|
+
}
|
|
302
|
+
writeSession(session) {
|
|
303
|
+
const store = this.sessionStore;
|
|
304
|
+
if (!store)
|
|
305
|
+
return;
|
|
306
|
+
try {
|
|
307
|
+
if (session)
|
|
308
|
+
store.storage.setItem(store.key, JSON.stringify(session));
|
|
309
|
+
else
|
|
310
|
+
store.storage.removeItem(store.key);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* storage full or blocked: keep working in memory */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
currentSession() {
|
|
317
|
+
if (!this.accessToken || this.accessToken === this.publishableKey)
|
|
318
|
+
return null;
|
|
319
|
+
return {
|
|
320
|
+
access_token: this.accessToken,
|
|
321
|
+
...(this.refreshToken ? { refresh_token: this.refreshToken } : {}),
|
|
322
|
+
expires_at: this.expiresAtMs,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
emitAuth(event) {
|
|
326
|
+
const session = this.currentSession();
|
|
327
|
+
if (event !== 'INITIAL_SESSION')
|
|
328
|
+
this.writeSession(session);
|
|
329
|
+
for (const listener of [...this.authListeners]) {
|
|
330
|
+
try {
|
|
331
|
+
listener(event, session);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
console.error('SovariaSDK: onAuthStateChange listener failed', error);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
static fromPortalConfig(config) {
|
|
339
|
+
const baseUrl = String(config.url ?? config.baseUrl ?? '').trim();
|
|
340
|
+
if (!baseUrl) {
|
|
341
|
+
throw SovariaError.config('Missing portal URL. Provide config.url or config.baseUrl.');
|
|
342
|
+
}
|
|
343
|
+
const accessToken = String(config.accessToken ??
|
|
344
|
+
config.access_token ??
|
|
345
|
+
config.publishableKey ??
|
|
346
|
+
config.publishable_key ??
|
|
347
|
+
config.anonKey ??
|
|
348
|
+
config.anon_key ??
|
|
349
|
+
'').trim();
|
|
350
|
+
const expiresInRaw = config.expiresIn ?? config.expires_in;
|
|
351
|
+
const sdk = new SovariaSDK({
|
|
352
|
+
baseUrl,
|
|
353
|
+
accessToken: accessToken || undefined,
|
|
354
|
+
expiresIn: typeof expiresInRaw === 'number' ? expiresInRaw : undefined,
|
|
355
|
+
onTokenRefresh: config.onTokenRefresh,
|
|
356
|
+
onTokenRefreshSensitive: config.onTokenRefreshSensitive,
|
|
357
|
+
});
|
|
358
|
+
const hasAppKey = Boolean(String(config.publishableKey ?? config.publishable_key ?? config.anonKey ?? config.anon_key ?? '').trim());
|
|
359
|
+
const persist = config.persistSession ?? (hasAppKey && typeof globalThis.window !== 'undefined');
|
|
360
|
+
sdk.configureSessionStore(persist);
|
|
361
|
+
const publishable = String(config.publishableKey ?? config.publishable_key ?? '').trim();
|
|
362
|
+
const fallbackKey = publishable || String(config.anonKey ?? config.anon_key ?? '').trim();
|
|
363
|
+
if (fallbackKey && !looksLikeJwt(fallbackKey)) {
|
|
364
|
+
sdk.publishableKey = fallbackKey;
|
|
365
|
+
}
|
|
366
|
+
if (publishable && looksLikeJwt(publishable)) {
|
|
367
|
+
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.');
|
|
368
|
+
}
|
|
369
|
+
const orgId = String(config.orgId ?? config.org_id ?? '').trim();
|
|
370
|
+
const projectId = String(config.projectId ?? config.project_id ?? '').trim();
|
|
371
|
+
const envId = String(config.envId ?? config.env_id ?? '').trim();
|
|
372
|
+
if (orgId && projectId) {
|
|
373
|
+
sdk.setScope({
|
|
374
|
+
orgId,
|
|
375
|
+
projectId,
|
|
376
|
+
envId: envId || undefined,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
sdk.restoreSession();
|
|
380
|
+
return sdk;
|
|
381
|
+
}
|
|
382
|
+
static fromEnv(env = {}) {
|
|
383
|
+
const baseUrl = String(env.SOVARIA_URL ?? env.NEXT_PUBLIC_SOVARIA_URL ?? env.VITE_SOVARIA_URL ?? '').trim();
|
|
384
|
+
if (!baseUrl) {
|
|
385
|
+
throw SovariaError.config('Missing SOVARIA_URL (or NEXT_PUBLIC_SOVARIA_URL / VITE_SOVARIA_URL).');
|
|
386
|
+
}
|
|
387
|
+
return SovariaSDK.fromPortalConfig({
|
|
388
|
+
baseUrl,
|
|
389
|
+
orgId: env.SOVARIA_ORG_ID,
|
|
390
|
+
projectId: env.SOVARIA_PROJECT_ID,
|
|
391
|
+
envId: env.SOVARIA_ENV_ID,
|
|
392
|
+
accessToken: env.SOVARIA_ACCESS_TOKEN,
|
|
393
|
+
publishableKey: env.SOVARIA_PUBLISHABLE_KEY,
|
|
394
|
+
anonKey: env.SOVARIA_ANON_KEY,
|
|
395
|
+
expiresIn: env.SOVARIA_EXPIRES_IN ? Number(env.SOVARIA_EXPIRES_IN) : undefined,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
setAccessToken(token, options = {}) {
|
|
399
|
+
this.accessToken = token.trim();
|
|
400
|
+
if (this.accessToken) {
|
|
401
|
+
const ttlMs = Math.max((options.expiresIn ?? 3600) * 1000, REFRESH_MARGIN_MS + 1);
|
|
402
|
+
this.expiresAtMs = Date.now() + ttlMs;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
this.expiresAtMs = 0;
|
|
406
|
+
}
|
|
407
|
+
return this;
|
|
408
|
+
}
|
|
409
|
+
setRefreshToken(token) {
|
|
410
|
+
this.refreshToken = token.trim();
|
|
411
|
+
return this;
|
|
412
|
+
}
|
|
413
|
+
clearAccessToken() {
|
|
414
|
+
const hadSession = this.currentSession() !== null;
|
|
415
|
+
this.accessToken = this.publishableKey;
|
|
416
|
+
this.refreshToken = '';
|
|
417
|
+
this.expiresAtMs = 0;
|
|
418
|
+
this.refreshInFlight = null;
|
|
419
|
+
this.clearKekCache();
|
|
420
|
+
if (hadSession)
|
|
421
|
+
this.emitAuth('SIGNED_OUT');
|
|
422
|
+
else
|
|
423
|
+
this.writeSession(null);
|
|
424
|
+
return this;
|
|
425
|
+
}
|
|
426
|
+
clearKekCache() {
|
|
427
|
+
if (this.cachedKek) {
|
|
428
|
+
zeroBytes(this.cachedKek);
|
|
429
|
+
}
|
|
430
|
+
this.cachedKek = undefined;
|
|
431
|
+
this.argon2Salt = undefined;
|
|
432
|
+
}
|
|
433
|
+
cacheArgon2SaltFromResponse(tokens) {
|
|
434
|
+
const saltHex = String(tokens.argon2_salt ?? '').trim();
|
|
435
|
+
if (!saltHex || Boolean(tokens.mfa_required)) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
this.argon2Salt = hexToBytes(saltHex);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// Non-fatal: E2EE calls will fall back to explicit password.
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async tryDeriveKekFromPassword(password, tokens) {
|
|
446
|
+
if (!this.argon2Salt || Boolean(tokens.mfa_required)) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const kek = await deriveKek(password, this.argon2Salt);
|
|
451
|
+
if (this.cachedKek) {
|
|
452
|
+
zeroBytes(this.cachedKek);
|
|
453
|
+
}
|
|
454
|
+
this.cachedKek = kek;
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// Non-fatal: session remains valid; E2EE requires explicit password.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
applyAuthResponse(tokens, event = 'SIGNED_IN') {
|
|
461
|
+
const accessToken = String(tokens.access_token ?? '');
|
|
462
|
+
const refreshToken = String(tokens.refresh_token ?? '');
|
|
463
|
+
const mfaRequired = Boolean(tokens.mfa_required);
|
|
464
|
+
if (accessToken) {
|
|
465
|
+
this.setAccessToken(accessToken, {
|
|
466
|
+
expiresIn: Number(tokens.expires_in ?? (mfaRequired ? 300 : 3600)),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (refreshToken) {
|
|
470
|
+
this.setRefreshToken(refreshToken);
|
|
471
|
+
}
|
|
472
|
+
else if (mfaRequired) {
|
|
473
|
+
this.refreshToken = '';
|
|
474
|
+
}
|
|
475
|
+
this.cacheArgon2SaltFromResponse(tokens);
|
|
476
|
+
this.onTokenRefreshSensitive?.(tokens);
|
|
477
|
+
this.onTokenRefresh?.(redactRefreshToken(tokens));
|
|
478
|
+
if (accessToken && !mfaRequired)
|
|
479
|
+
this.emitAuth(event);
|
|
480
|
+
return tokens;
|
|
481
|
+
}
|
|
482
|
+
tokenNeedsRefresh() {
|
|
483
|
+
if (!this.accessToken || !this.refreshToken) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
return Date.now() >= this.expiresAtMs - REFRESH_MARGIN_MS;
|
|
487
|
+
}
|
|
488
|
+
/** Refresh when the token is near expiry, or always with `force` (the server said 401). */
|
|
489
|
+
async ensureAccessTokenFresh(force = false) {
|
|
490
|
+
if (!force && !this.tokenNeedsRefresh()) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (this.refreshInFlight) {
|
|
494
|
+
await this.refreshInFlight;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
this.refreshInFlight = (async () => {
|
|
498
|
+
let refreshed;
|
|
499
|
+
try {
|
|
500
|
+
refreshed = await this.auth.refresh(this.refreshToken);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
// The refresh token was revoked or expired: the user is signed out, not "broken".
|
|
504
|
+
if (error instanceof SovariaError && [400, 401, 403].includes(error.statusCode)) {
|
|
505
|
+
this.clearAccessToken();
|
|
506
|
+
throw new SovariaError(401, error.detail, 'UNAUTHENTICATED');
|
|
507
|
+
}
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
this.applyAuthResponse(refreshed, 'TOKEN_REFRESHED');
|
|
511
|
+
})();
|
|
512
|
+
try {
|
|
513
|
+
await this.refreshInFlight;
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
this.refreshInFlight = null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
setScope(scope) {
|
|
520
|
+
this.scope = {
|
|
521
|
+
orgId: scope.orgId.trim(),
|
|
522
|
+
projectId: scope.projectId.trim(),
|
|
523
|
+
envId: scope.envId?.trim(),
|
|
524
|
+
};
|
|
525
|
+
return this;
|
|
526
|
+
}
|
|
527
|
+
clearScope() {
|
|
528
|
+
this.scope = undefined;
|
|
529
|
+
this.scopeLookup = undefined;
|
|
530
|
+
return this;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* The org/project this client talks to. Set explicitly with setScope(), or discovered once from
|
|
534
|
+
* the project URL (https://<ref>.sovarias.in) — so `createClient(projectUrl, anonKey)` is enough.
|
|
535
|
+
*/
|
|
536
|
+
async resolveScope() {
|
|
537
|
+
if (this.scope?.orgId && this.scope?.projectId)
|
|
538
|
+
return this.scope;
|
|
539
|
+
this.scopeLookup ??= this.request('GET', '/api/v1/auth/public-config', {
|
|
540
|
+
requiresAuth: false,
|
|
541
|
+
requiresScope: false,
|
|
542
|
+
})
|
|
543
|
+
.then((cfg) => {
|
|
544
|
+
const project = (cfg?.project ?? {});
|
|
545
|
+
if (project.id && project.orgId) {
|
|
546
|
+
this.setScope({ orgId: String(project.orgId), projectId: String(project.id) });
|
|
547
|
+
}
|
|
548
|
+
return this.scope;
|
|
549
|
+
})
|
|
550
|
+
.catch(() => {
|
|
551
|
+
this.scopeLookup = undefined;
|
|
552
|
+
return undefined;
|
|
553
|
+
});
|
|
554
|
+
return this.scopeLookup;
|
|
555
|
+
}
|
|
556
|
+
getScopeHeaders() {
|
|
557
|
+
if (!this.scope) {
|
|
558
|
+
return {};
|
|
559
|
+
}
|
|
560
|
+
const headers = {
|
|
561
|
+
'X-Sovaria-Org-ID': this.scope.orgId,
|
|
562
|
+
'X-Sovaria-Project-ID': this.scope.projectId,
|
|
563
|
+
};
|
|
564
|
+
if (this.scope.envId) {
|
|
565
|
+
headers['X-Sovaria-Env-ID'] = this.scope.envId;
|
|
566
|
+
}
|
|
567
|
+
return headers;
|
|
568
|
+
}
|
|
569
|
+
getRealtimeWebSocketUrl(channel, ticket) {
|
|
570
|
+
const protocolSwapped = this.baseUrl
|
|
571
|
+
.replace(/^https:\/\//, 'wss://')
|
|
572
|
+
.replace(/^http:\/\//, 'ws://');
|
|
573
|
+
const params = new URLSearchParams();
|
|
574
|
+
const resolvedTicket = (ticket ?? '').trim();
|
|
575
|
+
if (resolvedTicket) {
|
|
576
|
+
params.set('ticket', resolvedTicket);
|
|
577
|
+
}
|
|
578
|
+
if (this.scope?.orgId) {
|
|
579
|
+
params.set('org_id', this.scope.orgId);
|
|
580
|
+
}
|
|
581
|
+
if (this.scope?.projectId) {
|
|
582
|
+
params.set('project_id', this.scope.projectId);
|
|
583
|
+
}
|
|
584
|
+
if (this.scope?.envId) {
|
|
585
|
+
params.set('env_id', this.scope.envId);
|
|
586
|
+
}
|
|
587
|
+
const suffix = params.toString();
|
|
588
|
+
const base = `${protocolSwapped}/api/v1/rt/ws/${safePathSegment(channel)}`;
|
|
589
|
+
return suffix ? `${base}?${suffix}` : base;
|
|
590
|
+
}
|
|
591
|
+
async issueRealtimeWebSocketTicket(channel) {
|
|
592
|
+
await this.resolveScope();
|
|
593
|
+
this.assertScopePresent();
|
|
594
|
+
const response = await this.request('POST', '/api/v1/auth/ws-ticket', {
|
|
595
|
+
body: {
|
|
596
|
+
channel,
|
|
597
|
+
org_id: this.scope?.orgId,
|
|
598
|
+
project_id: this.scope?.projectId,
|
|
599
|
+
env_id: this.scope?.envId,
|
|
600
|
+
},
|
|
601
|
+
requiresScope: true,
|
|
602
|
+
});
|
|
603
|
+
const ticket = String(response.ticket ?? '').trim();
|
|
604
|
+
if (!ticket) {
|
|
605
|
+
throw new SovariaError(502, 'Gateway did not return a websocket ticket');
|
|
606
|
+
}
|
|
607
|
+
return ticket;
|
|
608
|
+
}
|
|
609
|
+
async getRealtimeWebSocketUrlWithTicket(channel) {
|
|
610
|
+
const ticket = await this.issueRealtimeWebSocketTicket(channel);
|
|
611
|
+
return this.getRealtimeWebSocketUrl(channel, ticket);
|
|
612
|
+
}
|
|
613
|
+
offlineTransport;
|
|
614
|
+
/**
|
|
615
|
+
* Offline-first documents: reads fall back to a local cache, offline writes are applied locally at
|
|
616
|
+
* once and queued, and everything syncs in order when the connection is back. Call once, before
|
|
617
|
+
* reading or writing documents.
|
|
618
|
+
*
|
|
619
|
+
* ```ts
|
|
620
|
+
* const offline = sov.enableOffline({ storage: indexedDbStorage(), onSyncError: (e, writes) => console.warn(e, writes) })
|
|
621
|
+
* ```
|
|
622
|
+
*/
|
|
623
|
+
enableOffline(options = {}) {
|
|
624
|
+
if (!this.offlineTransport) {
|
|
625
|
+
this.offlineTransport = createOfflineTransport(this.onlineDocumentsTransport(), {
|
|
626
|
+
namespace: this.scope?.projectId || 'default', ...options,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return this.offlineTransport.controller;
|
|
630
|
+
}
|
|
631
|
+
documentsTransport() {
|
|
632
|
+
return this.offlineTransport ?? this.onlineDocumentsTransport();
|
|
633
|
+
}
|
|
634
|
+
onlineDocumentsTransport() {
|
|
635
|
+
return {
|
|
636
|
+
request: (method, path, options) => this.request(method, path, options),
|
|
637
|
+
openChannel: (name, handlers) => this.realtime.channel(name, {
|
|
638
|
+
onMessage: (payload) => handlers.onMessage?.(payload),
|
|
639
|
+
onStatus: (status) => handlers.onStatus?.(status),
|
|
640
|
+
onError: handlers.onError,
|
|
641
|
+
}),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* A document collection (or `users/alice/posts` subcollection): field queries, cursors,
|
|
646
|
+
* live listeners and `add`.
|
|
647
|
+
*
|
|
648
|
+
* ```ts
|
|
649
|
+
* const snap = await sov.collection('posts').where('tags', 'array-contains', 'news').orderBy('at', 'desc').limit(20).get()
|
|
650
|
+
* ```
|
|
651
|
+
*/
|
|
652
|
+
collection(path) {
|
|
653
|
+
return new Query(this.documentsTransport(), path);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Every collection with this id at any depth (`comments`, `posts/p1/comments`, …), like Firestore's
|
|
657
|
+
* `collectionGroup`. Guard rules and row policies apply per matched collection; `onSnapshot` is a live listener on the whole group.
|
|
658
|
+
*
|
|
659
|
+
* ```ts
|
|
660
|
+
* const top = await sov.collectionGroup('comments').where('flagged', '==', true).orderBy('at', 'desc').get()
|
|
661
|
+
* ```
|
|
662
|
+
*/
|
|
663
|
+
collectionGroup(collectionId) {
|
|
664
|
+
return new Query(this.documentsTransport(), collectionId, {}, true);
|
|
665
|
+
}
|
|
666
|
+
/** A document reference: `get`, `set` (merge), `create`, `update` with FieldValue transforms, `delete`, `onSnapshot`. */
|
|
667
|
+
doc(path) {
|
|
668
|
+
return new DocumentReference(this.documentsTransport(), path);
|
|
669
|
+
}
|
|
670
|
+
/** Writes committed together: every one or none. */
|
|
671
|
+
batch() {
|
|
672
|
+
return new WriteBatch(this.documentsTransport());
|
|
673
|
+
}
|
|
674
|
+
/** Read then write atomically; retried (up to `maxAttempts`) when a document it read changes meanwhile. */
|
|
675
|
+
runTransaction(fn, maxAttempts = 5) {
|
|
676
|
+
return runDocumentTransaction(this.documentsTransport(), fn, maxAttempts);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* App crash reporting: `sov.crashes.install({ release })` reports uncaught errors (browser and React
|
|
680
|
+
* Native); `sov.crashes.capture(error)` reports one you caught. Issues appear in the console under
|
|
681
|
+
* Observability › Crashes.
|
|
682
|
+
*/
|
|
683
|
+
crashes = new CrashReporter((events) => this.request('POST', '/crashes/v1/events', { body: { events }, requiresScope: false }), (sessions) => this.request('POST', '/crashes/v1/sessions', { body: { sessions }, requiresScope: false }));
|
|
684
|
+
/**
|
|
685
|
+
* Client performance monitoring: `sov.perf.install({ release })` measures web vitals (LCP, INP, CLS, FCP, TTFB)
|
|
686
|
+
* and every Sovaria request (joined to the server trace by its `traceparent`); `sov.perf.trace(name, fn)` times
|
|
687
|
+
* your own steps. Results are in the console under Observability › Performance.
|
|
688
|
+
*/
|
|
689
|
+
perf = new PerfMonitor((events) => this.request('POST', '/perf/v1/events', { body: { events }, requiresScope: false }), () => this.crashes.sessionId);
|
|
690
|
+
/** Web push for browsers with the project's own VAPID keys: `await sov.push.register()` after sign-in. */
|
|
691
|
+
push = new WebPushClient((method, path, options) => this.request(method, path, options));
|
|
692
|
+
/** In-app messages (fetch what to show, report impressions / clicks / dismissals) and push open tracking. */
|
|
693
|
+
inApp = new InAppMessages((method, path, options) => this.request(method, path, options));
|
|
694
|
+
/**
|
|
695
|
+
* Product analytics: `sov.analytics.track(name, props)`, `screen(name)`, `identify(userProps)`,
|
|
696
|
+
* `setEnabled(false)` for an opt-out. Batched, offline-safe; reports in the console under Analytics.
|
|
697
|
+
*/
|
|
698
|
+
analytics = new Analytics((body) => this.request('POST', '/analytics/v1/events', { body, requiresScope: false,
|
|
699
|
+
headers: { 'X-Sovaria-Device-ID': this.analytics.anonId } }), (granted, anonId, erase) => this.request('PUT', '/analytics/v1/consent', { body: { granted, anon_id: anonId, erase },
|
|
700
|
+
requiresScope: false }));
|
|
701
|
+
/**
|
|
702
|
+
* Remote config and feature flags: `setDefaults`, `fetchAndActivate()`, `getBool/getString/getNumber/getJson`,
|
|
703
|
+
* `onUpdate(cb)` (live after a publish). Values are evaluated on the server for this user.
|
|
704
|
+
*/
|
|
705
|
+
config = new RemoteConfig(async (query, etag) => {
|
|
706
|
+
try {
|
|
707
|
+
const body = await this.request('GET', '/config/v1/fetch', { query, requiresScope: false,
|
|
708
|
+
headers: { 'X-Sovaria-Device-ID': this.analytics.anonId, ...(etag ? { 'If-None-Match': `"${etag}"` } : {}) } });
|
|
709
|
+
return { status: 200, body };
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
if (error instanceof SovariaError && error.statusCode === 304)
|
|
713
|
+
return { status: 304 };
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
}, this.analytics, (channel, onMessage) => openRealtimeChannel(this, channel, { onMessage: () => onMessage() }));
|
|
717
|
+
async request(method, path, options = {}) {
|
|
718
|
+
if (path.startsWith('/crashes/v1') || path.startsWith('/perf/v1') || path.startsWith('/analytics/v1') || path.startsWith('/config/v1')) {
|
|
719
|
+
return this.requestImpl(method, path, options);
|
|
720
|
+
}
|
|
721
|
+
let status = 0;
|
|
722
|
+
const timed = this.perf.timesNetwork;
|
|
723
|
+
let traceId;
|
|
724
|
+
const started = timed ? (globalThis.performance?.now?.() ?? Date.now()) : 0;
|
|
725
|
+
if (timed && !Object.keys(options.headers ?? {}).some((k) => k.toLowerCase() === 'traceparent')) {
|
|
726
|
+
// The span and the request share one W3C trace id, so the console can open the server trace for it.
|
|
727
|
+
traceId = newTraceId();
|
|
728
|
+
options = { ...options, headers: { ...(options.headers ?? {}), traceparent: traceparentFor(traceId) } };
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
const out = await this.requestImpl(method, path, options);
|
|
732
|
+
status = 200;
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
status = error instanceof SovariaError ? error.statusCode : 0;
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
// Breadcrumb for crash reports: which Sovaria calls led up to an error (no bodies or tokens).
|
|
741
|
+
this.crashes.addBreadcrumb({ category: 'sovaria', message: `${method.toUpperCase()} ${path.split('?')[0]} ${status || 'network error'}` });
|
|
742
|
+
if (timed) {
|
|
743
|
+
this.perf.network(method, path, status, (globalThis.performance?.now?.() ?? Date.now()) - started, traceId);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async requestImpl(method, path, options = {}) {
|
|
748
|
+
const normalizedMethod = method.toUpperCase();
|
|
749
|
+
const requiresAuth = options.requiresAuth ?? true;
|
|
750
|
+
const requiresScope = options.requiresScope ?? this.pathRequiresScope(path);
|
|
751
|
+
if (requiresAuth && !this.accessToken) {
|
|
752
|
+
throw new SovariaError(401, 'Missing access token; call auth.consoleLogin() / auth.projectLogin() or setAccessToken() first');
|
|
753
|
+
}
|
|
754
|
+
if (requiresAuth) {
|
|
755
|
+
await this.ensureAccessTokenFresh();
|
|
756
|
+
}
|
|
757
|
+
if (requiresAuth && requiresScope) {
|
|
758
|
+
if (!this.scope)
|
|
759
|
+
await this.resolveScope();
|
|
760
|
+
this.assertScopePresent();
|
|
761
|
+
}
|
|
762
|
+
const queryParams = mergeQuery(options.query);
|
|
763
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
764
|
+
const url = `${this.baseUrl}${normalizedPath}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
|
765
|
+
const headers = {
|
|
766
|
+
...this.getScopeHeaders(),
|
|
767
|
+
...(await this.clientHeaders()),
|
|
768
|
+
...(simulationRun() ? { 'X-Echo-Mode': '1' } : {}),
|
|
769
|
+
...(options.headers ?? {}),
|
|
770
|
+
};
|
|
771
|
+
if (requiresAuth) {
|
|
772
|
+
headers.Authorization = `Bearer ${this.accessToken}`;
|
|
773
|
+
}
|
|
774
|
+
const hasBody = options.body !== undefined;
|
|
775
|
+
// FormData and raw bytes (Blob, ArrayBuffer, typed arrays) go as they are; anything else is JSON.
|
|
776
|
+
const rawBody = hasBody && (options.body instanceof FormData || (typeof Blob !== 'undefined' && options.body instanceof Blob)
|
|
777
|
+
|| options.body instanceof ArrayBuffer || ArrayBuffer.isView(options.body));
|
|
778
|
+
if (hasBody && !rawBody && !headers['Content-Type']) {
|
|
779
|
+
headers['Content-Type'] = 'application/json';
|
|
780
|
+
}
|
|
781
|
+
// One Idempotency-Key for every attempt of a write (the caller's, or a fresh one): if the first
|
|
782
|
+
// try was applied but its response was lost, the retry gets the stored answer instead of
|
|
783
|
+
// running the change twice.
|
|
784
|
+
const idempotencyKey = options.idempotencyKey?.trim() ||
|
|
785
|
+
existingIdempotencyKey(headers) ||
|
|
786
|
+
(MUTATING_METHODS.has(normalizedMethod) ? generateIdempotencyKey() : undefined);
|
|
787
|
+
if (idempotencyKey) {
|
|
788
|
+
setIdempotencyKeyHeader(headers, idempotencyKey);
|
|
789
|
+
}
|
|
790
|
+
const canRetry = SAFE_RETRY_METHODS.has(normalizedMethod) || MUTATING_METHODS.has(normalizedMethod);
|
|
791
|
+
// One trace per call (every retry shares it), so a refused request can be explained later.
|
|
792
|
+
const callerTrace = Object.keys(headers).find((k) => k.toLowerCase() === 'traceparent');
|
|
793
|
+
const traceId = callerTrace ? headers[callerTrace].split('-')[1] ?? newTraceId() : newTraceId();
|
|
794
|
+
for (let attempt = 0; attempt < this.retryCount; attempt += 1) {
|
|
795
|
+
if (!callerTrace)
|
|
796
|
+
headers.traceparent = traceparentFor(traceId);
|
|
797
|
+
try {
|
|
798
|
+
const fetchImpl = this.fetchImpl;
|
|
799
|
+
const response = await fetchImpl(url, {
|
|
800
|
+
method: normalizedMethod,
|
|
801
|
+
headers,
|
|
802
|
+
body: hasBody
|
|
803
|
+
? rawBody
|
|
804
|
+
? options.body
|
|
805
|
+
: JSON.stringify(options.body)
|
|
806
|
+
: undefined,
|
|
807
|
+
});
|
|
808
|
+
if (response.ok && options.responseType === 'blob') {
|
|
809
|
+
return (await response.blob());
|
|
810
|
+
}
|
|
811
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
812
|
+
const isJson = contentType.includes('application/json');
|
|
813
|
+
const payload = isJson
|
|
814
|
+
? await response.json().catch(() => ({}))
|
|
815
|
+
: await response.text().catch(() => '');
|
|
816
|
+
if (response.ok) {
|
|
817
|
+
options.onResponseHeaders?.(response.headers);
|
|
818
|
+
if (response.status === 204) {
|
|
819
|
+
return {};
|
|
820
|
+
}
|
|
821
|
+
return payload;
|
|
822
|
+
}
|
|
823
|
+
if (response.status === 401 &&
|
|
824
|
+
requiresAuth &&
|
|
825
|
+
this.refreshToken &&
|
|
826
|
+
attempt === 0) {
|
|
827
|
+
await this.ensureAccessTokenFresh(true);
|
|
828
|
+
headers.Authorization = `Bearer ${this.accessToken}`;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
// "The same Idempotency-Key is still being processed": wait, then the retry gets the first
|
|
832
|
+
// try's stored answer instead of running the change twice.
|
|
833
|
+
const inProgress = response.status === 409 && payload?.code === 'IDEMPOTENCY_IN_PROGRESS';
|
|
834
|
+
if (canRetry && (RETRYABLE_STATUSES.has(response.status) || inProgress) && attempt < this.retryCount - 1) {
|
|
835
|
+
const retryAfterMs = response.status === 429 || inProgress ? parseRetryAfterMs(response.headers.get('Retry-After')) : null;
|
|
836
|
+
const delay = computeRetryDelayMs(attempt, this.retryBaseDelayMs, retryAfterMs);
|
|
837
|
+
await sleep(delay);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
throw new SovariaError(response.status, payload).withTraceId(response.headers.get('X-Request-ID') ?? traceId);
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
if (error instanceof SovariaError) {
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
if (attempt >= this.retryCount - 1 || !canRetry) {
|
|
847
|
+
throw new SovariaError(0, String(error));
|
|
848
|
+
}
|
|
849
|
+
const delay = computeRetryDelayMs(attempt, this.retryBaseDelayMs);
|
|
850
|
+
await sleep(delay);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
throw new SovariaError(0, 'Max retries exceeded');
|
|
854
|
+
}
|
|
855
|
+
pathRequiresScope(path) {
|
|
856
|
+
return SCOPE_REQUIRED_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
857
|
+
}
|
|
858
|
+
assertScopePresent() {
|
|
859
|
+
if (this.scope?.orgId && this.scope?.projectId) {
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
throw SovariaError.config('This endpoint requires org/project scope. Call setScope({ orgId, projectId, envId? }).');
|
|
863
|
+
}
|
|
864
|
+
async productAuthRequest(method, suffix, options = {}) {
|
|
865
|
+
await this.resolveScope();
|
|
866
|
+
return this.request(method, this.productAuthPath(suffix), options);
|
|
867
|
+
}
|
|
868
|
+
productAuthPath(suffix) {
|
|
869
|
+
this.assertScopePresent();
|
|
870
|
+
return `/api/v1/projects/${safePathSegment(this.scope.projectId)}/auth${suffix}`;
|
|
871
|
+
}
|
|
872
|
+
auth = {
|
|
873
|
+
/**
|
|
874
|
+
* Listen for sign-in, token refresh and sign-out. Fires once right away with the current
|
|
875
|
+
* session (`INITIAL_SESSION`). Returns a function that stops listening.
|
|
876
|
+
*/
|
|
877
|
+
onAuthStateChange: (callback) => {
|
|
878
|
+
this.authListeners.add(callback);
|
|
879
|
+
queueMicrotask(() => {
|
|
880
|
+
if (!this.authListeners.has(callback))
|
|
881
|
+
return;
|
|
882
|
+
try {
|
|
883
|
+
callback('INITIAL_SESSION', this.currentSession());
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
console.error('SovariaSDK: onAuthStateChange listener failed', error);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
return () => {
|
|
890
|
+
this.authListeners.delete(callback);
|
|
891
|
+
};
|
|
892
|
+
},
|
|
893
|
+
/** The signed-in app user's session, or null. */
|
|
894
|
+
getSession: () => this.currentSession(),
|
|
895
|
+
/** Sign the app user out (revokes the refresh token) and fall back to the anon key. */
|
|
896
|
+
signOut: async () => {
|
|
897
|
+
await this.auth.logout();
|
|
898
|
+
},
|
|
899
|
+
/**
|
|
900
|
+
* Create an app user and sign them in. When the project requires email confirmation, the
|
|
901
|
+
* result has `email_verification_required: true` and no session until the user confirms.
|
|
902
|
+
*/
|
|
903
|
+
signUp: async (email, password, name = '') => {
|
|
904
|
+
const created = await this.auth.register(email, password, name);
|
|
905
|
+
if (created.access_token || created.email_verification_required)
|
|
906
|
+
return created;
|
|
907
|
+
const session = await this.auth.projectLogin(email, password);
|
|
908
|
+
return { ...created, ...session };
|
|
909
|
+
},
|
|
910
|
+
/** Sign an app user in with email and password (same as projectLogin). */
|
|
911
|
+
signIn: async (email, password) => this.auth.projectLogin(email, password),
|
|
912
|
+
/** Create an app user (no session). Most apps want signUp(), which also signs them in. */
|
|
913
|
+
register: async (email, password, name) => {
|
|
914
|
+
await this.resolveScope();
|
|
915
|
+
const scopePayload = this.scope
|
|
916
|
+
? {
|
|
917
|
+
org_id: this.scope.orgId,
|
|
918
|
+
project_id: this.scope.projectId,
|
|
919
|
+
...(this.scope.envId ? { env_id: this.scope.envId } : {}),
|
|
920
|
+
}
|
|
921
|
+
: {};
|
|
922
|
+
const tokens = await this.request('POST', '/api/v1/auth/register', {
|
|
923
|
+
requiresAuth: false,
|
|
924
|
+
body: { email, password, name, ...scopePayload },
|
|
925
|
+
});
|
|
926
|
+
if (tokens.access_token) {
|
|
927
|
+
this.applyAuthResponse(tokens);
|
|
928
|
+
await this.tryDeriveKekFromPassword(password, tokens);
|
|
929
|
+
}
|
|
930
|
+
return tokens;
|
|
931
|
+
},
|
|
932
|
+
/**
|
|
933
|
+
* @deprecated Use {@link consoleLogin} for operators or {@link projectLogin}
|
|
934
|
+
* for product users. Unscoped calls post to `/api/v1/auth/console/login`.
|
|
935
|
+
*/
|
|
936
|
+
login: async (email, password) => {
|
|
937
|
+
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).');
|
|
938
|
+
await this.resolveScope();
|
|
939
|
+
const scopePayload = this.scope
|
|
940
|
+
? {
|
|
941
|
+
org_id: this.scope.orgId,
|
|
942
|
+
project_id: this.scope.projectId,
|
|
943
|
+
...(this.scope.envId ? { env_id: this.scope.envId } : {}),
|
|
944
|
+
}
|
|
945
|
+
: {};
|
|
946
|
+
const path = this.scope?.orgId && this.scope?.projectId
|
|
947
|
+
? '/api/v1/auth/project/login'
|
|
948
|
+
: '/api/v1/auth/console/login';
|
|
949
|
+
const tokens = await this.request('POST', path, {
|
|
950
|
+
requiresAuth: false,
|
|
951
|
+
body: { email, password, ...scopePayload },
|
|
952
|
+
});
|
|
953
|
+
this.applyAuthResponse(tokens);
|
|
954
|
+
await this.tryDeriveKekFromPassword(password, tokens);
|
|
955
|
+
return tokens;
|
|
956
|
+
},
|
|
957
|
+
/** Strict console login — rejects every project-scoped credential. */
|
|
958
|
+
consoleLogin: async (email, password) => {
|
|
959
|
+
const tokens = await this.request('POST', '/api/v1/auth/console/login', {
|
|
960
|
+
requiresAuth: false,
|
|
961
|
+
body: { email, password },
|
|
962
|
+
});
|
|
963
|
+
this.applyAuthResponse(tokens);
|
|
964
|
+
await this.tryDeriveKekFromPassword(password, tokens);
|
|
965
|
+
return tokens;
|
|
966
|
+
},
|
|
967
|
+
/** Alias for consoleLogin() matching lane terminology in docs. */
|
|
968
|
+
loginConsole: async (email, password) => this.auth.consoleLogin(email, password),
|
|
969
|
+
/** Strict project login — requires X-Sovaria-Org-ID + X-Sovaria-Project-ID. */
|
|
970
|
+
projectLogin: async (email, password) => {
|
|
971
|
+
await this.resolveScope();
|
|
972
|
+
if (!this.scope?.orgId || !this.scope?.projectId) {
|
|
973
|
+
throw SovariaError.config('projectLogin() needs the project: create the client with your project URL, or call setScope({ orgId, projectId }).');
|
|
974
|
+
}
|
|
975
|
+
const tokens = await this.request('POST', '/api/v1/auth/project/login', {
|
|
976
|
+
requiresAuth: false,
|
|
977
|
+
body: {
|
|
978
|
+
email,
|
|
979
|
+
password,
|
|
980
|
+
org_id: this.scope.orgId,
|
|
981
|
+
project_id: this.scope.projectId,
|
|
982
|
+
...(this.scope.envId ? { env_id: this.scope.envId } : {}),
|
|
983
|
+
},
|
|
984
|
+
});
|
|
985
|
+
this.applyAuthResponse(tokens);
|
|
986
|
+
await this.tryDeriveKekFromPassword(password, tokens);
|
|
987
|
+
return tokens;
|
|
988
|
+
},
|
|
989
|
+
/** Alias for projectLogin() matching lane terminology in docs. */
|
|
990
|
+
loginProduct: async (email, password) => this.auth.projectLogin(email, password),
|
|
991
|
+
refresh: async (refreshToken) => this.request('POST', '/api/v1/auth/refresh', {
|
|
992
|
+
requiresAuth: false,
|
|
993
|
+
body: { refresh_token: refreshToken },
|
|
994
|
+
}),
|
|
995
|
+
logout: async (refreshToken) => {
|
|
996
|
+
const token = (refreshToken ?? this.refreshToken).trim();
|
|
997
|
+
try {
|
|
998
|
+
if (this.accessToken && token) {
|
|
999
|
+
return await this.request('POST', '/api/v1/auth/logout', {
|
|
1000
|
+
body: { refresh_token: token },
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
return {};
|
|
1004
|
+
}
|
|
1005
|
+
finally {
|
|
1006
|
+
this.clearAccessToken();
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
me: async () => {
|
|
1010
|
+
const me = await this.request('GET', '/api/v1/auth/me');
|
|
1011
|
+
const orgId = String(me.org_id ?? '');
|
|
1012
|
+
const projectId = String(me.project_id ?? '');
|
|
1013
|
+
const envId = String(me.env_id ?? '');
|
|
1014
|
+
if (orgId && projectId && !this.scope) {
|
|
1015
|
+
this.setScope({ orgId, projectId, envId: envId || undefined });
|
|
1016
|
+
}
|
|
1017
|
+
return me;
|
|
1018
|
+
},
|
|
1019
|
+
verifyMfa: async (code, method = 'totp') => {
|
|
1020
|
+
const tokens = await this.request('POST', '/api/v1/auth/mfa/verify', {
|
|
1021
|
+
body: { code, method },
|
|
1022
|
+
});
|
|
1023
|
+
return this.applyAuthResponse(tokens);
|
|
1024
|
+
},
|
|
1025
|
+
/** Send a one-time login link by email. Returns 200 even for unknown emails. */
|
|
1026
|
+
/**
|
|
1027
|
+
* Email a sign-in link (plus a 6-digit code). A link that opens a mobile app (`myapp://…` or a
|
|
1028
|
+
* registered app link) is bound to this app with PKCE automatically; pass `pkce: true` to force it.
|
|
1029
|
+
*/
|
|
1030
|
+
sendMagicLink: async (email, opts = {}) => {
|
|
1031
|
+
const orgId = opts.orgId ?? this.scope?.orgId;
|
|
1032
|
+
const projectId = opts.projectId ?? this.scope?.projectId;
|
|
1033
|
+
const challenge = opts.pkce || isAppRedirect(opts.redirectTo) ? await this.startLinkPkce() : undefined;
|
|
1034
|
+
return this.request('POST', '/api/v1/auth/magic-link/send', {
|
|
1035
|
+
requiresAuth: false,
|
|
1036
|
+
body: {
|
|
1037
|
+
email,
|
|
1038
|
+
...(orgId ? { org_id: orgId } : {}),
|
|
1039
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
1040
|
+
// Must be your Site URL or in the project's redirect allow-list; otherwise the Site URL is used.
|
|
1041
|
+
...(opts.redirectTo ? { redirect_to: opts.redirectTo } : {}),
|
|
1042
|
+
...(challenge ? { code_challenge: challenge } : {}),
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
},
|
|
1046
|
+
/** Email a password-reset link. Returns 200 even for unknown emails. */
|
|
1047
|
+
resetPasswordForEmail: async (email, opts = {}) => {
|
|
1048
|
+
const orgId = opts.orgId ?? this.scope?.orgId;
|
|
1049
|
+
const projectId = opts.projectId ?? this.scope?.projectId;
|
|
1050
|
+
const challenge = opts.pkce || isAppRedirect(opts.redirectTo) ? await this.startLinkPkce() : undefined;
|
|
1051
|
+
return this.request('POST', '/api/v1/auth/password-reset/send', {
|
|
1052
|
+
requiresAuth: false,
|
|
1053
|
+
body: {
|
|
1054
|
+
email,
|
|
1055
|
+
...(orgId ? { org_id: orgId } : {}),
|
|
1056
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
1057
|
+
...(opts.redirectTo ? { redirect_to: opts.redirectTo } : {}),
|
|
1058
|
+
...(challenge ? { code_challenge: challenge } : {}),
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
},
|
|
1062
|
+
/** Set a new password with the token from a reset link (`type=recovery`). */
|
|
1063
|
+
confirmPasswordReset: (token, newPassword, codeVerifier) => {
|
|
1064
|
+
const verifier = this.takeLinkVerifier(codeVerifier);
|
|
1065
|
+
return this.request('POST', '/api/v1/auth/password-reset/confirm', {
|
|
1066
|
+
requiresAuth: false,
|
|
1067
|
+
body: { token, new_password: newPassword, ...(verifier ? { code_verifier: verifier } : {}) },
|
|
1068
|
+
});
|
|
1069
|
+
},
|
|
1070
|
+
/**
|
|
1071
|
+
* Handle the link an auth email opened (`?token=…&type=…` on your Site URL).
|
|
1072
|
+
* Magic links sign the user in; email confirmations are confirmed; for `recovery`
|
|
1073
|
+
* the token is returned so you can show a "new password" form and call
|
|
1074
|
+
* `confirmPasswordReset`. Returns null when the URL isn't an auth link.
|
|
1075
|
+
*/
|
|
1076
|
+
handleEmailLink: async (url) => {
|
|
1077
|
+
const href = url ?? (typeof window !== 'undefined' ? window.location.href : '');
|
|
1078
|
+
if (!href)
|
|
1079
|
+
return null;
|
|
1080
|
+
const params = new URL(href).searchParams;
|
|
1081
|
+
const type = params.get('type');
|
|
1082
|
+
if (type === 'sso' || type === 'link') {
|
|
1083
|
+
// `link` is a linkIdentity return; it completes through the same PKCE code exchange as `sso`, which
|
|
1084
|
+
// binds the completion to this browser's verifier (the one that started the link).
|
|
1085
|
+
const error = params.get('error');
|
|
1086
|
+
if (error)
|
|
1087
|
+
return { type, error, description: params.get('error_description') ?? undefined };
|
|
1088
|
+
const code = params.get('code');
|
|
1089
|
+
return code ? { type, session: await this.auth.exchangeCodeForSession(code) } : null;
|
|
1090
|
+
}
|
|
1091
|
+
const token = params.get('token');
|
|
1092
|
+
if (!token || !type)
|
|
1093
|
+
return null;
|
|
1094
|
+
if (type === 'magiclink')
|
|
1095
|
+
return { type, session: await this.auth.verifyMagicLink(token) };
|
|
1096
|
+
if (type === 'signup')
|
|
1097
|
+
return { type, result: await this.auth.confirmVerificationEmail(token) };
|
|
1098
|
+
if (type === 'email_change_current')
|
|
1099
|
+
return { type, result: await this.request('POST', '/api/v1/auth/email-change/confirm', { requiresAuth: false, body: { token } }) };
|
|
1100
|
+
if (type === 'email_change_new')
|
|
1101
|
+
return { type, result: await this.request('POST', '/api/v1/auth/email-change/finalize', { requiresAuth: false, body: { token } }) };
|
|
1102
|
+
return { type, token };
|
|
1103
|
+
},
|
|
1104
|
+
/**
|
|
1105
|
+
* "Sign in with Google / GitHub / …" (providers connected in Authentication › Sign in / Providers).
|
|
1106
|
+
* Sends the browser to the provider; it comes back to `redirectTo` (or your Site URL) with
|
|
1107
|
+
* `?code=…&type=sso`, which `handleEmailLink()` / `exchangeCodeForSession()` turn into a session.
|
|
1108
|
+
* A PKCE verifier is kept in sessionStorage so only this browser can redeem the code.
|
|
1109
|
+
*/
|
|
1110
|
+
signInWithProvider: async (providerId, opts = {}) => {
|
|
1111
|
+
const projectId = opts.projectId ?? (await this.resolveScope())?.projectId;
|
|
1112
|
+
if (!projectId)
|
|
1113
|
+
throw SovariaError.config('signInWithProvider() needs a project id (use the project URL or setScope first).');
|
|
1114
|
+
const verifier = randomVerifier();
|
|
1115
|
+
const challenge = await s256(verifier);
|
|
1116
|
+
this.ssoVerifier = verifier;
|
|
1117
|
+
try {
|
|
1118
|
+
globalThis.sessionStorage?.setItem('sovaria.sso.verifier', verifier);
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
/* storage unavailable: the code can still be exchanged without PKCE */
|
|
1122
|
+
}
|
|
1123
|
+
const params = new URLSearchParams({ project_id: projectId, code_challenge: challenge, code_challenge_method: 'S256', mode: 'json' });
|
|
1124
|
+
if (opts.redirectTo)
|
|
1125
|
+
params.set('redirect_to', opts.redirectTo);
|
|
1126
|
+
const res = await this.request('GET', `/api/v1/auth/sso/${encodeURIComponent(providerId)}/authorize?${params}`, { requiresAuth: false });
|
|
1127
|
+
const url = String(res.authorization_url ?? '');
|
|
1128
|
+
if (typeof window !== 'undefined' && url)
|
|
1129
|
+
window.location.assign(url);
|
|
1130
|
+
return { url };
|
|
1131
|
+
},
|
|
1132
|
+
/**
|
|
1133
|
+
* Enterprise SAML single sign-on for your app's users (connections in Authentication › SAML SSO, Team plan).
|
|
1134
|
+
* Pass the user's work `email` (or its `domain`) to find their company's identity provider, or a
|
|
1135
|
+
* `connectionId`. The browser goes to the IdP and comes back like `signInWithProvider()`:
|
|
1136
|
+
* `?code=…&type=sso`, which `exchangeCodeForSession()` turns into a session.
|
|
1137
|
+
*/
|
|
1138
|
+
signInWithSSO: async (opts) => {
|
|
1139
|
+
const projectId = opts.projectId ?? (await this.resolveScope())?.projectId;
|
|
1140
|
+
if (!projectId)
|
|
1141
|
+
throw SovariaError.config('signInWithSSO() needs a project id (use the project URL or setScope first).');
|
|
1142
|
+
let connectionId = opts.connectionId;
|
|
1143
|
+
if (!connectionId) {
|
|
1144
|
+
const address = opts.email ?? (opts.domain ? `user@${opts.domain}` : '');
|
|
1145
|
+
if (!address)
|
|
1146
|
+
throw SovariaError.config('signInWithSSO() needs an email, a domain or a connectionId.');
|
|
1147
|
+
const q = new URLSearchParams({ project_id: projectId, email: address });
|
|
1148
|
+
const found = await this.request('GET', `/api/v1/auth/sso/saml/lookup?${q}`, { requiresAuth: false });
|
|
1149
|
+
connectionId = String(found.connection_id ?? '');
|
|
1150
|
+
}
|
|
1151
|
+
const verifier = randomVerifier();
|
|
1152
|
+
const challenge = await s256(verifier);
|
|
1153
|
+
this.ssoVerifier = verifier;
|
|
1154
|
+
try {
|
|
1155
|
+
globalThis.sessionStorage?.setItem('sovaria.sso.verifier', verifier);
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
/* storage unavailable: the code can still be exchanged with the in-memory verifier */
|
|
1159
|
+
}
|
|
1160
|
+
const params = new URLSearchParams({ project_id: projectId, code_challenge: challenge, code_challenge_method: 'S256', mode: 'json' });
|
|
1161
|
+
if (opts.redirectTo)
|
|
1162
|
+
params.set('redirect_to', opts.redirectTo);
|
|
1163
|
+
const res = await this.request('GET', `/api/v1/auth/sso/saml/${encodeURIComponent(connectionId)}/authorize?${params}`, { requiresAuth: false });
|
|
1164
|
+
const url = String(res.authorization_url ?? '');
|
|
1165
|
+
if (typeof window !== 'undefined' && url)
|
|
1166
|
+
window.location.assign(url);
|
|
1167
|
+
return { url, connectionId };
|
|
1168
|
+
},
|
|
1169
|
+
/** Exchange the one-time `code` from a provider sign-in redirect for a session. */
|
|
1170
|
+
exchangeCodeForSession: async (code, codeVerifier) => {
|
|
1171
|
+
let verifier = codeVerifier;
|
|
1172
|
+
if (!verifier) {
|
|
1173
|
+
try {
|
|
1174
|
+
verifier = globalThis.sessionStorage?.getItem('sovaria.sso.verifier') ?? undefined;
|
|
1175
|
+
globalThis.sessionStorage?.removeItem('sovaria.sso.verifier');
|
|
1176
|
+
}
|
|
1177
|
+
catch {
|
|
1178
|
+
verifier = undefined;
|
|
1179
|
+
}
|
|
1180
|
+
verifier = verifier ?? this.ssoVerifier;
|
|
1181
|
+
}
|
|
1182
|
+
this.ssoVerifier = undefined;
|
|
1183
|
+
const tokens = await this.request('POST', '/api/v1/auth/sso/exchange', {
|
|
1184
|
+
requiresAuth: false,
|
|
1185
|
+
body: { code, ...(verifier ? { code_verifier: verifier } : {}) },
|
|
1186
|
+
});
|
|
1187
|
+
return this.applyAuthResponse(tokens);
|
|
1188
|
+
},
|
|
1189
|
+
/**
|
|
1190
|
+
* Link another sign-in method (Google/GitHub/SAML…) to the SIGNED-IN user (Supabase `linkIdentity`).
|
|
1191
|
+
* Sends the browser to the provider; it returns to `redirectTo` (or your Site URL) with `?code=…&type=link`,
|
|
1192
|
+
* which `handleEmailLink()` / `exchangeCodeForSession()` complete. The completion is bound to THIS browser by
|
|
1193
|
+
* a PKCE verifier kept in sessionStorage, so a forwarded link can't attach someone else's identity to your
|
|
1194
|
+
* account. An anonymous user who links a verified provider becomes permanent.
|
|
1195
|
+
*/
|
|
1196
|
+
linkIdentity: async (providerId, opts = {}) => {
|
|
1197
|
+
const verifier = randomVerifier();
|
|
1198
|
+
const challenge = await s256(verifier);
|
|
1199
|
+
this.ssoVerifier = verifier;
|
|
1200
|
+
try {
|
|
1201
|
+
globalThis.sessionStorage?.setItem('sovaria.sso.verifier', verifier);
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
/* storage unavailable: the code can still be exchanged with the in-memory verifier */
|
|
1205
|
+
}
|
|
1206
|
+
const params = new URLSearchParams({
|
|
1207
|
+
provider: providerId,
|
|
1208
|
+
code_challenge: challenge,
|
|
1209
|
+
code_challenge_method: 'S256',
|
|
1210
|
+
skip_http_redirect: 'true',
|
|
1211
|
+
});
|
|
1212
|
+
if (opts.redirectTo)
|
|
1213
|
+
params.set('redirect_to', opts.redirectTo);
|
|
1214
|
+
const res = await this.request('GET', `/api/v1/auth/me/identities/authorize?${params}`);
|
|
1215
|
+
const url = String(res.url ?? '');
|
|
1216
|
+
if (typeof window !== 'undefined' && url)
|
|
1217
|
+
window.location.assign(url);
|
|
1218
|
+
return { url };
|
|
1219
|
+
},
|
|
1220
|
+
/** The sign-in methods linked to the signed-in user (email, google, saml:…, passkey). */
|
|
1221
|
+
getUserIdentities: async () => this.request('GET', '/api/v1/auth/me/identities'),
|
|
1222
|
+
/** Remove a linked sign-in method (never the last one). Pass a recent-auth `nonce` if the project requires it. */
|
|
1223
|
+
unlinkIdentity: async (identityId, opts = {}) => {
|
|
1224
|
+
const q = opts.nonce ? `?nonce=${encodeURIComponent(opts.nonce)}` : '';
|
|
1225
|
+
return this.request('DELETE', `/api/v1/auth/me/identities/${encodeURIComponent(identityId)}${q}`);
|
|
1226
|
+
},
|
|
1227
|
+
/** Enabled social / enterprise providers for your sign-in screen. */
|
|
1228
|
+
listProviders: async (projectId) => {
|
|
1229
|
+
const pid = projectId ?? (await this.resolveScope())?.projectId;
|
|
1230
|
+
if (!pid)
|
|
1231
|
+
throw SovariaError.config('listProviders() needs a project id (use the project URL or setScope first).');
|
|
1232
|
+
return this.request('GET', `/api/v1/auth/sso/providers?project_id=${encodeURIComponent(pid)}`, { requiresAuth: false });
|
|
1233
|
+
},
|
|
1234
|
+
/** Public sign-in config for a project: enabled providers, password rules, MFA factors, passkeys. */
|
|
1235
|
+
getPublicConfig: (projectId) => {
|
|
1236
|
+
const pid = projectId ?? this.scope?.projectId;
|
|
1237
|
+
// Without an id the project host (https://<ref>.sovarias.in) identifies the project.
|
|
1238
|
+
const query = pid ? `?project_id=${encodeURIComponent(pid)}` : '';
|
|
1239
|
+
return this.request('GET', `/api/v1/auth/public-config${query}`, {
|
|
1240
|
+
requiresAuth: false,
|
|
1241
|
+
requiresScope: false,
|
|
1242
|
+
});
|
|
1243
|
+
},
|
|
1244
|
+
/** Exchange a magic-link token for an access + refresh token pair. */
|
|
1245
|
+
verifyMagicLink: async (token, codeVerifier) => {
|
|
1246
|
+
const verifier = this.takeLinkVerifier(codeVerifier);
|
|
1247
|
+
const tokens = await this.request('POST', '/api/v1/auth/magic-link/verify', {
|
|
1248
|
+
requiresAuth: false,
|
|
1249
|
+
body: { token, ...(verifier ? { code_verifier: verifier } : {}) },
|
|
1250
|
+
});
|
|
1251
|
+
return this.applyAuthResponse(tokens);
|
|
1252
|
+
},
|
|
1253
|
+
/**
|
|
1254
|
+
* Sign in with the 6-digit code from the sign-in email (the same email carries the magic link).
|
|
1255
|
+
* Uses the client scope's org/project unless given.
|
|
1256
|
+
*/
|
|
1257
|
+
verifyEmailCode: async (email, code, opts = {}) => {
|
|
1258
|
+
const orgId = opts.orgId ?? this.scope?.orgId;
|
|
1259
|
+
const projectId = opts.projectId ?? this.scope?.projectId;
|
|
1260
|
+
const tokens = await this.request('POST', '/api/v1/auth/magic-link/verify-code', {
|
|
1261
|
+
requiresAuth: false,
|
|
1262
|
+
body: { email, code: code.replace(/\s+/g, ''), ...(orgId ? { org_id: orgId } : {}), ...(projectId ? { project_id: projectId } : {}) },
|
|
1263
|
+
});
|
|
1264
|
+
return this.applyAuthResponse(tokens);
|
|
1265
|
+
},
|
|
1266
|
+
/** Send an SMS OTP code for phone-based login. */
|
|
1267
|
+
sendOtp: (phone, opts = {}) => {
|
|
1268
|
+
const body = { phone };
|
|
1269
|
+
const orgId = opts.orgId ?? this.scope?.orgId;
|
|
1270
|
+
const projectId = opts.projectId ?? this.scope?.projectId;
|
|
1271
|
+
const envId = opts.envId ?? this.scope?.envId;
|
|
1272
|
+
if (orgId) {
|
|
1273
|
+
body.org_id = orgId;
|
|
1274
|
+
}
|
|
1275
|
+
if (projectId) {
|
|
1276
|
+
body.project_id = projectId;
|
|
1277
|
+
}
|
|
1278
|
+
if (envId) {
|
|
1279
|
+
body.env_id = envId;
|
|
1280
|
+
}
|
|
1281
|
+
return this.request('POST', '/api/v1/auth/otp/send', {
|
|
1282
|
+
requiresAuth: false,
|
|
1283
|
+
body,
|
|
1284
|
+
});
|
|
1285
|
+
},
|
|
1286
|
+
/** Verify an SMS OTP code and establish a session.
|
|
1287
|
+
* Passwordless by default — pass `password` when argon2_salt is present to
|
|
1288
|
+
* populate the session KEK without a follow-up password login.
|
|
1289
|
+
*/
|
|
1290
|
+
verifyOtp: async (phone, code, opts = {}) => {
|
|
1291
|
+
const body = { phone, code };
|
|
1292
|
+
const orgId = opts.orgId ?? this.scope?.orgId;
|
|
1293
|
+
const projectId = opts.projectId ?? this.scope?.projectId;
|
|
1294
|
+
const envId = opts.envId ?? this.scope?.envId;
|
|
1295
|
+
if (orgId) {
|
|
1296
|
+
body.org_id = orgId;
|
|
1297
|
+
}
|
|
1298
|
+
if (projectId) {
|
|
1299
|
+
body.project_id = projectId;
|
|
1300
|
+
}
|
|
1301
|
+
if (envId) {
|
|
1302
|
+
body.env_id = envId;
|
|
1303
|
+
}
|
|
1304
|
+
const tokens = await this.request('POST', '/api/v1/auth/otp/verify', {
|
|
1305
|
+
requiresAuth: false,
|
|
1306
|
+
body,
|
|
1307
|
+
});
|
|
1308
|
+
this.applyAuthResponse(tokens);
|
|
1309
|
+
if (opts.password) {
|
|
1310
|
+
await this.tryDeriveKekFromPassword(opts.password, tokens);
|
|
1311
|
+
}
|
|
1312
|
+
return tokens;
|
|
1313
|
+
},
|
|
1314
|
+
/** Exchange an OAuth handoff token for a session (console OAuth callback).
|
|
1315
|
+
* Passwordless: session KEK is not derived unless `password` is supplied.
|
|
1316
|
+
*/
|
|
1317
|
+
oauthHandoff: async (token, password) => {
|
|
1318
|
+
const tokens = await this.request('POST', '/api/v1/auth/oauth/handoff', {
|
|
1319
|
+
requiresAuth: false,
|
|
1320
|
+
body: { token },
|
|
1321
|
+
});
|
|
1322
|
+
this.applyAuthResponse(tokens);
|
|
1323
|
+
if (password) {
|
|
1324
|
+
await this.tryDeriveKekFromPassword(password, tokens);
|
|
1325
|
+
}
|
|
1326
|
+
return tokens;
|
|
1327
|
+
},
|
|
1328
|
+
/** Console-operator listing of product users across accessible projects. Path: GET /api/v1/auth/product-users (console lane). */
|
|
1329
|
+
listConsoleProductUsers: () => this.request('GET', '/api/v1/auth/product-users'),
|
|
1330
|
+
/** Trigger a verification email for the authenticated user. */
|
|
1331
|
+
sendVerificationEmail: () => this.request('POST', '/api/v1/auth/verify-email/send', {
|
|
1332
|
+
body: {},
|
|
1333
|
+
}),
|
|
1334
|
+
/** Confirm an email-verification token. */
|
|
1335
|
+
confirmVerificationEmail: (token) => this.request('POST', '/api/v1/auth/verify-email/confirm', {
|
|
1336
|
+
requiresAuth: false,
|
|
1337
|
+
body: { token },
|
|
1338
|
+
}),
|
|
1339
|
+
/**
|
|
1340
|
+
* Auth event hooks (per-project HTTP webhooks). The project is the client's scope
|
|
1341
|
+
* (`X-Sovaria-Project-ID`); the caller must be an owner or admin of it.
|
|
1342
|
+
*/
|
|
1343
|
+
eventHooks: {
|
|
1344
|
+
list: () => this.request('GET', '/api/v1/auth/event-hooks', {
|
|
1345
|
+
requiresScope: false,
|
|
1346
|
+
}),
|
|
1347
|
+
create: (payload) => this.request('POST', '/api/v1/auth/event-hooks', {
|
|
1348
|
+
body: {
|
|
1349
|
+
name: payload.name,
|
|
1350
|
+
url: payload.url,
|
|
1351
|
+
events: payload.events,
|
|
1352
|
+
secret: payload.secret ?? '',
|
|
1353
|
+
enabled: payload.enabled ?? true,
|
|
1354
|
+
},
|
|
1355
|
+
// A project other than the client's scope is named by the header, never the body.
|
|
1356
|
+
...(payload.projectId ? { headers: { 'X-Sovaria-Project-ID': payload.projectId } } : {}),
|
|
1357
|
+
requiresScope: false,
|
|
1358
|
+
}),
|
|
1359
|
+
delete: (hookId) => this.request('DELETE', `/api/v1/auth/event-hooks/${safePathSegment(hookId)}`, { requiresScope: false }),
|
|
1360
|
+
},
|
|
1361
|
+
/**
|
|
1362
|
+
* Blocking hooks (before-create, before-sign-in): a webhook or one of the project's functions that can
|
|
1363
|
+
* refuse a sign-up / sign-in or add custom claims. Project owners and admins; plan key `auth.blocking_hooks`.
|
|
1364
|
+
* `set` returns the signing secret once (`secret`) when it is created or `rotateSecret` is true.
|
|
1365
|
+
*/
|
|
1366
|
+
blockingHooks: {
|
|
1367
|
+
get: () => this.request('GET', '/api/v1/auth/blocking-hooks', { requiresScope: false }),
|
|
1368
|
+
set: (event, hook) => this.request('PUT', `/api/v1/auth/blocking-hooks/${event.replace(/_/g, '-')}`, {
|
|
1369
|
+
body: {
|
|
1370
|
+
target: hook.target ?? (hook.function ? 'function' : 'webhook'),
|
|
1371
|
+
url: hook.url ?? null,
|
|
1372
|
+
function: hook.function ?? null,
|
|
1373
|
+
...(hook.timeoutMs !== undefined ? { timeout_ms: hook.timeoutMs } : {}),
|
|
1374
|
+
fail: hook.fail ?? 'closed',
|
|
1375
|
+
enabled: hook.enabled ?? true,
|
|
1376
|
+
rotate_secret: hook.rotateSecret ?? false,
|
|
1377
|
+
},
|
|
1378
|
+
requiresScope: false,
|
|
1379
|
+
}),
|
|
1380
|
+
delete: (event) => this.request('DELETE', `/api/v1/auth/blocking-hooks/${event.replace(/_/g, '-')}`, { requiresScope: false }),
|
|
1381
|
+
/** Send a sample event and get the hook's decision (`allowed`, `claims`, `message`, `failed`, `error`, `latency_ms`). */
|
|
1382
|
+
test: (event, sample = {}) => this.request('POST', `/api/v1/auth/blocking-hooks/${event.replace(/_/g, '-')}/test`, {
|
|
1383
|
+
body: sample,
|
|
1384
|
+
requiresScope: false,
|
|
1385
|
+
}),
|
|
1386
|
+
},
|
|
1387
|
+
/**
|
|
1388
|
+
* SCIM 2.0 provisioning of the project's app users and groups (Okta, Entra ID …). `createToken` turns it on or
|
|
1389
|
+
* rotates the token (the old one stops at once) and returns it once with the `base_url` for the identity
|
|
1390
|
+
* provider. Plan key `auth.scim`.
|
|
1391
|
+
*/
|
|
1392
|
+
scim: {
|
|
1393
|
+
status: () => this.request('GET', '/api/v1/auth/scim', { requiresScope: false }),
|
|
1394
|
+
createToken: () => this.request('POST', '/api/v1/auth/scim/token', { body: {}, requiresScope: false }),
|
|
1395
|
+
revokeToken: () => this.request('DELETE', '/api/v1/auth/scim/token', { requiresScope: false }),
|
|
1396
|
+
},
|
|
1397
|
+
/**
|
|
1398
|
+
* @deprecated Legacy GET `/api/v1/auth/users`. Use `productUsers.list()` for
|
|
1399
|
+
* project-scoped product users or `listConsoleProductUsers()` for the console
|
|
1400
|
+
* product-user admin path. Use `consoleMembers.list()` for org console members.
|
|
1401
|
+
*/
|
|
1402
|
+
listUsers: () => {
|
|
1403
|
+
console.warn('auth.listUsers() is deprecated (legacy GET /api/v1/auth/users). ' +
|
|
1404
|
+
'For product users use auth.productUsers.list() ' +
|
|
1405
|
+
'(GET /api/v1/projects/{id}/auth/users) or auth.listConsoleProductUsers() ' +
|
|
1406
|
+
'(GET /api/v1/auth/product-users). For org console members use auth.consoleMembers.list() ' +
|
|
1407
|
+
'(GET /api/v1/org/{orgId}/members).');
|
|
1408
|
+
if (this.scope?.orgId && this.scope?.projectId) {
|
|
1409
|
+
return this.auth.productUsers.list();
|
|
1410
|
+
}
|
|
1411
|
+
return this.auth.listConsoleProductUsers();
|
|
1412
|
+
},
|
|
1413
|
+
/**
|
|
1414
|
+
* @deprecated Legacy DELETE `/api/v1/auth/users/{id}`. Use `productUsers.delete()`
|
|
1415
|
+
* or `consoleMembers.delete()`.
|
|
1416
|
+
*/
|
|
1417
|
+
deleteUser: (userId) => {
|
|
1418
|
+
console.warn('auth.deleteUser() is deprecated (legacy DELETE /api/v1/auth/users/{id}). ' +
|
|
1419
|
+
'Use auth.productUsers.delete() for product users or auth.consoleMembers.delete() for console members.');
|
|
1420
|
+
if (this.scope?.orgId && this.scope?.projectId) {
|
|
1421
|
+
return this.auth.productUsers.delete(userId);
|
|
1422
|
+
}
|
|
1423
|
+
return this.request('DELETE', `/api/v1/auth/product-users/${safePathSegment(userId)}`);
|
|
1424
|
+
},
|
|
1425
|
+
/**
|
|
1426
|
+
* @deprecated Legacy POST `/api/v1/auth/users/{id}/ban`. Use `productUsers.ban()`.
|
|
1427
|
+
*/
|
|
1428
|
+
banUser: (userId) => {
|
|
1429
|
+
console.warn('auth.banUser() is deprecated (legacy POST /api/v1/auth/users/{id}/ban). ' +
|
|
1430
|
+
'Use auth.productUsers.ban() for product users.');
|
|
1431
|
+
if (this.scope?.orgId && this.scope?.projectId) {
|
|
1432
|
+
return this.auth.productUsers.ban(userId);
|
|
1433
|
+
}
|
|
1434
|
+
return this.request('POST', `/api/v1/auth/product-users/${safePathSegment(userId)}/ban`);
|
|
1435
|
+
},
|
|
1436
|
+
/** Org console-member management. Canonical path: GET /api/v1/org/{orgId}/members. Not product users. */
|
|
1437
|
+
consoleMembers: {
|
|
1438
|
+
list: () => {
|
|
1439
|
+
const orgId = this.scope?.orgId;
|
|
1440
|
+
if (!orgId) {
|
|
1441
|
+
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().');
|
|
1442
|
+
}
|
|
1443
|
+
return this.request('GET', `/api/v1/org/${safePathSegment(orgId)}/members`);
|
|
1444
|
+
},
|
|
1445
|
+
delete: (userId) => {
|
|
1446
|
+
const orgId = this.scope?.orgId;
|
|
1447
|
+
if (!orgId) {
|
|
1448
|
+
throw SovariaError.config('consoleMembers.delete() requires setScope({ orgId, projectId }). Canonical path: DELETE /api/v1/org/{orgId}/member/{userId}.');
|
|
1449
|
+
}
|
|
1450
|
+
return this.request('DELETE', `/api/v1/org/${safePathSegment(orgId)}/member/${safePathSegment(userId)}`);
|
|
1451
|
+
},
|
|
1452
|
+
ban: (userId) => {
|
|
1453
|
+
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.');
|
|
1454
|
+
if (this.scope?.orgId && this.scope?.projectId) {
|
|
1455
|
+
return this.auth.productUsers.ban(userId);
|
|
1456
|
+
}
|
|
1457
|
+
return this.request('POST', `/api/v1/auth/product-users/${safePathSegment(userId)}/ban`);
|
|
1458
|
+
},
|
|
1459
|
+
},
|
|
1460
|
+
/** Product auth admin (project-scoped identities). Path: GET /api/v1/projects/{id}/auth/users. Requires setScope. */
|
|
1461
|
+
productUsers: {
|
|
1462
|
+
/** Every product user: follows all pages. Use `listPage()` for big projects. */
|
|
1463
|
+
list: async () => {
|
|
1464
|
+
const all = [];
|
|
1465
|
+
let cursor = null;
|
|
1466
|
+
do {
|
|
1467
|
+
const page = await this.auth.productUsers.listPage({ limit: 500, cursor });
|
|
1468
|
+
all.push(...page.users);
|
|
1469
|
+
cursor = page.nextCursor;
|
|
1470
|
+
} while (cursor);
|
|
1471
|
+
return all;
|
|
1472
|
+
},
|
|
1473
|
+
/** One page of product users, newest first. `search` matches part of an email or an exact id. */
|
|
1474
|
+
listPage: async (params = {}) => {
|
|
1475
|
+
let headers = null;
|
|
1476
|
+
const limit = Math.max(1, Math.min(Math.trunc(params.limit ?? 50), 500));
|
|
1477
|
+
const body = await this.productAuthRequest('GET', '/users', {
|
|
1478
|
+
requiresScope: true,
|
|
1479
|
+
query: { limit, cursor: params.cursor || undefined, q: params.search || undefined, status: params.status },
|
|
1480
|
+
onResponseHeaders: (h) => {
|
|
1481
|
+
headers = h;
|
|
1482
|
+
},
|
|
1483
|
+
});
|
|
1484
|
+
const h = headers;
|
|
1485
|
+
const total = h?.get('x-total-count') ?? '';
|
|
1486
|
+
return {
|
|
1487
|
+
users: Array.isArray(body) ? body : [],
|
|
1488
|
+
nextCursor: h?.get('x-next-cursor') || null,
|
|
1489
|
+
total: /^\d+$/.test(total) ? Number(total) : null,
|
|
1490
|
+
};
|
|
1491
|
+
},
|
|
1492
|
+
delete: (userId) => this.productAuthRequest('DELETE', `/users/${safePathSegment(userId)}`, {
|
|
1493
|
+
requiresScope: true,
|
|
1494
|
+
}),
|
|
1495
|
+
ban: (userId) => this.productAuthRequest('POST', `/users/${safePathSegment(userId)}/ban`, {
|
|
1496
|
+
requiresScope: true,
|
|
1497
|
+
}),
|
|
1498
|
+
roles: (userId) => this.productAuthRequest('GET', `/users/${safePathSegment(userId)}/roles`, {
|
|
1499
|
+
requiresScope: true,
|
|
1500
|
+
}),
|
|
1501
|
+
setRoles: (userId, roles) => this.productAuthRequest('PUT', `/users/${safePathSegment(userId)}/roles`, {
|
|
1502
|
+
requiresScope: true,
|
|
1503
|
+
body: { roles },
|
|
1504
|
+
}),
|
|
1505
|
+
create: (payload) => this.productAuthRequest('POST', '/users', {
|
|
1506
|
+
requiresScope: true,
|
|
1507
|
+
body: payload,
|
|
1508
|
+
}),
|
|
1509
|
+
/**
|
|
1510
|
+
* Bulk-import app users (up to 1,000 per call) with their ids and password hashes: `bcrypt`
|
|
1511
|
+
* (Supabase) as is, `firebase_scrypt` (Firebase) checked with `hashConfig` and upgraded to bcrypt
|
|
1512
|
+
* at first sign-in. No emails, hooks or triggers fire; existing emails are skipped. Org owners/admins.
|
|
1513
|
+
*/
|
|
1514
|
+
import: (users, hashConfig) => this.productAuthRequest('POST', '/users/import', {
|
|
1515
|
+
requiresScope: true,
|
|
1516
|
+
body: hashConfig ? { users, hash_config: hashConfig } : { users },
|
|
1517
|
+
}),
|
|
1518
|
+
},
|
|
1519
|
+
sessions: {
|
|
1520
|
+
list: () => this.request('GET', '/api/v1/auth/sessions'),
|
|
1521
|
+
revoke: (sessionId) => this.request('DELETE', `/api/v1/auth/sessions/${safePathSegment(sessionId)}`),
|
|
1522
|
+
revokeOthers: () => this.request('POST', '/api/v1/auth/sessions/revoke-others', { body: {} }),
|
|
1523
|
+
getConfig: () => this.request('GET', '/api/v1/auth/sessions/config'),
|
|
1524
|
+
setConfig: (body) => this.request('PUT', '/api/v1/auth/sessions/config', { body }),
|
|
1525
|
+
},
|
|
1526
|
+
providersConfig: {
|
|
1527
|
+
get: () => this.request('GET', '/api/v1/auth/providers/config'),
|
|
1528
|
+
set: (body) => this.request('PUT', '/api/v1/auth/providers/config', { body }),
|
|
1529
|
+
},
|
|
1530
|
+
/**
|
|
1531
|
+
* Passkeys (WebAuthn): add one for the signed-in user, sign in with one, list and remove them.
|
|
1532
|
+
* The project must have passkeys set up (Users › Passkeys). `register()` and `signIn()` run the
|
|
1533
|
+
* browser ceremony; on other platforms use the `start…` / `finish…` calls with your own WebAuthn API.
|
|
1534
|
+
*
|
|
1535
|
+
* ```ts
|
|
1536
|
+
* await sov.auth.passkeys.register('MacBook') // signed in: Touch ID creates a passkey
|
|
1537
|
+
* const session = await sov.auth.passkeys.signIn() // later: sign in with it, no email needed
|
|
1538
|
+
* ```
|
|
1539
|
+
*/
|
|
1540
|
+
passkeys: {
|
|
1541
|
+
/** True when this browser can create and use passkeys. */
|
|
1542
|
+
isSupported: () => passkeysSupported(),
|
|
1543
|
+
/** Registration options for the signed-in user (their existing passkeys are excluded). */
|
|
1544
|
+
startRegistration: (name = 'Passkey') => this.request('POST', '/api/v1/auth/passkeys/register/start', { body: { name } }),
|
|
1545
|
+
/** Save the credential the authenticator created (`credential` is WebAuthn JSON). */
|
|
1546
|
+
finishRegistration: (credential, name = 'Passkey') => this.request('POST', '/api/v1/auth/passkeys/register/finish', { body: { credential, name } }),
|
|
1547
|
+
/** Create a passkey for the signed-in user with the browser's WebAuthn API and save it. */
|
|
1548
|
+
register: async (name = 'Passkey') => {
|
|
1549
|
+
if (!passkeysSupported())
|
|
1550
|
+
throw SovariaError.config('Passkeys need a browser with WebAuthn (navigator.credentials).');
|
|
1551
|
+
const start = await this.auth.passkeys.startRegistration(name);
|
|
1552
|
+
const created = await globalThis.navigator.credentials.create({ publicKey: creationOptionsFromJSON((start.options ?? start)) });
|
|
1553
|
+
if (!created)
|
|
1554
|
+
throw SovariaError.config('No passkey was created.');
|
|
1555
|
+
return this.auth.passkeys.finishRegistration(credentialToJSON(created), name);
|
|
1556
|
+
},
|
|
1557
|
+
/** Sign-in options: `{ challenge_id, options }`. Pass `userId` to offer only that user's passkeys. */
|
|
1558
|
+
startSignIn: async (opts = {}) => {
|
|
1559
|
+
await this.resolveScope();
|
|
1560
|
+
return this.request('POST', '/api/v1/auth/passkeys/authenticate/start', {
|
|
1561
|
+
requiresAuth: false,
|
|
1562
|
+
body: opts.userId ? { user_id: opts.userId } : {},
|
|
1563
|
+
});
|
|
1564
|
+
},
|
|
1565
|
+
/** Verify the signed challenge and sign the user in (the session is kept and refreshed like any sign-in). */
|
|
1566
|
+
finishSignIn: async (challengeId, credential) => {
|
|
1567
|
+
const tokens = await this.request('POST', '/api/v1/auth/passkeys/authenticate/finish', {
|
|
1568
|
+
requiresAuth: false,
|
|
1569
|
+
body: { challenge_id: challengeId, credential },
|
|
1570
|
+
});
|
|
1571
|
+
return this.applyAuthResponse(tokens);
|
|
1572
|
+
},
|
|
1573
|
+
/** Sign in with a passkey using the browser's WebAuthn API. Resolves to the session tokens (`aal` says if it was two-factor). */
|
|
1574
|
+
signIn: async (opts = {}) => {
|
|
1575
|
+
if (!passkeysSupported())
|
|
1576
|
+
throw SovariaError.config('Passkeys need a browser with WebAuthn (navigator.credentials).');
|
|
1577
|
+
const start = await this.auth.passkeys.startSignIn(opts);
|
|
1578
|
+
const asserted = await globalThis.navigator.credentials.get({ publicKey: requestOptionsFromJSON(start.options) });
|
|
1579
|
+
if (!asserted)
|
|
1580
|
+
throw SovariaError.config('No passkey was chosen.');
|
|
1581
|
+
return this.auth.passkeys.finishSignIn(String(start.challenge_id), credentialToJSON(asserted));
|
|
1582
|
+
},
|
|
1583
|
+
/** The signed-in user's passkeys: id, name, created_at, last_used_at. */
|
|
1584
|
+
list: () => this.request('GET', '/api/v1/auth/passkeys'),
|
|
1585
|
+
/** Remove one of the signed-in user's passkeys. */
|
|
1586
|
+
remove: (passkeyId) => this.request('DELETE', `/api/v1/auth/passkeys/${safePathSegment(passkeyId)}`),
|
|
1587
|
+
},
|
|
1588
|
+
mfa: {
|
|
1589
|
+
status: () => this.request('GET', '/api/v1/auth/mfa/status'),
|
|
1590
|
+
enroll: () => this.request('POST', '/api/v1/auth/mfa/enroll', { body: {} }),
|
|
1591
|
+
disable: (totpCode) => this.request('DELETE', '/api/v1/auth/mfa', { body: { totp_code: totpCode } }),
|
|
1592
|
+
},
|
|
1593
|
+
github: {
|
|
1594
|
+
connectionState: () => this.request('GET', '/api/v1/auth/github/connection-state'),
|
|
1595
|
+
disconnect: () => this.request('DELETE', '/api/v1/auth/github/connection-state'),
|
|
1596
|
+
},
|
|
1597
|
+
};
|
|
1598
|
+
graph = {
|
|
1599
|
+
/**
|
|
1600
|
+
* Create an org, project or environment node. For a PROJECT, `options.databasePassword` sets the
|
|
1601
|
+
* project's database password (12–72 characters, letters, numbers and symbols; no spaces, quotes,
|
|
1602
|
+
* backslashes or backticks). Without it the database gets a random password you can change later.
|
|
1603
|
+
*/
|
|
1604
|
+
createNode: (nodeType, name, metadata = {}, options = {}) => this.request('POST', '/api/v1/org', {
|
|
1605
|
+
body: {
|
|
1606
|
+
type: nodeType,
|
|
1607
|
+
name,
|
|
1608
|
+
metadata,
|
|
1609
|
+
...(options.databasePassword ? { database_password: options.databasePassword } : {}),
|
|
1610
|
+
},
|
|
1611
|
+
}),
|
|
1612
|
+
createEdge: (sourceId, targetId, relationType, role) => this.request('POST', '/api/v1/edge', {
|
|
1613
|
+
body: { source_id: sourceId, target_id: targetId, relation_type: relationType, role },
|
|
1614
|
+
}),
|
|
1615
|
+
addMember: (orgId, userId, role = 'MEMBER') => this.request('POST', `/api/v1/org/${safePathSegment(orgId)}/member`, {
|
|
1616
|
+
body: { user_id: userId, role },
|
|
1617
|
+
}),
|
|
1618
|
+
getContext: (userId, resourceId) => this.request('GET', `/api/v1/context/${safePathSegment(userId)}/${safePathSegment(resourceId)}`),
|
|
1619
|
+
listNodes: (nodeType) => this.request('GET', '/api/v1/org', {
|
|
1620
|
+
query: nodeType ? { type: nodeType } : undefined,
|
|
1621
|
+
}),
|
|
1622
|
+
getNode: (nodeId) => this.request('GET', `/api/v1/org/${safePathSegment(nodeId)}`),
|
|
1623
|
+
getChildren: (parentId, relation = 'OWNS') => this.request('GET', `/api/v1/org/${safePathSegment(parentId)}/children`, {
|
|
1624
|
+
query: { relation },
|
|
1625
|
+
}),
|
|
1626
|
+
updateOrg: (orgId, body) => this.request('PUT', `/api/v1/org/${safePathSegment(orgId)}`, { body }),
|
|
1627
|
+
deleteOrg: (orgId) => this.request('DELETE', `/api/v1/org/${safePathSegment(orgId)}`),
|
|
1628
|
+
setOrgPlan: (orgId, body) => this.request('PUT', `/api/v1/org/${safePathSegment(orgId)}/plan`, { body }),
|
|
1629
|
+
listMembers: (orgId) => this.request('GET', `/api/v1/org/${safePathSegment(orgId)}/members`),
|
|
1630
|
+
removeMember: (orgId, userId) => this.request('DELETE', `/api/v1/org/${safePathSegment(orgId)}/member/${safePathSegment(userId)}`),
|
|
1631
|
+
updateMember: (orgId, userId, body) => this.request('PUT', `/api/v1/org/${safePathSegment(orgId)}/member/${safePathSegment(userId)}`, { body }),
|
|
1632
|
+
createInvite: (orgId, body) => this.request('POST', `/api/v1/org/${safePathSegment(orgId)}/invites`, { body }),
|
|
1633
|
+
acceptInvite: (body) => this.request('POST', '/api/v1/org/invites/accept', { body }),
|
|
1634
|
+
getRoleMatrix: () => this.request('GET', '/api/v1/roles/matrix'),
|
|
1635
|
+
setRoleMatrix: (body) => this.request('PUT', '/api/v1/roles/matrix', { body }),
|
|
1636
|
+
getRoleCatalog: () => this.request('GET', '/api/v1/roles/catalog'),
|
|
1637
|
+
getProjectRef: (projectId) => this.request('GET', `/api/v1/org/${safePathSegment(projectId)}/ref`),
|
|
1638
|
+
getProjectSettings: () => this.request('GET', '/api/v1/project-settings', { requiresScope: true }),
|
|
1639
|
+
setProjectSettings: (body) => this.request('PUT', '/api/v1/project-settings', { body, requiresScope: true }),
|
|
1640
|
+
};
|
|
1641
|
+
data = {
|
|
1642
|
+
docPut: (collection, documentId, data) => this.request('POST', `/api/v1/data/doc/${collection
|
|
1643
|
+
.split('/')
|
|
1644
|
+
.map((segment) => safePathSegment(segment))
|
|
1645
|
+
.join('/')}/${safePathSegment(documentId)}`, { body: { data } }),
|
|
1646
|
+
docGet: (collection, documentId, atTxid) => this.request('GET', `/api/v1/data/doc/${collection
|
|
1647
|
+
.split('/')
|
|
1648
|
+
.map((segment) => safePathSegment(segment))
|
|
1649
|
+
.join('/')}/${safePathSegment(documentId)}`, { query: atTxid ? { at_txid: atTxid } : undefined }),
|
|
1650
|
+
docDelete: (collection, documentId) => this.request('DELETE', docCollectionPath(collection, documentId)),
|
|
1651
|
+
/** Encrypt client-side and write a Zero-Knowledge document envelope to Atlas-Doc. */
|
|
1652
|
+
putDocumentE2ee: async (collection, documentId, plaintext, password) => {
|
|
1653
|
+
const plainBytes = typeof plaintext === 'string' ? new TextEncoder().encode(plaintext) : plaintext;
|
|
1654
|
+
let envelope;
|
|
1655
|
+
if (this.cachedKek && this.argon2Salt) {
|
|
1656
|
+
envelope = await encryptDocumentWithKek(plainBytes, this.cachedKek);
|
|
1657
|
+
envelope = { ...envelope, salt: bytesToHex(this.argon2Salt) };
|
|
1658
|
+
}
|
|
1659
|
+
else if (password) {
|
|
1660
|
+
if (!this.argon2Salt) {
|
|
1661
|
+
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.');
|
|
1662
|
+
}
|
|
1663
|
+
envelope = await encryptDocument(plainBytes, password, this.argon2Salt);
|
|
1664
|
+
envelope = { ...envelope, salt: bytesToHex(this.argon2Salt) };
|
|
1665
|
+
}
|
|
1666
|
+
else {
|
|
1667
|
+
throw SovariaError.config('No cached KEK found. Either log in with a Phase 2–enabled server or supply the password argument explicitly.');
|
|
1668
|
+
}
|
|
1669
|
+
return this.request('POST', docCollectionPath(collection, documentId), {
|
|
1670
|
+
body: {
|
|
1671
|
+
document: {
|
|
1672
|
+
collection_id: collection,
|
|
1673
|
+
document_id: documentId,
|
|
1674
|
+
...envelope,
|
|
1675
|
+
},
|
|
1676
|
+
},
|
|
1677
|
+
headers: { 'X-Payload-Hash': envelope.payload_hash },
|
|
1678
|
+
});
|
|
1679
|
+
},
|
|
1680
|
+
/** Retrieve and decrypt a Zero-Knowledge document from Atlas-Doc. */
|
|
1681
|
+
getDocumentE2ee: async (collection, documentId, password, atTxid) => {
|
|
1682
|
+
const resp = await this.request('GET', docCollectionPath(collection, documentId), { query: atTxid ? { at_txid: atTxid } : undefined });
|
|
1683
|
+
if (!resp.found) {
|
|
1684
|
+
throw new SovariaError(404, `Document ${documentId} not found`);
|
|
1685
|
+
}
|
|
1686
|
+
const doc = resp.document;
|
|
1687
|
+
const envelope = {
|
|
1688
|
+
ciphertext: String(doc.ciphertext ?? ''),
|
|
1689
|
+
nonce: String(doc.nonce ?? ''),
|
|
1690
|
+
auth_tag: String(doc.auth_tag ?? ''),
|
|
1691
|
+
encrypted_dek: String(doc.encrypted_dek ?? ''),
|
|
1692
|
+
dek_nonce: String(doc.dek_nonce ?? ''),
|
|
1693
|
+
payload_hash: String(doc.payload_hash ?? ''),
|
|
1694
|
+
};
|
|
1695
|
+
let plaintext;
|
|
1696
|
+
if (this.cachedKek && this.argon2Salt) {
|
|
1697
|
+
plaintext = await decryptDocumentWithKek(envelope, this.cachedKek);
|
|
1698
|
+
}
|
|
1699
|
+
else if (password) {
|
|
1700
|
+
const saltHex = String(doc.salt ?? '');
|
|
1701
|
+
const salt = this.argon2Salt ?? (saltHex ? hexToBytes(saltHex) : undefined);
|
|
1702
|
+
if (!salt) {
|
|
1703
|
+
throw SovariaError.config('No Argon2 salt available to decrypt. The document envelope must embed salt, or the session must have a cached server salt.');
|
|
1704
|
+
}
|
|
1705
|
+
plaintext = await decryptDocument(envelope, password, salt);
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
throw SovariaError.config('No cached KEK. Supply the password argument to decrypt.');
|
|
1709
|
+
}
|
|
1710
|
+
return { ...resp, plaintext };
|
|
1711
|
+
},
|
|
1712
|
+
/**
|
|
1713
|
+
* List the documents in one collection (newest writes last): `[{ id, data, txid }]`.
|
|
1714
|
+
* Subcollections are not included — list `collection/<docId>/<sub>` for those.
|
|
1715
|
+
*/
|
|
1716
|
+
docList: async (collection, options = {}) => {
|
|
1717
|
+
const prefix = `${collection.replace(/^\/+|\/+$/g, '')}:`;
|
|
1718
|
+
const res = await this.request('POST', '/api/v1/data/query/scan', {
|
|
1719
|
+
body: { prefix, limit: options.limit ?? 500 },
|
|
1720
|
+
});
|
|
1721
|
+
const docs = res.documents ?? [];
|
|
1722
|
+
return docs
|
|
1723
|
+
.map((d) => ({ id: String(d.key ?? '').slice(prefix.length), data: (d.data ?? {}), txid: Number(d.txid ?? 0) }))
|
|
1724
|
+
.filter((d) => d.id && !d.id.includes('/'));
|
|
1725
|
+
},
|
|
1726
|
+
/** Field query (`where`, `order_by`, `limit`, cursors) — see also `sov.collection(...)`. */
|
|
1727
|
+
docQuery: (collection, query = {}) => this.request('POST', `/api/v1/data/doc/_query/${collection.split('/').map(safePathSegment).join('/')}`, { body: query }),
|
|
1728
|
+
docCount: (collection, query = {}) => this.request('POST', `/api/v1/data/doc/_count/${collection.split('/').map(safePathSegment).join('/')}`, { body: query }),
|
|
1729
|
+
/** Atomic commit: `{ op, path, data, merge, transforms, precondition }` writes; `reads` make it a transaction. */
|
|
1730
|
+
docCommit: (writes, reads = []) => this.request('POST', '/api/v1/data/doc/_commit', { body: { writes, reads } }),
|
|
1731
|
+
docIndexes: (collection) => this.request('GET', '/api/v1/data/doc/_indexes', { query: { collection } }),
|
|
1732
|
+
createDocIndex: (collection, fields) => this.request('POST', '/api/v1/data/doc/_indexes', { body: { collection, fields } }),
|
|
1733
|
+
dropDocIndex: (name) => this.request('DELETE', `/api/v1/data/doc/_indexes/${safePathSegment(name)}`),
|
|
1734
|
+
collectionRules: (collection) => this.request('GET', `/api/v1/data/doc/_rules/${collection.split('/').map(safePathSegment).join('/')}`),
|
|
1735
|
+
docScan: (args) => this.request('POST', '/api/v1/data/query/scan', { body: args }),
|
|
1736
|
+
/**
|
|
1737
|
+
* Run one SQL statement. Pass values for `$1, $2, …` as `params` — they are bound by the
|
|
1738
|
+
* database, so user input never becomes SQL: `sql('SELECT * FROM books WHERE id = $1', [id])`.
|
|
1739
|
+
* A number as the second argument is still read as `txid` (older signature).
|
|
1740
|
+
*
|
|
1741
|
+
* `{ readOnly: true }` runs it in a read-only transaction (the server refuses writes, including
|
|
1742
|
+
* through functions). The answer has `truncated: true` when it was cut at your plan's `sql.max_rows`.
|
|
1743
|
+
*/
|
|
1744
|
+
sql: (sql, params, txid = 0, opts) => {
|
|
1745
|
+
const bound = Array.isArray(params) ? params : undefined;
|
|
1746
|
+
const snapshot = typeof params === 'number' ? params : txid;
|
|
1747
|
+
return this.request('POST', '/api/v1/sql/query', {
|
|
1748
|
+
body: {
|
|
1749
|
+
sql,
|
|
1750
|
+
...(bound && bound.length ? { params: bound } : {}),
|
|
1751
|
+
...(snapshot > 0 ? { txid: snapshot } : {}),
|
|
1752
|
+
...(opts?.readOnly ? { read_only: true } : {}),
|
|
1753
|
+
},
|
|
1754
|
+
});
|
|
1755
|
+
},
|
|
1756
|
+
/**
|
|
1757
|
+
* Call one of your Postgres functions: `POST /rest/v1/rpc/{name}` with the arguments by name.
|
|
1758
|
+
* A function that returns one value resolves to that value; one that returns rows resolves to
|
|
1759
|
+
* an array of objects. App users need an access policy that allows `fn:<name>`. When the function
|
|
1760
|
+
* raises an error, the SovariaError's `detail` holds the database's `code` and `message` (and
|
|
1761
|
+
* `technicalMessage` repeats it for logs); `message` stays one you can show users.
|
|
1762
|
+
*
|
|
1763
|
+
* const n = await sovaria.data.rpc<number>('post_count', { u: userId })
|
|
1764
|
+
* const top = await sovaria.data.rpc('top_posts', { days: 7 }, { get: true, query: { limit: 10 } })
|
|
1765
|
+
*/
|
|
1766
|
+
rpc: (name, args = {}, options = {}) => {
|
|
1767
|
+
if (!name?.trim())
|
|
1768
|
+
throw SovariaError.config('rpc() needs a function name');
|
|
1769
|
+
return this.request(options.get ? 'GET' : 'POST', `/rest/v1/rpc/${safePathSegment(name.trim())}`, {
|
|
1770
|
+
body: options.get ? undefined : args,
|
|
1771
|
+
query: options.get ? { ...rpcQueryArgs(args), ...(options.query ?? {}) } : options.query,
|
|
1772
|
+
requiresScope: false,
|
|
1773
|
+
});
|
|
1774
|
+
},
|
|
1775
|
+
tableCreate: (payload) => this.request('POST', '/api/v1/data/rel/create', { body: payload }),
|
|
1776
|
+
tableDrop: (tableName) => this.request('POST', '/api/v1/data/rel/drop', {
|
|
1777
|
+
body: { table_name: tableName },
|
|
1778
|
+
}),
|
|
1779
|
+
tableAddColumn: (payload) => this.request('POST', '/api/v1/data/rel/column/add', { body: payload }),
|
|
1780
|
+
tableDropColumn: (tableName, columnName) => this.request('POST', '/api/v1/data/rel/column/drop', {
|
|
1781
|
+
body: { table_name: tableName, column_name: columnName },
|
|
1782
|
+
}),
|
|
1783
|
+
tableAddForeignKey: (payload) => this.request('POST', '/api/v1/data/rel/foreign-key/add', { body: payload }),
|
|
1784
|
+
tableDropForeignKey: (payload) => this.request('POST', '/api/v1/data/rel/foreign-key/drop', { body: payload }),
|
|
1785
|
+
tableMigrations: (query) => this.request('GET', '/api/v1/data/rel/migrations', { query }),
|
|
1786
|
+
/** The schema as one short line per table: types, keys, →references, allowed values, indexes, owner-only rows. */
|
|
1787
|
+
schemaDigest: (options = {}) => this.request('GET', '/api/v1/data/rel/schema/digest', {
|
|
1788
|
+
query: { tables: options.tables?.join(',') || undefined, max_chars: options.maxChars, since: options.since },
|
|
1789
|
+
requiresScope: true,
|
|
1790
|
+
}),
|
|
1791
|
+
/**
|
|
1792
|
+
* Create or extend tables from one declarative plan: checked first (a 422 lists every problem and nothing
|
|
1793
|
+
* changes), then applied as one reversible migration and verified. `dryRun` returns the SQL instead.
|
|
1794
|
+
*/
|
|
1795
|
+
applySchemaPlan: (plan) => this.request('POST', '/api/v1/data/rel/schema/plan', {
|
|
1796
|
+
body: { tables: plan.tables, dry_run: plan.dryRun, name: plan.name },
|
|
1797
|
+
requiresScope: true,
|
|
1798
|
+
}),
|
|
1799
|
+
/** Apply a migration atomically and record it in the project's history. `downSql` makes it reversible. */
|
|
1800
|
+
applyMigration: (migration) => this.request('POST', '/api/v1/data/rel/migrations/apply', {
|
|
1801
|
+
body: { name: migration.name, up_sql: migration.upSql, down_sql: migration.downSql, version: migration.version },
|
|
1802
|
+
requiresScope: true,
|
|
1803
|
+
}),
|
|
1804
|
+
/** Run a migration inside a transaction and roll it back: reports whether it would apply. */
|
|
1805
|
+
dryRunMigration: (migration) => this.request('POST', '/api/v1/data/rel/migrations/dry-run', {
|
|
1806
|
+
body: { name: migration.name, up_sql: migration.upSql, down_sql: migration.downSql },
|
|
1807
|
+
requiresScope: true,
|
|
1808
|
+
}),
|
|
1809
|
+
/**
|
|
1810
|
+
* Check several migrations the way they will be applied (what `sov db migrations push --dry-run` does):
|
|
1811
|
+
* all run in order inside one transaction that is rolled back, so later ones see what earlier ones
|
|
1812
|
+
* create. Up to 500 and 5 MB of SQL; `failed.index` is the first that fails. Nothing is kept.
|
|
1813
|
+
*/
|
|
1814
|
+
dryRunMigrations: (migrations) => this.request('POST', '/api/v1/data/rel/migrations/dry-run-sequence', {
|
|
1815
|
+
body: { migrations: migrations.map((m) => ({ name: m.name, up_sql: m.upSql })) },
|
|
1816
|
+
requiresScope: true,
|
|
1817
|
+
}),
|
|
1818
|
+
/** Undo the latest migration (or `version` with `force`) using its down SQL. */
|
|
1819
|
+
rollbackMigration: (opts = {}) => this.request('POST', '/api/v1/data/rel/migrations/rollback', {
|
|
1820
|
+
query: { ...(opts.version ? { version: opts.version } : {}), ...(opts.force ? { force: 'true' } : {}) },
|
|
1821
|
+
requiresScope: true,
|
|
1822
|
+
}),
|
|
1823
|
+
beginTransaction: () => this.request('POST', '/api/v1/data/rel/transaction/begin', { requiresScope: true }),
|
|
1824
|
+
commitTransaction: (txid) => this.request('POST', '/api/v1/data/rel/transaction/commit', {
|
|
1825
|
+
body: { txid },
|
|
1826
|
+
requiresScope: true,
|
|
1827
|
+
}),
|
|
1828
|
+
rollbackTransaction: (txid) => this.request('POST', '/api/v1/data/rel/transaction/rollback', {
|
|
1829
|
+
body: { txid },
|
|
1830
|
+
requiresScope: true,
|
|
1831
|
+
}),
|
|
1832
|
+
};
|
|
1833
|
+
realtime = {
|
|
1834
|
+
subscribe: (channel, clientId) => this.request('POST', '/api/v1/rt/stream/subscribe', {
|
|
1835
|
+
body: { channel, client_id: clientId },
|
|
1836
|
+
}),
|
|
1837
|
+
unsubscribe: (channel, clientId) => this.request('POST', '/api/v1/rt/stream/unsubscribe', {
|
|
1838
|
+
body: { channel, client_id: clientId },
|
|
1839
|
+
}),
|
|
1840
|
+
presence: (channel) => this.request('GET', `/api/v1/rt/stream/presence/${safePathSegment(channel)}`),
|
|
1841
|
+
channels: () => this.request('GET', '/api/v1/rt/stream/channels'),
|
|
1842
|
+
replay: (channel, after = 0, limit = 200) => this.request('GET', `/api/v1/rt/stream/replay/${safePathSegment(channel)}`, {
|
|
1843
|
+
query: { after, limit },
|
|
1844
|
+
}),
|
|
1845
|
+
publish: (channel, payload, clock, clockDelta) => this.request('POST', `/api/v1/rt/stream/publish/${safePathSegment(channel)}`, {
|
|
1846
|
+
body: {
|
|
1847
|
+
payload,
|
|
1848
|
+
clock,
|
|
1849
|
+
clock_delta: clockDelta,
|
|
1850
|
+
},
|
|
1851
|
+
}),
|
|
1852
|
+
e2eeBootstrap: (channel, ttlSeconds = 86400, forceNew = false) => this.request('POST', '/api/v1/rt/e2ee/keys/bootstrap', {
|
|
1853
|
+
body: { channel, ttl_seconds: ttlSeconds, force_new: forceNew },
|
|
1854
|
+
}),
|
|
1855
|
+
e2eeRotate: (channel, ttlSeconds = 86400) => this.request('POST', '/api/v1/rt/e2ee/keys/rotate', {
|
|
1856
|
+
body: { channel, ttl_seconds: ttlSeconds },
|
|
1857
|
+
}),
|
|
1858
|
+
e2eeRevoke: (channel, keyId, reason) => this.request('POST', '/api/v1/rt/e2ee/keys/revoke', {
|
|
1859
|
+
body: { channel, key_id: keyId, reason },
|
|
1860
|
+
}),
|
|
1861
|
+
/** Pass your RSA-OAEP (SHA-256) public key — PEM or base64 SPKI — to get the key wrapped for you. */
|
|
1862
|
+
e2eeActiveKey: (channel, clientPublicKey) => this.request('GET', `/api/v1/rt/e2ee/keys/active/${safePathSegment(channel)}`, {
|
|
1863
|
+
query: { client_public_key_pem: clientPublicKey },
|
|
1864
|
+
}),
|
|
1865
|
+
e2eeGetKey: (channel, keyId, clientPublicKey) => this.request('GET', `/api/v1/rt/e2ee/keys/${safePathSegment(channel)}/${safePathSegment(keyId)}`, { query: { client_public_key_pem: clientPublicKey } }),
|
|
1866
|
+
e2eeListKeys: (channel) => this.request('GET', `/api/v1/rt/e2ee/keys/${safePathSegment(channel)}`),
|
|
1867
|
+
wsTicket: (channel) => this.issueRealtimeWebSocketTicket(channel),
|
|
1868
|
+
wsUrl: (channel, ticket) => this.getRealtimeWebSocketUrl(channel, ticket),
|
|
1869
|
+
wsUrlWithTicket: (channel) => this.getRealtimeWebSocketUrlWithTicket(channel),
|
|
1870
|
+
/**
|
|
1871
|
+
* Listen on a channel over WebSocket. Reconnects with backoff (a fresh ticket each time) and
|
|
1872
|
+
* hands you each message's payload. Call `close()` when done.
|
|
1873
|
+
*
|
|
1874
|
+
* ```ts
|
|
1875
|
+
* const ch = sov.realtime.channel('book:42', { onMessage: (m) => console.log(m), onStatus: setStatus })
|
|
1876
|
+
* await ch.send({ type: 'typing' })
|
|
1877
|
+
* ch.close()
|
|
1878
|
+
* ```
|
|
1879
|
+
*/
|
|
1880
|
+
channel: (name, handlers = {}) => openRealtimeChannel(this, name, handlers),
|
|
1881
|
+
};
|
|
1882
|
+
policy = {
|
|
1883
|
+
templates: () => this.request('GET', '/api/v1/policies/templates'),
|
|
1884
|
+
list: () => this.request('GET', '/api/v1/policies/'),
|
|
1885
|
+
get: (policyId) => this.request('GET', `/api/v1/policies/${safePathSegment(policyId)}`),
|
|
1886
|
+
create: (payload) => this.request('POST', '/api/v1/policies/', {
|
|
1887
|
+
body: payload,
|
|
1888
|
+
requiresScope: false,
|
|
1889
|
+
}),
|
|
1890
|
+
update: (policyId, payload) => this.request('PUT', `/api/v1/policies/${safePathSegment(policyId)}`, {
|
|
1891
|
+
body: payload,
|
|
1892
|
+
requiresScope: false,
|
|
1893
|
+
}),
|
|
1894
|
+
delete: (policyId) => this.request('DELETE', `/api/v1/policies/${safePathSegment(policyId)}`, {
|
|
1895
|
+
requiresScope: false,
|
|
1896
|
+
}),
|
|
1897
|
+
evaluate: (policyId, context) => this.request('POST', `/api/v1/policies/${safePathSegment(policyId)}/evaluate`, {
|
|
1898
|
+
body: { context },
|
|
1899
|
+
requiresScope: false,
|
|
1900
|
+
}),
|
|
1901
|
+
check: (payload) => this.request('POST', '/api/v1/policies/check', {
|
|
1902
|
+
body: {
|
|
1903
|
+
org_id: payload.orgId,
|
|
1904
|
+
project_id: payload.projectId,
|
|
1905
|
+
action: payload.action,
|
|
1906
|
+
resource: payload.resource,
|
|
1907
|
+
context: payload.context ?? {},
|
|
1908
|
+
},
|
|
1909
|
+
requiresScope: false,
|
|
1910
|
+
}),
|
|
1911
|
+
preflight: async (action, resource, context = {}) => {
|
|
1912
|
+
await this.resolveScope();
|
|
1913
|
+
this.assertScopePresent();
|
|
1914
|
+
return this.policy.check({
|
|
1915
|
+
orgId: this.scope.orgId,
|
|
1916
|
+
projectId: this.scope.projectId,
|
|
1917
|
+
action,
|
|
1918
|
+
resource,
|
|
1919
|
+
context,
|
|
1920
|
+
});
|
|
1921
|
+
},
|
|
1922
|
+
history: (policyId) => this.request('GET', `/api/v1/policies/${safePathSegment(policyId)}/history`, {
|
|
1923
|
+
requiresScope: false,
|
|
1924
|
+
}),
|
|
1925
|
+
rollback: (policyId, version) => this.request('POST', `/api/v1/policies/${safePathSegment(policyId)}/rollback`, {
|
|
1926
|
+
body: { version },
|
|
1927
|
+
requiresScope: false,
|
|
1928
|
+
}),
|
|
1929
|
+
simulate: (splSource, context = {}) => this.request('POST', '/api/v1/policies/simulate', {
|
|
1930
|
+
body: { spl_source: splSource, context },
|
|
1931
|
+
requiresScope: true,
|
|
1932
|
+
}),
|
|
1933
|
+
};
|
|
1934
|
+
audit = {
|
|
1935
|
+
append: (payload) => this.request('POST', '/api/v1/audit/', {
|
|
1936
|
+
body: payload,
|
|
1937
|
+
}),
|
|
1938
|
+
list: (query) => this.request('GET', '/api/v1/audit/', { query }),
|
|
1939
|
+
verify: () => this.request('GET', '/api/v1/audit/verify'),
|
|
1940
|
+
count: () => this.request('GET', '/api/v1/audit/count'),
|
|
1941
|
+
};
|
|
1942
|
+
simulate = {
|
|
1943
|
+
/**
|
|
1944
|
+
* Who can do what in the live project, as visitors, the app's users and the team; nothing is
|
|
1945
|
+
* written. Pass `{ manifest_id }` from `discover()` to skip discovery (manifest content is never
|
|
1946
|
+
* accepted). Narrow with `{ filter: { resource, action, role } }` (role: `__guest__`,
|
|
1947
|
+
* `__app_user__`, `app:<role>` or a team role): up to 25 request types is a single-request check
|
|
1948
|
+
* that doesn't use the allowance; a wider filter is charged as a full run. One full run at a time
|
|
1949
|
+
* per organization (409 `SIMULATION_RUN_IN_PROGRESS`).
|
|
1950
|
+
*/
|
|
1951
|
+
run: async (payload = {}) => {
|
|
1952
|
+
const filter = payload.filter;
|
|
1953
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
1954
|
+
return this.request('POST', '/api/v1/sim/run', { body: payload, requiresScope: true });
|
|
1955
|
+
}
|
|
1956
|
+
// A full run takes longer than one request should wait: start it in the background, poll it.
|
|
1957
|
+
let run = await this.request('POST', '/api/v1/sim/run', {
|
|
1958
|
+
body: { ...payload, background: true }, requiresScope: true,
|
|
1959
|
+
});
|
|
1960
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
1961
|
+
while (run.status === 'running') {
|
|
1962
|
+
if (Date.now() > deadline)
|
|
1963
|
+
throw new Error(`Simulation ${String(run.run_id)} is still running after 10 minutes`);
|
|
1964
|
+
await sleep(2_000);
|
|
1965
|
+
run = await this.request('GET', `/api/v1/sim/runs/${encodeURIComponent(String(run.run_id))}`, {
|
|
1966
|
+
requiresScope: true,
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
if (run.status === 'failed')
|
|
1970
|
+
throw new Error(String(run.error ?? 'Simulation failed'));
|
|
1971
|
+
return run;
|
|
1972
|
+
},
|
|
1973
|
+
/**
|
|
1974
|
+
* Try a change before making it; nothing is written. `{ kind: 'policy', policy: { spl_source,
|
|
1975
|
+
* name?, replaces? }, disable_policy_ids? }` lists who gains and loses access; `{ kind: 'sql',
|
|
1976
|
+
* sql }` runs the script with every check, rolls it back and lists what it changed.
|
|
1977
|
+
*/
|
|
1978
|
+
change: (payload) => this.request('POST', '/api/v1/sim/change', { body: payload, requiresScope: true }),
|
|
1979
|
+
plan: (payload) => this.request('POST', '/api/v1/sim/plan', { body: payload }),
|
|
1980
|
+
discover: () => this.request('POST', '/api/v1/sim/discover', { body: {}, requiresScope: true }),
|
|
1981
|
+
listRuns: (query) => this.request('GET', '/api/v1/sim/runs', { query, requiresScope: true }),
|
|
1982
|
+
/** Real runs used and allowed today (UTC) and this month: `{ day: {used, limit, resets_at}, month: {…} }`. */
|
|
1983
|
+
quota: () => this.request('GET', '/api/v1/sim/quota', { requiresScope: true }),
|
|
1984
|
+
/** Outcomes the team marked as intended (shown green on every run, left out of findings). */
|
|
1985
|
+
listIntended: () => this.request('GET', '/api/v1/sim/intended', { requiresScope: true }),
|
|
1986
|
+
/** Mark an outcome as intended, e.g. `{ role: '__guest__', action: 'sql:select', resource: 'public.products',
|
|
1987
|
+
* outcome: 'ALLOW', note: 'The catalogue is public' }`. Needs policies.write. */
|
|
1988
|
+
markIntended: (mark) => this.request('POST', '/api/v1/sim/intended', { body: mark, requiresScope: true }),
|
|
1989
|
+
/** Remove a mark; matching cases show as measured again. */
|
|
1990
|
+
unmarkIntended: (markId) => this.request('DELETE', `/api/v1/sim/intended/${safePathSegment(markId)}`, { requiresScope: true }),
|
|
1991
|
+
/** Callers, gateway checks, sign-in, resources and connectors (platform defences as "Platform protection"). */
|
|
1992
|
+
topology: () => this.request('GET', '/api/v1/sim/topology', { requiresScope: true }),
|
|
1993
|
+
/** Everything the project has, read from every engine (every schema it created, collections, buckets, secret names,
|
|
1994
|
+
* functions with schedules, queues, workflows and triggers, sign-in, webhooks, channels, edge limits, backups,
|
|
1995
|
+
* environments, domains, policies, plan headroom). */
|
|
1996
|
+
inventory: (params = {}) => this.request('GET', '/api/v1/sim/inventory', {
|
|
1997
|
+
query: { include: params.include, fresh: params.fresh ? 'true' : undefined }, requiresScope: true
|
|
1998
|
+
}),
|
|
1999
|
+
/** What is wrong or at risk, worst first, each with a fix. `severity` keeps that level and worse. */
|
|
2000
|
+
checks: (params = {}) => this.request('GET', '/api/v1/sim/checks', {
|
|
2001
|
+
query: { severity: params.severity, area: params.area, fresh: params.fresh ? 'true' : undefined }, requiresScope: true
|
|
2002
|
+
}),
|
|
2003
|
+
/** The architecture map: nodes by layer with their status, and the edges between them. */
|
|
2004
|
+
graph: (params = {}) => this.request('GET', '/api/v1/sim/graph', { query: { fresh: params.fresh ? 'true' : undefined }, requiresScope: true }),
|
|
2005
|
+
/** Every limit the project lives under, with its value and how it is counted. */
|
|
2006
|
+
limits: () => this.request('GET', '/api/v1/sim/limits', { requiresScope: true }),
|
|
2007
|
+
/** One integration flow under a programmed outcome. `modelled` is free and sends nothing; `sealed` runs the
|
|
2008
|
+
* real code path to the send point and returns the recording (one single-request check). */
|
|
2009
|
+
integrationRun: (body) => this.request('POST', '/api/v1/sim/integrations/run', { body, requiresScope: true }),
|
|
2010
|
+
/** The 14 security drills judged from the project's configuration, with an optional what-if draft (never saved). */
|
|
2011
|
+
drillsRun: (body = {}) => this.request('POST', '/api/v1/sim/drills/run', { body, requiresScope: true }),
|
|
2012
|
+
/** What a run's sealed send points would have sent. */
|
|
2013
|
+
runArtifacts: (runId) => this.request('GET', `/api/v1/sim/runs/${safePathSegment(runId)}/artifacts`, { requiresScope: true }),
|
|
2014
|
+
getRun: (runId) => this.request('GET', `/api/v1/sim/runs/${safePathSegment(runId)}`, {
|
|
2015
|
+
requiresScope: true,
|
|
2016
|
+
}),
|
|
2017
|
+
compareRuns: (targetRunId, baselineRunId) => this.request('GET', `/api/v1/sim/runs/${safePathSegment(targetRunId)}/compare/${safePathSegment(baselineRunId)}`, { requiresScope: true }),
|
|
2018
|
+
};
|
|
2019
|
+
chrono = {
|
|
2020
|
+
/** The project's configuration (policies, schema, functions, settings, buckets, keys) at a commit or ISO time. */
|
|
2021
|
+
state: (query = {}) => this.request('GET', '/api/v1/chrono/state', { query: { timeline: 'main', ...query }, requiresScope: true }),
|
|
2022
|
+
/**
|
|
2023
|
+
* What going back to `at` on a branch changes, file by file, with who would gain or lose access. Writes nothing.
|
|
2024
|
+
* The branch decides the environment acted on (main = production); `envId` is the one you have checked out
|
|
2025
|
+
* ('' = production) and a mismatch is refused (ENV_MISMATCH).
|
|
2026
|
+
*/
|
|
2027
|
+
restorePlan: (at, timeline = 'main', options = {}) => this.request('POST', '/api/v1/chrono/restore-plan', {
|
|
2028
|
+
body: { at, timeline, ...(options.envId !== undefined ? { env_id: options.envId } : {}) },
|
|
2029
|
+
requiresScope: true,
|
|
2030
|
+
}),
|
|
2031
|
+
/**
|
|
2032
|
+
* Go back to `at` on a branch: applies the plan's automated changes with the caller's own permissions, in that
|
|
2033
|
+
* branch's own environment. `includeSchema` also rolls back the migrations applied since then; `includeDatabase`
|
|
2034
|
+
* also restores the database as of then (production: the closest backup; an environment: point-in-time).
|
|
2035
|
+
* The answer has `new_head`, or `recording: true` while the new commits are still arriving.
|
|
2036
|
+
*/
|
|
2037
|
+
restore: (at, timeline = 'main', options = {}) => this.request('POST', '/api/v1/chrono/restore', {
|
|
2038
|
+
body: {
|
|
2039
|
+
at, timeline, confirm: true,
|
|
2040
|
+
...(options.includeDatabase ? { include_database: true } : {}),
|
|
2041
|
+
...(options.includeSchema ? { include_schema: true } : {}),
|
|
2042
|
+
...(options.envId !== undefined ? { env_id: options.envId } : {}),
|
|
2043
|
+
},
|
|
2044
|
+
requiresScope: true,
|
|
2045
|
+
}),
|
|
2046
|
+
commit: (payload) => this.request('POST', '/api/v1/chrono/commit', { body: payload }),
|
|
2047
|
+
timelineSpawn: (payload) => this.request('POST', '/api/v1/chrono/timelines/spawn', { body: payload }),
|
|
2048
|
+
timelineList: () => this.request('GET', '/api/v1/chrono/timelines'),
|
|
2049
|
+
timelineDelete: (name) => this.request('DELETE', `/api/v1/chrono/timelines/${safePathSegment(name)}`),
|
|
2050
|
+
timelineJump: (name, targetHex) => this.request('POST', `/api/v1/chrono/timelines/${safePathSegment(name)}/jump`, {
|
|
2051
|
+
body: { target_hex: targetHex },
|
|
2052
|
+
}),
|
|
2053
|
+
timelineHistory: (name, limit = 50, offset = 0) => this.request('GET', `/api/v1/chrono/timelines/${safePathSegment(name)}/history`, {
|
|
2054
|
+
query: { limit, offset },
|
|
2055
|
+
}),
|
|
2056
|
+
topology: (timeline = 'main', limit = 50) => this.request('GET', '/api/v1/chrono/timelines/topology', {
|
|
2057
|
+
query: { timeline, limit },
|
|
2058
|
+
}),
|
|
2059
|
+
converge: (payload) => this.request('POST', '/api/v1/chrono/timelines/converge', {
|
|
2060
|
+
body: payload,
|
|
2061
|
+
}),
|
|
2062
|
+
variance: (payload) => this.request('POST', '/api/v1/chrono/variance', {
|
|
2063
|
+
body: payload,
|
|
2064
|
+
}),
|
|
2065
|
+
snapshotCreate: (payload) => this.request('POST', '/api/v1/chrono/snapshots/create', {
|
|
2066
|
+
body: payload,
|
|
2067
|
+
}),
|
|
2068
|
+
snapshotList: () => this.request('GET', '/api/v1/chrono/snapshots'),
|
|
2069
|
+
snapshotRestore: (snapshotId) => this.request('POST', `/api/v1/chrono/snapshots/${snapshotId}/restore`),
|
|
2070
|
+
};
|
|
2071
|
+
functions = {
|
|
2072
|
+
list: (options) => this.request('GET', '/api/v1/fn/functions', {
|
|
2073
|
+
query: {
|
|
2074
|
+
project_id: options.projectId,
|
|
2075
|
+
limit: options.limit,
|
|
2076
|
+
offset: options.offset,
|
|
2077
|
+
},
|
|
2078
|
+
requiresScope: false,
|
|
2079
|
+
}),
|
|
2080
|
+
/** Validate a bundle and get a signed plan bound to its exact bytes. */
|
|
2081
|
+
plan: (bundle, name, projectId) => {
|
|
2082
|
+
const form = new FormData();
|
|
2083
|
+
form.append('bundle', bundle, 'bundle.zip');
|
|
2084
|
+
form.append('name', name);
|
|
2085
|
+
if (projectId) {
|
|
2086
|
+
form.append('project_id', projectId);
|
|
2087
|
+
}
|
|
2088
|
+
return this.request('POST', '/api/v1/fn/plan', {
|
|
2089
|
+
body: form,
|
|
2090
|
+
requiresScope: false,
|
|
2091
|
+
});
|
|
2092
|
+
},
|
|
2093
|
+
/** Plans, then applies exactly the planned bundle (Forge refuses anything else). */
|
|
2094
|
+
deploy: async (bundle, name, projectId) => {
|
|
2095
|
+
const plan = await this.functions.plan(bundle, name, projectId);
|
|
2096
|
+
const token = typeof plan.token === 'string' ? plan.token : '';
|
|
2097
|
+
const form = new FormData();
|
|
2098
|
+
form.append('bundle', bundle, 'bundle.zip');
|
|
2099
|
+
form.append('name', name);
|
|
2100
|
+
if (projectId) {
|
|
2101
|
+
form.append('project_id', projectId);
|
|
2102
|
+
}
|
|
2103
|
+
return this.request('POST', '/api/v1/fn/deploy', {
|
|
2104
|
+
body: form,
|
|
2105
|
+
headers: token ? { 'X-Sovaria-Deploy-Plan': token } : undefined,
|
|
2106
|
+
requiresScope: false,
|
|
2107
|
+
});
|
|
2108
|
+
},
|
|
2109
|
+
status: (deployId, projectId) => this.request('GET', `/api/v1/fn/${safePathSegment(deployId)}/status`, {
|
|
2110
|
+
query: projectId ? { project_id: projectId } : undefined,
|
|
2111
|
+
requiresScope: false,
|
|
2112
|
+
}),
|
|
2113
|
+
invoke: (functionName, payload, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/invoke`, {
|
|
2114
|
+
body: payload,
|
|
2115
|
+
query: options?.projectId || options?.deployId || options?.executionProfile
|
|
2116
|
+
? {
|
|
2117
|
+
project_id: options?.projectId,
|
|
2118
|
+
deploy_id: options?.deployId,
|
|
2119
|
+
execution_profile: options?.executionProfile,
|
|
2120
|
+
}
|
|
2121
|
+
: undefined,
|
|
2122
|
+
requiresScope: false,
|
|
2123
|
+
}),
|
|
2124
|
+
/**
|
|
2125
|
+
* Call a function the way an app does: POST {projectUrl}/functions/v1/{name} with the signed-in
|
|
2126
|
+
* user's token (or no token for functions marked public). Returns the function's result.
|
|
2127
|
+
*/
|
|
2128
|
+
call: (functionName, payload = {}) => this.request('POST', `/functions/v1/${safePathSegment(functionName)}`, { body: payload, requiresScope: false }),
|
|
2129
|
+
/**
|
|
2130
|
+
* Call an HTTP function like `fetch`: any method, a sub-path, headers, a raw body (string, bytes, FormData…),
|
|
2131
|
+
* and the function's own Response back (status, headers, binary body). Sends the signed-in user's token
|
|
2132
|
+
* when there is one (none for public functions).
|
|
2133
|
+
*
|
|
2134
|
+
* const res = await sovaria.functions.fetch('api', 'users/42', { method: 'PUT', body: JSON.stringify(user) })
|
|
2135
|
+
* if (res.status === 201) console.log(await res.json())
|
|
2136
|
+
*/
|
|
2137
|
+
fetch: async (functionName, path = '', init = {}) => {
|
|
2138
|
+
const sub = path.replace(/^\/+/, '');
|
|
2139
|
+
if (sub.split('/').includes('..'))
|
|
2140
|
+
throw SovariaError.config('Function paths cannot contain ..', 400);
|
|
2141
|
+
const query = init.query ? `?${new URLSearchParams(init.query).toString()}` : '';
|
|
2142
|
+
const url = `${this.baseUrl}/functions/v1/${safePathSegment(functionName)}${sub ? `/${sub}` : ''}${query}`;
|
|
2143
|
+
if (this.accessToken)
|
|
2144
|
+
await this.ensureAccessTokenFresh();
|
|
2145
|
+
const headers = {
|
|
2146
|
+
...(await this.clientHeaders()),
|
|
2147
|
+
...(init.headers ?? {}),
|
|
2148
|
+
...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
|
|
2149
|
+
};
|
|
2150
|
+
return this.fetchImpl(url, { method: (init.method ?? 'GET').toUpperCase(), headers, body: init.body ?? undefined });
|
|
2151
|
+
},
|
|
2152
|
+
/** Make a function an HTTP function (Request in, Response out) or a JSON one again. */
|
|
2153
|
+
setHttp: (functionName, http) => this.request('PUT', `/api/v1/fn/functions/${safePathSegment(functionName)}/http`, { body: { http } }),
|
|
2154
|
+
simulate: (functionName, payload, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/simulate`, {
|
|
2155
|
+
body: payload,
|
|
2156
|
+
query: options?.projectId || options?.deployId || options?.executionProfile
|
|
2157
|
+
? {
|
|
2158
|
+
project_id: options?.projectId,
|
|
2159
|
+
deploy_id: options?.deployId,
|
|
2160
|
+
execution_profile: options?.executionProfile,
|
|
2161
|
+
}
|
|
2162
|
+
: undefined,
|
|
2163
|
+
requiresScope: false,
|
|
2164
|
+
}),
|
|
2165
|
+
invokeAsync: (functionName, payload, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/invoke/async`, {
|
|
2166
|
+
body: payload,
|
|
2167
|
+
query: options?.projectId ||
|
|
2168
|
+
options?.deployId ||
|
|
2169
|
+
options?.executionProfile ||
|
|
2170
|
+
options?.simulation !== undefined
|
|
2171
|
+
? {
|
|
2172
|
+
project_id: options?.projectId,
|
|
2173
|
+
deploy_id: options?.deployId,
|
|
2174
|
+
execution_profile: options?.executionProfile,
|
|
2175
|
+
simulation: options?.simulation,
|
|
2176
|
+
}
|
|
2177
|
+
: undefined,
|
|
2178
|
+
requiresScope: false,
|
|
2179
|
+
}),
|
|
2180
|
+
invocationStatus: (invocationId, projectId) => this.request('GET', `/api/v1/fn/invocations/${safePathSegment(invocationId)}`, {
|
|
2181
|
+
query: { project_id: projectId },
|
|
2182
|
+
requiresScope: false,
|
|
2183
|
+
}),
|
|
2184
|
+
jobs: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/jobs`, {
|
|
2185
|
+
query: {
|
|
2186
|
+
project_id: options.projectId,
|
|
2187
|
+
status: options.status,
|
|
2188
|
+
limit: options.limit,
|
|
2189
|
+
offset: options.offset,
|
|
2190
|
+
},
|
|
2191
|
+
requiresScope: false,
|
|
2192
|
+
}),
|
|
2193
|
+
versions: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/versions`, {
|
|
2194
|
+
query: {
|
|
2195
|
+
project_id: options.projectId,
|
|
2196
|
+
limit: options.limit,
|
|
2197
|
+
offset: options.offset,
|
|
2198
|
+
},
|
|
2199
|
+
requiresScope: false,
|
|
2200
|
+
}),
|
|
2201
|
+
history: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/history/invocations`, {
|
|
2202
|
+
query: {
|
|
2203
|
+
project_id: options.projectId,
|
|
2204
|
+
mode: options.mode,
|
|
2205
|
+
limit: options.limit,
|
|
2206
|
+
offset: options.offset,
|
|
2207
|
+
},
|
|
2208
|
+
requiresScope: false,
|
|
2209
|
+
}),
|
|
2210
|
+
simulationDiff: (functionName, options) => this.request('GET', `/api/v1/fn/${safePathSegment(functionName)}/history/simulation-diff`, {
|
|
2211
|
+
query: {
|
|
2212
|
+
project_id: options.projectId,
|
|
2213
|
+
left_invocation_id: options.leftInvocationId,
|
|
2214
|
+
right_invocation_id: options.rightInvocationId,
|
|
2215
|
+
},
|
|
2216
|
+
requiresScope: false,
|
|
2217
|
+
}),
|
|
2218
|
+
promote: (functionName, options) => {
|
|
2219
|
+
if (options.version === undefined && !options.deployId) {
|
|
2220
|
+
throw SovariaError.config('Provide either version or deployId for promote.');
|
|
2221
|
+
}
|
|
2222
|
+
return this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/promote`, {
|
|
2223
|
+
query: {
|
|
2224
|
+
project_id: options.projectId,
|
|
2225
|
+
},
|
|
2226
|
+
body: {
|
|
2227
|
+
version: options.version,
|
|
2228
|
+
deploy_id: options.deployId,
|
|
2229
|
+
reason: options.reason ?? '',
|
|
2230
|
+
},
|
|
2231
|
+
requiresScope: false,
|
|
2232
|
+
});
|
|
2233
|
+
},
|
|
2234
|
+
rollback: (functionName, options) => this.request('POST', `/api/v1/fn/${safePathSegment(functionName)}/rollback`, {
|
|
2235
|
+
query: {
|
|
2236
|
+
project_id: options.projectId,
|
|
2237
|
+
},
|
|
2238
|
+
body: {
|
|
2239
|
+
version: options.version,
|
|
2240
|
+
reason: options.reason ?? '',
|
|
2241
|
+
},
|
|
2242
|
+
requiresScope: false,
|
|
2243
|
+
}),
|
|
2244
|
+
request: (method, path, options = {}) => this.request(method, `/api/v1/fn/${normalizeSubpath(path)}`, {
|
|
2245
|
+
...options,
|
|
2246
|
+
requiresScope: options.requiresScope ?? false,
|
|
2247
|
+
}),
|
|
2248
|
+
get: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}`, {
|
|
2249
|
+
requiresScope: true,
|
|
2250
|
+
}),
|
|
2251
|
+
getSource: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/source`, {
|
|
2252
|
+
requiresScope: true,
|
|
2253
|
+
}),
|
|
2254
|
+
update: (name, body) => this.request('PATCH', `/api/v1/fn/functions/${safePathSegment(name)}`, {
|
|
2255
|
+
body,
|
|
2256
|
+
requiresScope: true,
|
|
2257
|
+
}),
|
|
2258
|
+
delete: (name) => this.request('DELETE', `/api/v1/fn/functions/${safePathSegment(name)}`, {
|
|
2259
|
+
requiresScope: true,
|
|
2260
|
+
}),
|
|
2261
|
+
logs: (name, query) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/logs`, {
|
|
2262
|
+
query,
|
|
2263
|
+
requiresScope: true,
|
|
2264
|
+
}),
|
|
2265
|
+
getPolicy: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/policy`, {
|
|
2266
|
+
requiresScope: true,
|
|
2267
|
+
}),
|
|
2268
|
+
setPolicy: (name, body) => this.request('PUT', `/api/v1/fn/functions/${safePathSegment(name)}/policy`, {
|
|
2269
|
+
body,
|
|
2270
|
+
requiresScope: true,
|
|
2271
|
+
}),
|
|
2272
|
+
getBindings: (name) => this.request('GET', `/api/v1/fn/functions/${safePathSegment(name)}/bindings`, {
|
|
2273
|
+
requiresScope: true,
|
|
2274
|
+
}),
|
|
2275
|
+
setBindings: (name, body) => this.request('PUT', `/api/v1/fn/functions/${safePathSegment(name)}/bindings`, {
|
|
2276
|
+
body,
|
|
2277
|
+
requiresScope: true,
|
|
2278
|
+
}),
|
|
2279
|
+
githubRepositories: (body) => this.request('POST', '/api/v1/fn/github/repositories', { body, requiresScope: true }),
|
|
2280
|
+
githubImport: (body) => this.request('POST', '/api/v1/fn/github/import', { body, requiresScope: true }),
|
|
2281
|
+
githubCommit: (body) => this.request('POST', '/api/v1/fn/github/commit', { body, requiresScope: true }),
|
|
2282
|
+
};
|
|
2283
|
+
/**
|
|
2284
|
+
* Web hosting (Sites): static builds served on `<site>.sovaria.app` and your own domains. Deploys are
|
|
2285
|
+
* immutable and content-addressed (only new files are uploaded); a release switches the whole site at once.
|
|
2286
|
+
*
|
|
2287
|
+
* await sovaria.hosting.createSite('my-app')
|
|
2288
|
+
* const { release } = await sovaria.hosting.deployFiles('my-app', { '/index.html': '<h1>Hi</h1>' }, { config: { spa: true } })
|
|
2289
|
+
* console.log(release.url) // https://my-app.sovaria.app
|
|
2290
|
+
*/
|
|
2291
|
+
hosting = {
|
|
2292
|
+
config: () => this.request('GET', '/api/v1/fn/hosting/config'),
|
|
2293
|
+
sites: () => this.request('GET', '/api/v1/fn/hosting/sites'),
|
|
2294
|
+
createSite: (name) => this.request('POST', '/api/v1/fn/hosting/sites', { body: { name } }),
|
|
2295
|
+
site: (site) => this.request('GET', `/api/v1/fn/hosting/sites/${safePathSegment(site)}`),
|
|
2296
|
+
deleteSite: (site) => this.request('DELETE', `/api/v1/fn/hosting/sites/${safePathSegment(site)}`),
|
|
2297
|
+
deploys: (site) => this.request('GET', `/api/v1/fn/hosting/sites/${safePathSegment(site)}/deploys`),
|
|
2298
|
+
releases: (site, channel) => this.request('GET', `/api/v1/fn/hosting/sites/${safePathSegment(site)}/releases`, {
|
|
2299
|
+
query: channel ? { channel } : undefined,
|
|
2300
|
+
}),
|
|
2301
|
+
rollback: (site, options = {}) => this.request('POST', `/api/v1/fn/hosting/sites/${safePathSegment(site)}/rollback`, {
|
|
2302
|
+
body: { channel: options.channel ?? 'live', ...(options.deployId ? { deploy_id: options.deployId } : {}) },
|
|
2303
|
+
}),
|
|
2304
|
+
channels: (site) => this.request('GET', `/api/v1/fn/hosting/sites/${safePathSegment(site)}/channels`),
|
|
2305
|
+
releaseToChannel: (site, channel, deployId, expiresDays) => this.request('POST', `/api/v1/fn/hosting/sites/${safePathSegment(site)}/channels/${safePathSegment(channel)}`, {
|
|
2306
|
+
body: { deploy_id: deployId, ...(expiresDays ? { expires_days: expiresDays } : {}) },
|
|
2307
|
+
}),
|
|
2308
|
+
deleteChannel: (site, channel) => this.request('DELETE', `/api/v1/fn/hosting/sites/${safePathSegment(site)}/channels/${safePathSegment(channel)}`),
|
|
2309
|
+
/**
|
|
2310
|
+
* Deploy files (path → contents) and release them: hashes each file, uploads only the ones the project
|
|
2311
|
+
* doesn't have, then switches the channel (`live` by default) to the new deploy in one step.
|
|
2312
|
+
*/
|
|
2313
|
+
deployFiles: async (site, files, options = {}) => {
|
|
2314
|
+
const bytes = {};
|
|
2315
|
+
for (const [path, value] of Object.entries(files)) {
|
|
2316
|
+
bytes[path.startsWith('/') ? path : `/${path}`] =
|
|
2317
|
+
typeof value === 'string' ? new TextEncoder().encode(value)
|
|
2318
|
+
: value instanceof Uint8Array ? value
|
|
2319
|
+
: value instanceof ArrayBuffer ? new Uint8Array(value)
|
|
2320
|
+
: new Uint8Array(await value.arrayBuffer());
|
|
2321
|
+
}
|
|
2322
|
+
const listing = {};
|
|
2323
|
+
const byHash = {};
|
|
2324
|
+
for (const [path, data] of Object.entries(bytes)) {
|
|
2325
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', data);
|
|
2326
|
+
const sha = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
2327
|
+
listing[path] = { sha256: sha, size: data.byteLength };
|
|
2328
|
+
byHash[sha] = data;
|
|
2329
|
+
}
|
|
2330
|
+
const base = `/api/v1/fn/hosting/sites/${safePathSegment(site)}/deploys`;
|
|
2331
|
+
const created = await this.request('POST', base, {
|
|
2332
|
+
body: { files: listing, config: options.config ?? {}, message: options.message ?? '' },
|
|
2333
|
+
});
|
|
2334
|
+
for (const sha of created.missing ?? []) {
|
|
2335
|
+
await this.request('PUT', `${base}/${created.deploy_id}/files/${sha}`, {
|
|
2336
|
+
body: byHash[sha],
|
|
2337
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
return this.request('POST', `${base}/${created.deploy_id}/finalize`, {
|
|
2341
|
+
body: {
|
|
2342
|
+
release: options.release ?? true,
|
|
2343
|
+
channel: options.channel ?? 'live',
|
|
2344
|
+
...(options.expiresDays ? { expires_days: options.expiresDays } : {}),
|
|
2345
|
+
},
|
|
2346
|
+
});
|
|
2347
|
+
},
|
|
2348
|
+
};
|
|
2349
|
+
storage = {
|
|
2350
|
+
request: (method, path, options = {}) => this.request(method, `/api/v1/store/${normalizeSubpath(path)}`, {
|
|
2351
|
+
...options,
|
|
2352
|
+
requiresScope: options.requiresScope ?? false,
|
|
2353
|
+
}),
|
|
2354
|
+
putJson: (path, payload) => this.request('POST', `/api/v1/store/${normalizeSubpath(path)}`, {
|
|
2355
|
+
body: payload,
|
|
2356
|
+
requiresScope: false,
|
|
2357
|
+
}),
|
|
2358
|
+
get: (path, query) => this.request('GET', `/api/v1/store/${normalizeSubpath(path)}`, {
|
|
2359
|
+
query,
|
|
2360
|
+
requiresScope: false,
|
|
2361
|
+
}),
|
|
2362
|
+
delete: (path) => this.request('DELETE', `/api/v1/store/${normalizeSubpath(path)}`, {
|
|
2363
|
+
requiresScope: false,
|
|
2364
|
+
}),
|
|
2365
|
+
/** Public URL of an object in a public bucket (no token needed; safe to put in <img src>). */
|
|
2366
|
+
publicUrl: (bucket, objectPath, projectId) => {
|
|
2367
|
+
const pid = projectId ?? this.scope?.projectId;
|
|
2368
|
+
if (!pid)
|
|
2369
|
+
throw SovariaError.config('publicUrl() needs a projectId or setScope() first');
|
|
2370
|
+
const path = objectPath.split('/').map((p) => encodeURIComponent(p)).join('/');
|
|
2371
|
+
return `${this.baseUrl}/api/v1/store/public/${encodeURIComponent(pid)}/${encodeURIComponent(bucket)}/${path}`;
|
|
2372
|
+
},
|
|
2373
|
+
listBuckets: () => this.request('GET', '/api/v1/store/buckets', { requiresScope: true }),
|
|
2374
|
+
createBucket: (body) => this.request('POST', '/api/v1/store/buckets', { body, requiresScope: true }),
|
|
2375
|
+
getBucket: (name) => this.request('GET', `/api/v1/store/buckets/${safePathSegment(name)}`, {
|
|
2376
|
+
requiresScope: true,
|
|
2377
|
+
}),
|
|
2378
|
+
updateBucket: (name, body) => this.request('PATCH', `/api/v1/store/buckets/${safePathSegment(name)}`, {
|
|
2379
|
+
body,
|
|
2380
|
+
requiresScope: true,
|
|
2381
|
+
}),
|
|
2382
|
+
deleteBucket: (name) => this.request('DELETE', `/api/v1/store/buckets/${safePathSegment(name)}`, {
|
|
2383
|
+
requiresScope: true,
|
|
2384
|
+
}),
|
|
2385
|
+
listObjects: (bucket, query) => this.request('GET', `/api/v1/store/objects/${safePathSegment(bucket)}`, {
|
|
2386
|
+
query,
|
|
2387
|
+
requiresScope: true,
|
|
2388
|
+
}),
|
|
2389
|
+
signDownload: (bucket, objectPath, expiresIn = 3600) => this.request('POST', `/api/v1/store/objects/${safePathSegment(bucket)}/${objectPath
|
|
2390
|
+
.split('/')
|
|
2391
|
+
.map((p) => safePathSegment(p))
|
|
2392
|
+
.join('/')}/sign-download`, { body: { expires_in: expiresIn }, requiresScope: true }),
|
|
2393
|
+
/**
|
|
2394
|
+
* Signed upload URL. Pass `sizeBytes` (the file's exact size) to get a direct-to-storage URL bound
|
|
2395
|
+
* to that size; without it the URL uploads through Sovaria, which checks the size limits as the
|
|
2396
|
+
* bytes arrive.
|
|
2397
|
+
*/
|
|
2398
|
+
signUpload: (bucket, objectPath, expiresIn = 3600, options = {}) => this.request('POST', `/api/v1/store/objects/${safePathSegment(bucket)}/${objectPath
|
|
2399
|
+
.split('/')
|
|
2400
|
+
.map((p) => safePathSegment(p))
|
|
2401
|
+
.join('/')}/sign-upload`, {
|
|
2402
|
+
body: {
|
|
2403
|
+
expires_in: expiresIn,
|
|
2404
|
+
...(options.sizeBytes !== undefined ? { size_bytes: options.sizeBytes } : {}),
|
|
2405
|
+
...(options.contentType ? { content_type: options.contentType } : {}),
|
|
2406
|
+
},
|
|
2407
|
+
requiresScope: true,
|
|
2408
|
+
}),
|
|
2409
|
+
moveObject: (body) => this.request('POST', '/api/v1/store/objects/move', { body, requiresScope: true }),
|
|
2410
|
+
/**
|
|
2411
|
+
* Upload a file. Bucket rules decide who may write where — e.g. with the "Own folder" rule a
|
|
2412
|
+
* user writes to `${userId}/…`. Pass `upsert: true` to replace an existing object.
|
|
2413
|
+
*/
|
|
2414
|
+
upload: (bucket, objectPath, file, options = {}) => {
|
|
2415
|
+
const form = new FormData();
|
|
2416
|
+
const name = objectPath.split('/').pop() || 'file';
|
|
2417
|
+
// Without a type the server would store application/octet-stream; guess from the extension.
|
|
2418
|
+
const type = options.contentType || file.type || guessContentType(name);
|
|
2419
|
+
const blob = type && type !== file.type ? new Blob([file], { type }) : file;
|
|
2420
|
+
form.append('file', blob, name);
|
|
2421
|
+
return this.request('POST', `/api/v1/store/objects/${storageObjectPath(bucket, objectPath)}`, {
|
|
2422
|
+
body: form,
|
|
2423
|
+
query: options.upsert ? { upsert: true } : undefined,
|
|
2424
|
+
requiresScope: true,
|
|
2425
|
+
});
|
|
2426
|
+
},
|
|
2427
|
+
/** Download an object the caller may read, as a Blob. */
|
|
2428
|
+
download: (bucket, objectPath) => this.request('GET', `/api/v1/store/objects/${storageObjectPath(bucket, objectPath)}`, {
|
|
2429
|
+
requiresScope: true,
|
|
2430
|
+
responseType: 'blob',
|
|
2431
|
+
}),
|
|
2432
|
+
/** Delete an object. */
|
|
2433
|
+
remove: (bucket, objectPath) => this.request('DELETE', `/api/v1/store/objects/${storageObjectPath(bucket, objectPath)}`, {
|
|
2434
|
+
requiresScope: true,
|
|
2435
|
+
}),
|
|
2436
|
+
};
|
|
2437
|
+
/**
|
|
2438
|
+
* Compliance center (organization owners and admins): the evidence pack for your own audits. It is evidence of
|
|
2439
|
+
* how Sovaria is configured for your organization, not a certification.
|
|
2440
|
+
*/
|
|
2441
|
+
compliance = {
|
|
2442
|
+
/** Audit-log check, encryption and own-key status, data regions, per-project backups/PITR/Security Advisor, access. */
|
|
2443
|
+
overview: (orgId, options = {}) => this.request('GET', `/api/v1/org/${safePathSegment(this.complianceOrg(orgId))}/compliance`, {
|
|
2444
|
+
query: options.projectId ? { project_id: options.projectId } : undefined,
|
|
2445
|
+
}),
|
|
2446
|
+
/** The evidence pack as a ZIP Blob (MANIFEST.json holds every file's SHA-256). Counted per day; audited. */
|
|
2447
|
+
downloadEvidence: (orgId, options = {}) => this.request('GET', `/api/v1/org/${safePathSegment(this.complianceOrg(orgId))}/compliance/evidence`, {
|
|
2448
|
+
query: options.projectId ? { project_id: options.projectId } : undefined,
|
|
2449
|
+
responseType: 'blob',
|
|
2450
|
+
}),
|
|
2451
|
+
};
|
|
2452
|
+
complianceOrg(orgId) {
|
|
2453
|
+
const org = String(orgId ?? this.scope?.orgId ?? '').trim();
|
|
2454
|
+
if (!org)
|
|
2455
|
+
throw SovariaError.config('Pass an organization id or call setScope({ orgId, projectId }) first.');
|
|
2456
|
+
return org;
|
|
2457
|
+
}
|
|
2458
|
+
system = {
|
|
2459
|
+
enginesHealth: () => this.request('GET', '/api/v1/system/engines/health', {
|
|
2460
|
+
requiresAuth: false,
|
|
2461
|
+
}),
|
|
2462
|
+
};
|
|
2463
|
+
/**
|
|
2464
|
+
* Billing + plan management (Ledger engine).
|
|
2465
|
+
*
|
|
2466
|
+
* The plan catalog is public; subscription and usage routes require a
|
|
2467
|
+
* valid auth token. Subscription writes require the caller to be an
|
|
2468
|
+
* org admin.
|
|
2469
|
+
*/
|
|
2470
|
+
billing = {
|
|
2471
|
+
listPlans: () => this.request('GET', '/api/v1/plans', { requiresAuth: false }),
|
|
2472
|
+
getPlan: (name) => this.request('GET', `/api/v1/plans/${safePathSegment(name)}`, {
|
|
2473
|
+
requiresAuth: false,
|
|
2474
|
+
}),
|
|
2475
|
+
getSubscription: (orgId) => this.request('GET', `/api/v1/subscriptions/${safePathSegment(orgId)}`, { requiresScope: false }),
|
|
2476
|
+
setSubscription: (orgId, payload) => this.request('PUT', `/api/v1/subscriptions/${safePathSegment(orgId)}`, {
|
|
2477
|
+
body: {
|
|
2478
|
+
plan: payload.plan,
|
|
2479
|
+
billing_cycle: payload.billingCycle ?? 'monthly',
|
|
2480
|
+
...(payload.stripeCustomerId
|
|
2481
|
+
? { stripe_customer_id: payload.stripeCustomerId }
|
|
2482
|
+
: {}),
|
|
2483
|
+
metadata: payload.metadata ?? {},
|
|
2484
|
+
},
|
|
2485
|
+
requiresScope: false,
|
|
2486
|
+
}),
|
|
2487
|
+
cancelSubscription: (orgId, options = {}) => this.request('DELETE', `/api/v1/subscriptions/${safePathSegment(orgId)}`, {
|
|
2488
|
+
query: options.immediate ? { immediate: true } : undefined,
|
|
2489
|
+
requiresScope: false,
|
|
2490
|
+
}),
|
|
2491
|
+
listSubscriptions: () => this.request('GET', '/api/v1/subscriptions', { requiresScope: false }),
|
|
2492
|
+
getUsage: (orgId, period) => this.request('GET', `/api/v1/usage/${safePathSegment(orgId)}`, {
|
|
2493
|
+
query: period ? { period } : undefined,
|
|
2494
|
+
requiresScope: false,
|
|
2495
|
+
}),
|
|
2496
|
+
getUsageHistory: (orgId, months = 6) => this.request('GET', `/api/v1/usage/${safePathSegment(orgId)}/history`, {
|
|
2497
|
+
query: { months },
|
|
2498
|
+
requiresScope: false,
|
|
2499
|
+
}),
|
|
2500
|
+
listInvoices: (orgId) => this.request('GET', `/api/v1/invoices/${safePathSegment(orgId)}`),
|
|
2501
|
+
buildInvoice: (orgId, period) => this.request('POST', `/api/v1/invoices/${safePathSegment(orgId)}/build`, {
|
|
2502
|
+
query: period ? { period } : undefined,
|
|
2503
|
+
}),
|
|
2504
|
+
getCompute: (orgId, period) => this.request('GET', `/api/v1/compute/${safePathSegment(orgId)}`, {
|
|
2505
|
+
query: period ? { period } : undefined,
|
|
2506
|
+
}),
|
|
2507
|
+
checkout: (body) => this.request('POST', '/api/v1/billing/checkout', { body }),
|
|
2508
|
+
getProvider: () => this.request('GET', '/api/v1/billing/provider'),
|
|
2509
|
+
getFx: () => this.request('GET', '/api/v1/billing/fx'),
|
|
2510
|
+
getOverage: (orgId) => this.request('GET', `/api/v1/billing/overage/${safePathSegment(orgId)}`),
|
|
2511
|
+
getOpenInvoice: () => this.request('GET', '/api/v1/billing/invoices/open'),
|
|
2512
|
+
payInvoice: (orgId, period, body = {}) => this.request('POST', `/api/v1/billing/invoices/${safePathSegment(orgId)}/${safePathSegment(period)}/pay`, { body }),
|
|
2513
|
+
razorpayCreateOrder: (body) => this.request('POST', '/api/v1/billing/razorpay/create-order', { body }),
|
|
2514
|
+
razorpayVerifyPayment: (body) => this.request('POST', '/api/v1/billing/razorpay/verify-payment', { body }),
|
|
2515
|
+
getAccount: (accountId) => this.request('GET', `/api/v1/accounts/${safePathSegment(accountId)}`),
|
|
2516
|
+
cancelAccount: (accountId) => this.request('POST', `/api/v1/accounts/${safePathSegment(accountId)}/cancel`, {
|
|
2517
|
+
body: {},
|
|
2518
|
+
}),
|
|
2519
|
+
resumeAccount: (accountId) => this.request('POST', `/api/v1/accounts/${safePathSegment(accountId)}/resume`, {
|
|
2520
|
+
body: {},
|
|
2521
|
+
}),
|
|
2522
|
+
/** Add-ons on the billing account (price, paid units, what renews, paid-through) and the autopay mandate. */
|
|
2523
|
+
listAddons: (accountId) => this.request('GET', `/api/v1/accounts/${safePathSegment(accountId)}/addons`),
|
|
2524
|
+
/**
|
|
2525
|
+
* Start paying for `quantity` more units of an add-on (`extra_project`, `custom_domains`,
|
|
2526
|
+
* `extra_environment`), prorated to the period end. Returns a Razorpay order to open in Checkout;
|
|
2527
|
+
* the units count once the payment is verified (`razorpayVerifyPayment`). Uses the scoped org.
|
|
2528
|
+
*/
|
|
2529
|
+
addonCheckout: (accountId, addonId, quantity = 1, projectId) => this.request('POST', `/api/v1/accounts/${safePathSegment(accountId)}/addons/${safePathSegment(addonId)}/checkout`, { body: projectId ? { quantity, project_id: projectId } : { quantity } }),
|
|
2530
|
+
/** How many units renew at the period end (0 cancels). Units already paid for keep working until then. */
|
|
2531
|
+
setAddonRenewal: (accountId, addonId, renewQuantity) => this.request('PUT', `/api/v1/accounts/${safePathSegment(accountId)}/addons/${safePathSegment(addonId)}/renewal`, { body: { renew_quantity: renewQuantity } }),
|
|
2532
|
+
/** ₹1 (refunded) authorisation that saves a card or UPI mandate; open it in Checkout with `recurring: '1'`. */
|
|
2533
|
+
autopaySetup: (accountId, body = {}) => this.request('POST', `/api/v1/accounts/${safePathSegment(accountId)}/autopay/setup`, { body }),
|
|
2534
|
+
/** Remove the saved mandate; renewals become invoices to pay by hand. */
|
|
2535
|
+
removeAutopayMandate: (accountId) => this.request('DELETE', `/api/v1/accounts/${safePathSegment(accountId)}/autopay/mandate`),
|
|
2536
|
+
};
|
|
2537
|
+
/** The complete API, generated from spec/sovaria-api.json (same surface in every SDK language). */
|
|
2538
|
+
api = createGeneratedApi((method, path, options) => this.request(method, path, options), () => this.scope);
|
|
2539
|
+
keys = createKeysApi(this.request.bind(this));
|
|
2540
|
+
governance = createGovernanceApi(this.request.bind(this));
|
|
2541
|
+
admin = createAdminApi(this.request.bind(this));
|
|
2542
|
+
/**
|
|
2543
|
+
* Vector / embeddings (Nexus engine). `embed` works anywhere (in functions too, with the service key).
|
|
2544
|
+
*
|
|
2545
|
+
* **Collections are the legacy vector store** (capped at `ai.collection_max_records` per collection). For
|
|
2546
|
+
* large or row-secured data use SQL vectors (`sov.api.vector.sqlSearch`, `sqlCreateIndex`, automatic
|
|
2547
|
+
* embeddings) or knowledge bases (`sov.knowledge`).
|
|
2548
|
+
*
|
|
2549
|
+
* All routes are project-scoped via the standard `X-Sovaria-Project-ID`
|
|
2550
|
+
* header so call `setScope({ orgId, projectId })` first.
|
|
2551
|
+
*/
|
|
2552
|
+
vector = {
|
|
2553
|
+
embed: (texts) => this.request('POST', '/api/v1/embed', { body: { texts } }),
|
|
2554
|
+
upsert: (collection, docs) => this.request('POST', '/api/v1/vectors/upsert', {
|
|
2555
|
+
body: { collection, docs },
|
|
2556
|
+
}),
|
|
2557
|
+
/**
|
|
2558
|
+
* Search a collection. `mode`: `vector` (meaning), `keyword` (the words, BM25) or `hybrid` (both,
|
|
2559
|
+
* fused); the default is vector with a real embedding model and hybrid with the local hash model.
|
|
2560
|
+
* Apps (anon key, signed-in users) may only search collections opened with `setCollection(name, { appSearch: true })`.
|
|
2561
|
+
*/
|
|
2562
|
+
search: (collection, query, options = {}) => this.request('POST', '/api/v1/vectors/search', {
|
|
2563
|
+
body: {
|
|
2564
|
+
collection,
|
|
2565
|
+
query,
|
|
2566
|
+
top_k: options.topK ?? 5,
|
|
2567
|
+
metadata_filter: options.metadataFilter ?? {},
|
|
2568
|
+
...(options.mode ? { mode: options.mode } : {}),
|
|
2569
|
+
},
|
|
2570
|
+
}),
|
|
2571
|
+
/** Collection settings (team / service key): `appSearch` lets the project's apps search it. */
|
|
2572
|
+
setCollection: (collection, settings) => this.request('PATCH', `/api/v1/collections/${safePathSegment(collection)}`, {
|
|
2573
|
+
body: { ...(settings.appSearch !== undefined ? { app_search: settings.appSearch } : {}) },
|
|
2574
|
+
}),
|
|
2575
|
+
listCollections: () => this.request('GET', '/api/v1/collections'),
|
|
2576
|
+
listRecords: (collection, options = {}) => this.request('GET', `/api/v1/collections/${safePathSegment(collection)}/records`, {
|
|
2577
|
+
query: {
|
|
2578
|
+
limit: options.limit,
|
|
2579
|
+
offset: options.offset,
|
|
2580
|
+
},
|
|
2581
|
+
}),
|
|
2582
|
+
deleteRecord: (collection, recordId) => this.request('DELETE', `/api/v1/collections/${safePathSegment(collection)}/records/${safePathSegment(recordId)}`),
|
|
2583
|
+
deleteCollection: (collection) => this.request('DELETE', `/api/v1/collections/${safePathSegment(collection)}`),
|
|
2584
|
+
};
|
|
2585
|
+
/**
|
|
2586
|
+
* Knowledge bases (RAG) on the project's own Postgres: add documents (text, files, web pages), then search
|
|
2587
|
+
* them with citations. Searches only return passages the signed-in user may read.
|
|
2588
|
+
*
|
|
2589
|
+
* ```ts
|
|
2590
|
+
* await sov.knowledge.addFile('support_docs', file) // a File / Blob in the browser
|
|
2591
|
+
* const { results, citations } = await sov.knowledge.search('support_docs', 'refund window')
|
|
2592
|
+
* ```
|
|
2593
|
+
*/
|
|
2594
|
+
knowledge = {
|
|
2595
|
+
list: () => this.request('GET', KNOWLEDGE_PATH),
|
|
2596
|
+
create: (name, options = {}) => this.request('POST', KNOWLEDGE_PATH, {
|
|
2597
|
+
body: {
|
|
2598
|
+
name,
|
|
2599
|
+
...(options.description !== undefined ? { description: options.description } : {}),
|
|
2600
|
+
...(options.access ? { access: options.access } : {}),
|
|
2601
|
+
...(options.userUploads !== undefined ? { user_uploads: options.userUploads } : {}),
|
|
2602
|
+
...(options.chunkTokens ? { chunk_tokens: options.chunkTokens } : {}),
|
|
2603
|
+
},
|
|
2604
|
+
}),
|
|
2605
|
+
addText: (name, text, options = {}) => this.request('POST', `${KNOWLEDGE_PATH}/${safePathSegment(name)}/documents`, {
|
|
2606
|
+
body: { text, format: options.format ?? 'markdown', ...knowledgeDocumentBody(options) },
|
|
2607
|
+
}),
|
|
2608
|
+
/** A web page. `refreshHours` (team, paid plans) fetches it again on a schedule; only changed parts are re-embedded. */
|
|
2609
|
+
addUrl: (name, url, options = {}) => this.request('POST', `${KNOWLEDGE_PATH}/${safePathSegment(name)}/documents`, {
|
|
2610
|
+
body: { url, ...knowledgeDocumentBody(options), ...(options.refreshHours ? { refresh_hours: options.refreshHours } : {}) },
|
|
2611
|
+
}),
|
|
2612
|
+
/** URL documents fetched again on a schedule (team). */
|
|
2613
|
+
sources: (name) => this.request('GET', `${KNOWLEDGE_PATH}/${safePathSegment(name)}/sources`),
|
|
2614
|
+
/** A PDF, DOCX, HTML, Markdown, TXT, CSV or JSON file (up to 7 MB): a `File`/`Blob`, or bytes with `filename`. */
|
|
2615
|
+
addFile: async (name, file, options = {}) => {
|
|
2616
|
+
const named = file.name;
|
|
2617
|
+
const filename = options.filename ?? (typeof named === 'string' ? named : '');
|
|
2618
|
+
if (!filename)
|
|
2619
|
+
throw SovariaError.config('Give the file name (options.filename) so its format is known');
|
|
2620
|
+
const bytes = file instanceof Uint8Array ? file
|
|
2621
|
+
: file instanceof ArrayBuffer ? new Uint8Array(file)
|
|
2622
|
+
: new Uint8Array(await file.arrayBuffer());
|
|
2623
|
+
return this.request('POST', `${KNOWLEDGE_PATH}/${safePathSegment(name)}/documents`, {
|
|
2624
|
+
body: {
|
|
2625
|
+
content_base64: bytesToBase64(bytes), filename,
|
|
2626
|
+
...(file.type ? { content_type: file.type } : {}),
|
|
2627
|
+
...knowledgeDocumentBody(options),
|
|
2628
|
+
},
|
|
2629
|
+
});
|
|
2630
|
+
},
|
|
2631
|
+
documents: (name, options = {}) => this.request('GET', `${KNOWLEDGE_PATH}/${safePathSegment(name)}/documents`, {
|
|
2632
|
+
query: { limit: options.limit, offset: options.offset },
|
|
2633
|
+
}),
|
|
2634
|
+
deleteDocument: (name, documentId) => this.request('DELETE', `${KNOWLEDGE_PATH}/${safePathSegment(name)}/documents/${safePathSegment(documentId)}`),
|
|
2635
|
+
/**
|
|
2636
|
+
* Passages for a question. `documentIds` (up to 50) scopes the search; `rerank` reorders the passages with
|
|
2637
|
+
* the managed reranker (counts on `ai.rerank_month`; `reranked: false` when none is configured).
|
|
2638
|
+
*/
|
|
2639
|
+
search: (name, query, options = {}) => this.request('POST', `${KNOWLEDGE_PATH}/${safePathSegment(name)}/search`, {
|
|
2640
|
+
body: {
|
|
2641
|
+
query,
|
|
2642
|
+
...(options.k ? { k: options.k } : {}),
|
|
2643
|
+
...(options.mode ? { mode: options.mode } : {}),
|
|
2644
|
+
...(options.metadataFilter ? { metadata_filter: options.metadataFilter } : {}),
|
|
2645
|
+
...(options.documentIds?.length ? { document_ids: options.documentIds } : {}),
|
|
2646
|
+
...(options.rerank ? { rerank: true } : {}),
|
|
2647
|
+
},
|
|
2648
|
+
}),
|
|
2649
|
+
};
|
|
2650
|
+
/**
|
|
2651
|
+
* Your project's agents, from your app. The agent acts with the signed-in user's own permissions (and an
|
|
2652
|
+
* agent with knowledge bases only retrieves passages that user may read).
|
|
2653
|
+
*
|
|
2654
|
+
* ```ts
|
|
2655
|
+
* const reply = await sov.agents.run(agentId, { message: 'Where is my order?', onEvent: (e) => render(e) })
|
|
2656
|
+
* await sov.agents.run(agentId, { message: 'And the invoice?', threadId: reply.threadId })
|
|
2657
|
+
* ```
|
|
2658
|
+
*/
|
|
2659
|
+
agents = {
|
|
2660
|
+
/** Send a message (a new thread, or `threadId` to continue one) and wait for the answer; `onEvent` streams it. */
|
|
2661
|
+
run: async (agentId, input) => {
|
|
2662
|
+
const events = [];
|
|
2663
|
+
for await (const event of this.agents.stream(agentId, input)) {
|
|
2664
|
+
events.push(event);
|
|
2665
|
+
input.onEvent?.(event);
|
|
2666
|
+
}
|
|
2667
|
+
return agentResult(events);
|
|
2668
|
+
},
|
|
2669
|
+
/** The same as `run`, as an async iterator of events (`conversation`, `text_delta`, `text`, `tool_call`, …, `done`). */
|
|
2670
|
+
stream: (agentId, input) => this.streamEvents(`/api/v1/sovariq/agents/${safePathSegment(agentId)}/runs`, { message: input.message, ...(input.threadId ? { thread_id: input.threadId } : {}) }, input.signal),
|
|
2671
|
+
/** The signed-in user's threads with this agent, newest first. */
|
|
2672
|
+
threads: (agentId) => this.request('GET', `/api/v1/sovariq/agents/${safePathSegment(agentId)}/runs`)
|
|
2673
|
+
.then((out) => out.runs ?? []),
|
|
2674
|
+
/** One thread with its events (messages, tool calls, answers). */
|
|
2675
|
+
thread: (threadId) => this.request('GET', `/api/v1/sovariq/conversations/${safePathSegment(threadId)}`),
|
|
2676
|
+
/** Stop the agent's current run in this thread. */
|
|
2677
|
+
cancel: (threadId) => this.request('POST', `/api/v1/sovariq/conversations/${safePathSegment(threadId)}/cancel`, { body: {} }),
|
|
2678
|
+
};
|
|
2679
|
+
/** POST a JSON body and read the server-sent events it streams back (one JSON object per `data:` line). */
|
|
2680
|
+
async *streamEvents(path, body, signal) {
|
|
2681
|
+
if (!this.accessToken) {
|
|
2682
|
+
throw new SovariaError(401, 'Missing access token; sign the user in first');
|
|
2683
|
+
}
|
|
2684
|
+
await this.ensureAccessTokenFresh();
|
|
2685
|
+
if (!this.scope)
|
|
2686
|
+
await this.resolveScope();
|
|
2687
|
+
this.assertScopePresent();
|
|
2688
|
+
const url = `${this.baseUrl}${path}`;
|
|
2689
|
+
const headers = {
|
|
2690
|
+
...this.getScopeHeaders(),
|
|
2691
|
+
...(await this.clientHeaders()),
|
|
2692
|
+
'Content-Type': 'application/json',
|
|
2693
|
+
Accept: 'text/event-stream',
|
|
2694
|
+
traceparent: traceparentFor(newTraceId()),
|
|
2695
|
+
};
|
|
2696
|
+
let response;
|
|
2697
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2698
|
+
headers.Authorization = `Bearer ${this.accessToken}`;
|
|
2699
|
+
response = await this.fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
|
|
2700
|
+
if (response.status === 401 && this.refreshToken && attempt === 0) {
|
|
2701
|
+
await this.ensureAccessTokenFresh(true);
|
|
2702
|
+
continue;
|
|
2703
|
+
}
|
|
2704
|
+
break;
|
|
2705
|
+
}
|
|
2706
|
+
if (!response || !response.ok) {
|
|
2707
|
+
const payload = response ? await response.json().catch(() => ({})) : {};
|
|
2708
|
+
throw new SovariaError(response?.status ?? 0, payload);
|
|
2709
|
+
}
|
|
2710
|
+
yield* parseEventStream(response);
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
const KNOWLEDGE_PATH = '/api/v1/data/rel/vector/knowledge';
|
|
2714
|
+
function knowledgeDocumentBody(options) {
|
|
2715
|
+
return {
|
|
2716
|
+
...(options.documentId ? { document_id: options.documentId } : {}),
|
|
2717
|
+
...(options.title ? { title: options.title } : {}),
|
|
2718
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
function bytesToBase64(bytes) {
|
|
2722
|
+
const g = globalThis;
|
|
2723
|
+
if (g.Buffer)
|
|
2724
|
+
return g.Buffer.from(bytes).toString('base64');
|
|
2725
|
+
let binary = '';
|
|
2726
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
2727
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
2728
|
+
}
|
|
2729
|
+
return g.btoa(binary);
|
|
2730
|
+
}
|
|
2731
|
+
/** Server-sent events → JSON objects. Streams when the runtime gives a readable body, else reads it whole. */
|
|
2732
|
+
export async function* parseEventStream(response) {
|
|
2733
|
+
const parse = (block) => {
|
|
2734
|
+
const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
|
|
2735
|
+
if (!data)
|
|
2736
|
+
return null;
|
|
2737
|
+
try {
|
|
2738
|
+
const value = JSON.parse(data);
|
|
2739
|
+
return value && typeof value === 'object' ? value : null;
|
|
2740
|
+
}
|
|
2741
|
+
catch {
|
|
2742
|
+
return null;
|
|
2743
|
+
}
|
|
2744
|
+
};
|
|
2745
|
+
const reader = response.body?.getReader?.();
|
|
2746
|
+
if (!reader) {
|
|
2747
|
+
for (const block of (await response.text()).split(/\r?\n\r?\n/)) {
|
|
2748
|
+
const event = parse(block);
|
|
2749
|
+
if (event)
|
|
2750
|
+
yield event;
|
|
2751
|
+
}
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
const decoder = new TextDecoder();
|
|
2755
|
+
let buffer = '';
|
|
2756
|
+
for (;;) {
|
|
2757
|
+
const { value, done } = await reader.read();
|
|
2758
|
+
if (done)
|
|
2759
|
+
break;
|
|
2760
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2761
|
+
let cut = buffer.search(/\r?\n\r?\n/);
|
|
2762
|
+
while (cut >= 0) {
|
|
2763
|
+
const block = buffer.slice(0, cut);
|
|
2764
|
+
buffer = buffer.slice(buffer.slice(cut).match(/^\r?\n\r?\n/)[0].length + cut);
|
|
2765
|
+
const event = parse(block);
|
|
2766
|
+
if (event)
|
|
2767
|
+
yield event;
|
|
2768
|
+
cut = buffer.search(/\r?\n\r?\n/);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
const last = parse(buffer + decoder.decode());
|
|
2772
|
+
if (last)
|
|
2773
|
+
yield last;
|
|
2774
|
+
}
|
|
2775
|
+
function agentResult(events) {
|
|
2776
|
+
const conversation = events.find((e) => e.type === 'conversation');
|
|
2777
|
+
const done = [...events].reverse().find((e) => e.type === 'done');
|
|
2778
|
+
const finals = events.filter((e) => e.type === 'text' && typeof e.text === 'string').map((e) => e.text);
|
|
2779
|
+
const deltas = events.filter((e) => e.type === 'text_delta' && typeof e.text === 'string').map((e) => e.text);
|
|
2780
|
+
return {
|
|
2781
|
+
threadId: String(conversation?.id ?? ''),
|
|
2782
|
+
text: (finals.length ? finals.join('\n\n') : deltas.join('')).trim(),
|
|
2783
|
+
status: String(done?.status ?? (events.some((e) => e.type === 'error') ? 'error' : 'unknown')),
|
|
2784
|
+
usage: done?.usage ?? undefined,
|
|
2785
|
+
events,
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
const CONTENT_TYPES = {
|
|
2789
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif',
|
|
2790
|
+
svg: 'image/svg+xml', ico: 'image/x-icon', pdf: 'application/pdf', json: 'application/json', txt: 'text/plain',
|
|
2791
|
+
csv: 'text/csv', md: 'text/markdown', html: 'text/html', css: 'text/css', js: 'text/javascript',
|
|
2792
|
+
mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', zip: 'application/zip',
|
|
2793
|
+
};
|
|
2794
|
+
function guessContentType(name) {
|
|
2795
|
+
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
|
2796
|
+
return CONTENT_TYPES[ext] ?? '';
|
|
2797
|
+
}
|
|
2798
|
+
function openRealtimeChannel(sdk, name, handlers) {
|
|
2799
|
+
const WS = globalThis.WebSocket;
|
|
2800
|
+
let status = 'connecting';
|
|
2801
|
+
let socket = null;
|
|
2802
|
+
let closed = false;
|
|
2803
|
+
let attempt = 0;
|
|
2804
|
+
let timer;
|
|
2805
|
+
const setStatus = (next) => {
|
|
2806
|
+
if (status === next)
|
|
2807
|
+
return;
|
|
2808
|
+
status = next;
|
|
2809
|
+
handlers.onStatus?.(next);
|
|
2810
|
+
};
|
|
2811
|
+
// Catch-up after a reconnect: the last stream_seq delivered, and live frames held back while the
|
|
2812
|
+
// missed ones are fetched (so the app sees every message once, in order).
|
|
2813
|
+
let lastSeq = null;
|
|
2814
|
+
let held = null;
|
|
2815
|
+
let generation = 0;
|
|
2816
|
+
const deliver = (frame) => {
|
|
2817
|
+
const seq = typeof frame.stream_seq === 'number' ? frame.stream_seq : null;
|
|
2818
|
+
if (seq !== null) {
|
|
2819
|
+
if (lastSeq !== null && seq <= lastSeq)
|
|
2820
|
+
return; // already delivered (live and replayed overlap)
|
|
2821
|
+
lastSeq = seq;
|
|
2822
|
+
}
|
|
2823
|
+
const payload = (frame.payload ?? frame.data ?? frame);
|
|
2824
|
+
try {
|
|
2825
|
+
handlers.onMessage?.(payload, frame);
|
|
2826
|
+
}
|
|
2827
|
+
catch (error) {
|
|
2828
|
+
console.error('SovariaSDK: realtime onMessage handler failed', error);
|
|
2829
|
+
}
|
|
2830
|
+
};
|
|
2831
|
+
const catchUp = async (fromSeq, gen) => {
|
|
2832
|
+
const pageSize = 500;
|
|
2833
|
+
try {
|
|
2834
|
+
for (let page = 0; page < 20; page += 1) {
|
|
2835
|
+
const res = await sdk.realtime.replay(name, lastSeq ?? fromSeq, pageSize);
|
|
2836
|
+
if (closed || gen !== generation)
|
|
2837
|
+
return;
|
|
2838
|
+
const events = Array.isArray(res.events) ? res.events : [];
|
|
2839
|
+
for (const event of events) {
|
|
2840
|
+
if (event.type === 'gap') {
|
|
2841
|
+
const toSeq = Number(event.to_seq);
|
|
2842
|
+
handlers.onGap?.({ fromSeq: Number(event.from_seq), toSeq, reason: 'not_retained' });
|
|
2843
|
+
if (lastSeq === null || toSeq > lastSeq)
|
|
2844
|
+
lastSeq = toSeq;
|
|
2845
|
+
continue;
|
|
2846
|
+
}
|
|
2847
|
+
deliver(event);
|
|
2848
|
+
}
|
|
2849
|
+
if (events.length < pageSize)
|
|
2850
|
+
break;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
catch (error) {
|
|
2854
|
+
handlers.onError?.(error);
|
|
2855
|
+
if (!closed && gen === generation)
|
|
2856
|
+
handlers.onGap?.({ fromSeq: fromSeq + 1, reason: 'replay_failed' });
|
|
2857
|
+
}
|
|
2858
|
+
finally {
|
|
2859
|
+
if (gen === generation) {
|
|
2860
|
+
const pending = held ?? [];
|
|
2861
|
+
held = null;
|
|
2862
|
+
for (const frame of pending)
|
|
2863
|
+
deliver(frame);
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
};
|
|
2867
|
+
const schedule = () => {
|
|
2868
|
+
if (closed)
|
|
2869
|
+
return;
|
|
2870
|
+
setStatus('reconnecting');
|
|
2871
|
+
const cap = handlers.maxBackoffMs ?? 15_000;
|
|
2872
|
+
const delay = Math.min(cap, 500 * 2 ** Math.min(attempt, 6)) * (0.75 + Math.random() * 0.5);
|
|
2873
|
+
attempt += 1;
|
|
2874
|
+
timer = setTimeout(() => void connect(), delay);
|
|
2875
|
+
};
|
|
2876
|
+
const connect = async () => {
|
|
2877
|
+
if (closed)
|
|
2878
|
+
return;
|
|
2879
|
+
if (!WS) {
|
|
2880
|
+
handlers.onError?.(SovariaError.config('No WebSocket implementation available in this runtime.'));
|
|
2881
|
+
setStatus('closed');
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
try {
|
|
2885
|
+
const url = await sdk.realtime.wsUrlWithTicket(name);
|
|
2886
|
+
if (closed)
|
|
2887
|
+
return;
|
|
2888
|
+
const ws = new WS(url);
|
|
2889
|
+
socket = ws;
|
|
2890
|
+
const gen = ++generation;
|
|
2891
|
+
held = null;
|
|
2892
|
+
ws.onopen = () => {
|
|
2893
|
+
attempt = 0;
|
|
2894
|
+
setStatus('live');
|
|
2895
|
+
// A reconnect (we had seen messages before): fetch what was published while we were away.
|
|
2896
|
+
if (lastSeq !== null && handlers.catchUp !== false) {
|
|
2897
|
+
held = [];
|
|
2898
|
+
void catchUp(lastSeq, gen);
|
|
2899
|
+
}
|
|
2900
|
+
};
|
|
2901
|
+
ws.onmessage = (event) => {
|
|
2902
|
+
let frame;
|
|
2903
|
+
try {
|
|
2904
|
+
frame = JSON.parse(String(event.data));
|
|
2905
|
+
}
|
|
2906
|
+
catch {
|
|
2907
|
+
return; // pings and other non-JSON frames
|
|
2908
|
+
}
|
|
2909
|
+
if (frame.type === 'ping' || frame.type === 'pong')
|
|
2910
|
+
return;
|
|
2911
|
+
if (held && gen === generation) {
|
|
2912
|
+
held.push(frame); // delivered after the missed messages
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
deliver(frame);
|
|
2916
|
+
};
|
|
2917
|
+
ws.onclose = () => {
|
|
2918
|
+
if (socket === ws)
|
|
2919
|
+
socket = null;
|
|
2920
|
+
schedule();
|
|
2921
|
+
};
|
|
2922
|
+
ws.onerror = (error) => handlers.onError?.(error);
|
|
2923
|
+
}
|
|
2924
|
+
catch (error) {
|
|
2925
|
+
handlers.onError?.(error);
|
|
2926
|
+
// Signed out (session can't be refreshed) or no permission: retrying will not help, and
|
|
2927
|
+
// every retry is a refused ticket request. A new sign-in opens channels again.
|
|
2928
|
+
if (error instanceof SovariaError && (error.code === 'PERMISSION_DENIED' || error.code === 'CONFIG_ERROR' || error.code === 'UNAUTHENTICATED')) {
|
|
2929
|
+
setStatus('closed');
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
schedule();
|
|
2933
|
+
}
|
|
2934
|
+
};
|
|
2935
|
+
handlers.onStatus?.('connecting');
|
|
2936
|
+
void connect();
|
|
2937
|
+
return {
|
|
2938
|
+
name,
|
|
2939
|
+
get status() {
|
|
2940
|
+
return status;
|
|
2941
|
+
},
|
|
2942
|
+
send: (payload) => sdk.realtime.publish(name, payload),
|
|
2943
|
+
close: () => {
|
|
2944
|
+
closed = true;
|
|
2945
|
+
if (timer)
|
|
2946
|
+
clearTimeout(timer);
|
|
2947
|
+
const ws = socket;
|
|
2948
|
+
socket = null;
|
|
2949
|
+
ws?.close();
|
|
2950
|
+
setStatus('closed');
|
|
2951
|
+
},
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
/** Map sovaria.config.json content (written by `sov init` / `sov link`) to SDK bootstrap config. */
|
|
2955
|
+
export function portalConfigFromProjectFile(raw, options = {}) {
|
|
2956
|
+
const ref = String(raw.project?.ref ?? '').trim();
|
|
2957
|
+
const gatewayUrl = String(raw.gateway?.url ?? '').trim() || (ref ? `https://${ref}.sovarias.in` : '');
|
|
2958
|
+
if (!gatewayUrl) {
|
|
2959
|
+
throw SovariaError.config('Missing gateway.url in sovaria.config.json — run `sov link` in your app folder.');
|
|
2960
|
+
}
|
|
2961
|
+
const scope = raw.scope ?? {};
|
|
2962
|
+
const env = globalThis.process?.env;
|
|
2963
|
+
const accessToken = options.accessToken ??
|
|
2964
|
+
options.access_token ??
|
|
2965
|
+
env?.SOVARIA_ACCESS_TOKEN;
|
|
2966
|
+
const publishableKey = options.publishableKey ??
|
|
2967
|
+
options.publishable_key ??
|
|
2968
|
+
options.anonKey ??
|
|
2969
|
+
options.anon_key ??
|
|
2970
|
+
env?.SOVARIA_PUBLISHABLE_KEY ??
|
|
2971
|
+
env?.SOVARIA_ANON_KEY;
|
|
2972
|
+
return {
|
|
2973
|
+
...options,
|
|
2974
|
+
url: gatewayUrl,
|
|
2975
|
+
orgId: String(options.orgId ?? scope.org_id ?? scope.orgId ?? '').trim(),
|
|
2976
|
+
projectId: String(options.projectId ?? scope.project_id ?? scope.projectId ?? '').trim(),
|
|
2977
|
+
envId: String(options.envId ?? scope.env_id ?? scope.envId ?? '').trim(),
|
|
2978
|
+
accessToken: accessToken ? String(accessToken).trim() : undefined,
|
|
2979
|
+
publishableKey: publishableKey ? String(publishableKey).trim() : undefined,
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Create a client from sovaria.config.json. In a bundler, import the file and pass the anon key:
|
|
2984
|
+
* `createClientFromProjectConfig(config, { publishableKey: import.meta.env.VITE_SOVARIA_ANON_KEY })`.
|
|
2985
|
+
*/
|
|
2986
|
+
export function createClientFromProjectConfig(raw, options = {}) {
|
|
2987
|
+
return SovariaSDK.fromPortalConfig(portalConfigFromProjectFile(raw, options));
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Create a client. Either pass the two values from the console's **Connect** panel —
|
|
2991
|
+
* `createClient('https://<ref>.sovarias.in', '<anon-key>')` — or a config object.
|
|
2992
|
+
*/
|
|
2993
|
+
export function createClient(urlOrConfig, key, options = {}) {
|
|
2994
|
+
if (typeof urlOrConfig === 'string') {
|
|
2995
|
+
return SovariaSDK.fromPortalConfig({ ...options, url: urlOrConfig, ...(key ? { publishableKey: key } : {}) });
|
|
2996
|
+
}
|
|
2997
|
+
return SovariaSDK.fromPortalConfig(urlOrConfig);
|
|
2998
|
+
}
|
|
2999
|
+
/** A redirect into an app (custom scheme), which the server only accepts with PKCE. */
|
|
3000
|
+
function isAppRedirect(url) {
|
|
3001
|
+
const match = /^([a-z][a-z0-9+.-]*):/i.exec((url ?? '').trim());
|
|
3002
|
+
return !!match && !['http', 'https'].includes(match[1].toLowerCase());
|
|
3003
|
+
}
|
|
3004
|
+
function randomVerifier() {
|
|
3005
|
+
const bytes = new Uint8Array(32);
|
|
3006
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
3007
|
+
return base64Url(bytes);
|
|
3008
|
+
}
|
|
3009
|
+
async function s256(verifier) {
|
|
3010
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
3011
|
+
return base64Url(new Uint8Array(digest));
|
|
3012
|
+
}
|
|
3013
|
+
function base64Url(bytes) {
|
|
3014
|
+
let bin = '';
|
|
3015
|
+
for (const b of bytes)
|
|
3016
|
+
bin += String.fromCharCode(b);
|
|
3017
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
3018
|
+
}
|