@fenaura/sdk 0.2.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.
@@ -0,0 +1,1665 @@
1
+ /**
2
+ * Fenaura Client SDK
3
+ *
4
+ * A lightweight JavaScript client for Fenaura that provides direct database access,
5
+ * authentication, and storage operations from the browser.
6
+ *
7
+ * Usage:
8
+ * import { createClient } from '@fenaura/client'
9
+ * const db = createClient('https://your-fenaura-url.com', 'your-project-slug')
10
+ *
11
+ * // Database
12
+ * const { data } = await db.from('users').select('*')
13
+ * await db.from('users').insert({ name: 'John' })
14
+ *
15
+ * // Auth
16
+ * await db.auth.signUp({ email: 'john@example.com', password: 'secret' })
17
+ * await db.auth.signIn({ email: 'john@example.com', password: 'secret' })
18
+ *
19
+ * // Storage
20
+ * await db.storage.from('avatars').upload('user1.jpg', file)
21
+ *
22
+ * // Storage with client-side compression (images shrink, text gzips —
23
+ * * fewer bytes leave the device over the same chunked upload)
24
+ * await db.storage.from('avatars').upload('user1.jpg', file, {
25
+ * compress: { mode: 'auto', maxWidth: 1920, quality: 0.8, format: 'webp' },
26
+ * })
27
+ * // Or compress standalone: const r = await compressFile(file, { mode: 'image' })
28
+ */
29
+
30
+ // ─── Types ───────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * @typedef {Object} ClientOptions
34
+ * @property {string} [schema] - Database schema to use
35
+ * @property {number} [timeout] - Request timeout in milliseconds (default: 30000)
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} QueryResult
40
+ * @property {string} status - 'success' or 'error'
41
+ * @property {any} data - The returned data
42
+ * @property {any} [error] - Error details if status is 'error'
43
+ * @property {number} [count] - Total count if count was requested
44
+ * @property {string} request_id - Unique request identifier
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} Filter
49
+ * @property {string} [column] - Column name
50
+ * @property {string} [op] - Operator (eq, neq, gt, gte, lt, lte, like, ilike, in, is, contains, containedBy, overlaps, textSearch)
51
+ * @property {any} [value] - Filter value
52
+ * @property {Filter} [filter] - Inner filter for 'not' operator
53
+ * @property {Filter[]} [filters] - Array of filters for 'or' operator
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} Order
58
+ * @property {string} column - Column to order by
59
+ * @property {boolean} [ascending] - Sort direction (default: true)
60
+ */
61
+
62
+ // ─── Proxy helpers ───────────────────────────────────────────────────────────
63
+ function isProxyBase(url) { return typeof url === 'string' && url.startsWith('/'); }
64
+ function buildFetchOpts(url, apiKey, body, signal) {
65
+ const isProxy = isProxyBase(url);
66
+ const fetchUrl = `${url}/api/v1/data/${apiKey}`;
67
+ const headers = { 'Content-Type': 'application/json' };
68
+ if (!isProxy) {
69
+ const tok = getSessionToken();
70
+ if (!tok) {
71
+ try { console.warn('[fenaura] direct (non-proxy) mode without setSession(token): requests will 401. Call fenaura.setSession(token) or use createClient(\'/fenaura\', id).'); } catch { /* ignore */ }
72
+ }
73
+ headers['Authorization'] = `Bearer ${tok}`;
74
+ }
75
+ const opts = { method: 'POST', headers, body: JSON.stringify(body), signal };
76
+ if (isProxy) opts.credentials = 'include';
77
+ return { fetchUrl, opts };
78
+ }
79
+
80
+ // ─── Validation Helpers ──────────────────────────────────────────────────────
81
+
82
+ const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
83
+ const COLUMN_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
84
+
85
+ function isPathSafe(path) {
86
+ // B14: mirror the server's validate_rel_path (Rust blob-store): relative,
87
+ // ≤512 chars, ≤16 segments, per-segment charset [A-Za-z0-9._\- space],
88
+ // no empties/./.. /controls, no .meta.json suffix, no backslashes.
89
+ if (typeof path !== 'string' || !path || path.length > 512) return false;
90
+ if (path.startsWith('/') || path.endsWith('/') || path.includes('\\')) return false;
91
+ let decoded = path;
92
+ try { decoded = decodeURIComponent(path); } catch { return false; }
93
+ if (/^(.*%2f.*|.*%5c.*)$/i.test(path)) return false;
94
+ const segs = decoded.split('/');
95
+ if (segs.length > 16) return false;
96
+ for (const s of segs) {
97
+ if (!s || s.length > 128 || s === '.' || s === '..') return false;
98
+ if (s.endsWith('.meta.json')) return false;
99
+ if (!/^[A-Za-z0-9._\- ]+$/.test(s)) return false;
100
+ // eslint-disable-next-line no-control-regex
101
+ if (/[\x00-\x1f\x7f]/.test(s)) return false;
102
+ }
103
+ return true;
104
+ }
105
+
106
+ // Per-segment URL-encoding for object URLs (spaces etc. break otherwise).
107
+ function encodePath(path) {
108
+ return String(path).split('/').map((s) => encodeURIComponent(s)).join('/');
109
+ }
110
+
111
+ const SQL_KEYWORDS = new Set([
112
+ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER',
113
+ 'TRUNCATE', 'REVOKE', 'GRANT', 'EXECUTE', 'UNION', 'INTERSECT',
114
+ 'EXCEPT', 'WHERE', 'HAVING', 'GROUP', 'ORDER', 'BY', 'LIMIT',
115
+ 'OFFSET', 'FETCH', 'NEXT', 'ROWS', 'ONLY', 'FOR', 'UPDATE',
116
+ 'LOCK', 'IN', 'EXISTS', 'BETWEEN', 'LIKE', 'ILIKE', 'SIMILAR',
117
+ 'REGEXP', 'RLIKE', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'TRUE',
118
+ 'FALSE', 'CHECK', 'DEFAULT', 'CONSTRAINT', 'PRIMARY', 'FOREIGN',
119
+ 'KEY', 'UNIQUE', 'INDEX', 'TABLE', 'SCHEMA', 'DATABASE', 'VIEW',
120
+ 'FUNCTION', 'TRIGGER', 'PROCEDURE', 'LANGUAGE', 'EXTENSION',
121
+ 'WITH', 'RECURSIVE', 'CTE', 'MATERIALIZED', 'REFRESH', 'CONCURRENTLY',
122
+ 'COPY', 'VACUUM', 'ANALYZE', 'REINDEX', 'CLUSTER', 'COMMENT',
123
+ 'DO', 'BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'RELEASE',
124
+ 'START', 'TRANSACTION', 'ISOLATION', 'LEVEL', 'READ', 'WRITE',
125
+ 'COMMITTED', 'UNCOMMITTED', 'REPEATABLE', 'SERIALIZABLE', 'SNAPSHOT',
126
+ ]);
127
+
128
+ function validateTableName(name) {
129
+ if (!name || name.length > 63) {
130
+ throw new Error('Invalid table name');
131
+ }
132
+ if (!TABLE_NAME_REGEX.test(name)) {
133
+ throw new Error('Invalid table name');
134
+ }
135
+ if (SQL_KEYWORDS.has(name.toUpperCase())) {
136
+ throw new Error('Reserved keyword');
137
+ }
138
+ }
139
+
140
+ function validateColumnName(name) {
141
+ if (name === '*') {
142
+ throw new Error('Wildcard not allowed');
143
+ }
144
+ if (!name || name.length > 63) {
145
+ throw new Error('Invalid column name');
146
+ }
147
+ if (!COLUMN_NAME_REGEX.test(name)) {
148
+ throw new Error('Invalid column name');
149
+ }
150
+ if (SQL_KEYWORDS.has(name.toUpperCase())) {
151
+ throw new Error('Reserved keyword');
152
+ }
153
+ }
154
+
155
+ function validateColumns(columns) {
156
+ if (columns === '*') {
157
+ return;
158
+ }
159
+ const cols = columns.split(',').map(c => c.trim());
160
+ for (const col of cols) {
161
+ if (col.includes('(') || col.includes(')') || col.includes(';')) {
162
+ throw new Error('Invalid column expression');
163
+ }
164
+ validateColumnName(col);
165
+ }
166
+ }
167
+
168
+ function validateFilterValue(value) {
169
+ if (value === null || value === undefined) {
170
+ return;
171
+ }
172
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
173
+ return;
174
+ }
175
+ if (Array.isArray(value)) {
176
+ if (value.length > 100) {
177
+ throw new Error('Filter array too large (max 100)');
178
+ }
179
+ value.forEach(validateFilterValue);
180
+ return;
181
+ }
182
+ throw new Error('Invalid filter value type');
183
+ }
184
+
185
+ function validateData(data) {
186
+ if (Array.isArray(data)) {
187
+ if (data.length === 0) {
188
+ throw new Error('Data array must not be empty');
189
+ }
190
+ for (const item of data) {
191
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
192
+ throw new Error('Each item in data array must be an object');
193
+ }
194
+ validateDataObject(item);
195
+ }
196
+ return;
197
+ }
198
+ if (typeof data !== 'object' || data === null) {
199
+ throw new Error('Data must be an object or array');
200
+ }
201
+ validateDataObject(data);
202
+ }
203
+
204
+ function validateDataObject(obj, depth = 0) {
205
+ if (depth > 10) {
206
+ throw new Error('Data too deeply nested');
207
+ }
208
+ const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'];
209
+ for (const key of Object.keys(obj)) {
210
+ if (FORBIDDEN_KEYS.includes(key)) {
211
+ throw new Error(`Reserved key: ${key}`);
212
+ }
213
+ if (key.length > 63) {
214
+ throw new Error('Column name too long');
215
+ }
216
+ if (key.includes('.') || key.includes('[') || key.includes(']')) {
217
+ throw new Error('Invalid key characters');
218
+ }
219
+ const value = obj[key];
220
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
221
+ validateDataObject(value, depth + 1);
222
+ } else if (Array.isArray(value)) {
223
+ if (value.length > 1000) {
224
+ throw new Error('Array too large');
225
+ }
226
+ for (const item of value) {
227
+ if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
228
+ validateDataObject(item, depth + 1);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ function validateFilter(filter, depth = 0) {
236
+ if (depth > 3) {
237
+ throw new Error('Filter too deeply nested');
238
+ }
239
+
240
+ if (!filter.op) {
241
+ throw new Error('Missing filter operator');
242
+ }
243
+
244
+ const ALLOWED_FILTER_OPS = [
245
+ 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike',
246
+ 'in', 'is', 'contains', 'containedBy', 'overlaps', 'textSearch',
247
+ 'not', 'or',
248
+ ];
249
+
250
+ if (!ALLOWED_FILTER_OPS.includes(filter.op)) {
251
+ throw new Error('Unknown filter operator');
252
+ }
253
+
254
+ if (filter.op === 'not') {
255
+ if (!filter.filter) {
256
+ throw new Error('Missing inner filter');
257
+ }
258
+ validateFilter(filter.filter, depth + 1);
259
+ } else if (filter.op === 'or') {
260
+ if (!filter.filters || !Array.isArray(filter.filters)) {
261
+ throw new Error('Missing filter array');
262
+ }
263
+ for (const f of filter.filters) {
264
+ validateFilter(f, depth + 1);
265
+ }
266
+ } else {
267
+ if (filter.column) {
268
+ validateColumnName(filter.column);
269
+ }
270
+ if (filter.value !== undefined) {
271
+ validateFilterValue(filter.value);
272
+ }
273
+ }
274
+ }
275
+
276
+ // ─── Query Builder ───────────────────────────────────────────────────────────
277
+
278
+ class QueryBuilder {
279
+ /**
280
+ * @param {string} url - Fenaura server URL
281
+ * @param {string} apiKey - Project slug
282
+ * @param {string} table - Table name
283
+ * @param {ClientOptions} [options] - Client options
284
+ */
285
+ constructor(url, apiKey, table, options = {}) {
286
+ validateTableName(table);
287
+ this._url = url;
288
+ this._apiKey = apiKey;
289
+ this._table = table;
290
+ this._schema = options.schema;
291
+ this._timeout = options.timeout || 30000;
292
+ this._filters = [];
293
+ this._order = null;
294
+ this._limit = null;
295
+ this._offset = null;
296
+ this._columns = '*';
297
+ this._countMode = null;
298
+ this._single = false;
299
+ this._updateData = null;
300
+ this._op = 'select';
301
+ }
302
+
303
+ /**
304
+ * Select columns to retrieve
305
+ * @param {string} [columns='*'] - Comma-separated column names
306
+ * @returns {QueryBuilder}
307
+ */
308
+ select(columns = '*') {
309
+ validateColumns(columns);
310
+ this._columns = columns;
311
+ this._op = 'select';
312
+ return this;
313
+ }
314
+
315
+ /**
316
+ * Insert data
317
+ * @param {Object} data - Data to insert
318
+ * @returns {Promise<QueryResult>}
319
+ */
320
+ insert(data) {
321
+ validateData(data);
322
+ this._op = 'insert';
323
+ this._updateData = data;
324
+ return this._execute();
325
+ }
326
+
327
+ /**
328
+ * Update data
329
+ * @param {Object} data - Data to update
330
+ * @returns {QueryBuilder}
331
+ */
332
+ update(data) {
333
+ validateData(data);
334
+ this._op = 'update';
335
+ this._updateData = data;
336
+ return this;
337
+ }
338
+
339
+ /**
340
+ * Upsert data (insert or update) — single object only (D18: arrays are
341
+ * rejected 400 server-side; bulk upsert is insert-then-update instead).
342
+ * @param {Object} data - Data to upsert
343
+ * @returns {Promise<QueryResult>}
344
+ */
345
+ upsert(data) {
346
+ if (Array.isArray(data)) {
347
+ throw new Error('upsert takes a single object (arrays are rejected by the server)');
348
+ }
349
+ validateData(data);
350
+ this._op = 'upsert';
351
+ this._updateData = data;
352
+ return this._execute();
353
+ }
354
+
355
+ /**
356
+ * Delete data
357
+ * @returns {QueryBuilder}
358
+ */
359
+ delete() {
360
+ this._op = 'delete';
361
+ return this;
362
+ }
363
+
364
+ // ─── Filter Methods ──────────────────────────────────────────────────────
365
+
366
+ /**
367
+ * Filter: column equals value
368
+ * @param {string} column - Column name
369
+ * @param {any} value - Value to match
370
+ * @returns {QueryBuilder}
371
+ */
372
+ eq(column, value) {
373
+ return this._addFilter('eq', column, value);
374
+ }
375
+
376
+ /**
377
+ * Filter: column not equals value
378
+ * @param {string} column - Column name
379
+ * @param {any} value - Value to exclude
380
+ * @returns {QueryBuilder}
381
+ */
382
+ neq(column, value) {
383
+ return this._addFilter('neq', column, value);
384
+ }
385
+
386
+ /**
387
+ * Filter: column greater than value
388
+ * @param {string} column - Column name
389
+ * @param {any} value - Value to compare
390
+ * @returns {QueryBuilder}
391
+ */
392
+ gt(column, value) {
393
+ return this._addFilter('gt', column, value);
394
+ }
395
+
396
+ /**
397
+ * Filter: column greater than or equal to value
398
+ * @param {string} column - Column name
399
+ * @param {any} value - Value to compare
400
+ * @returns {QueryBuilder}
401
+ */
402
+ gte(column, value) {
403
+ return this._addFilter('gte', column, value);
404
+ }
405
+
406
+ /**
407
+ * Filter: column less than value
408
+ * @param {string} column - Column name
409
+ * @param {any} value - Value to compare
410
+ * @returns {QueryBuilder}
411
+ */
412
+ lt(column, value) {
413
+ return this._addFilter('lt', column, value);
414
+ }
415
+
416
+ /**
417
+ * Filter: column less than or equal to value
418
+ * @param {string} column - Column name
419
+ * @param {any} value - Value to compare
420
+ * @returns {QueryBuilder}
421
+ */
422
+ lte(column, value) {
423
+ return this._addFilter('lte', column, value);
424
+ }
425
+
426
+ /**
427
+ * Filter: column LIKE pattern
428
+ * @param {string} column - Column name
429
+ * @param {string} pattern - LIKE pattern
430
+ * @returns {QueryBuilder}
431
+ */
432
+ like(column, pattern) {
433
+ return this._addFilter('like', column, pattern);
434
+ }
435
+
436
+ /**
437
+ * Filter: column ILIKE pattern (case-insensitive)
438
+ * @param {string} column - Column name
439
+ * @param {string} pattern - ILIKE pattern
440
+ * @returns {QueryBuilder}
441
+ */
442
+ ilike(column, pattern) {
443
+ return this._addFilter('ilike', column, pattern);
444
+ }
445
+
446
+ /**
447
+ * Filter: column IN values
448
+ * @param {string} column - Column name
449
+ * @param {any[]} values - Array of values
450
+ * @returns {QueryBuilder}
451
+ */
452
+ in(column, values) {
453
+ return this._addFilter('in', column, values);
454
+ }
455
+
456
+ /**
457
+ * Filter: column IS value (use for null checks)
458
+ * @param {string} column - Column name
459
+ * @param {null} value - null
460
+ * @returns {QueryBuilder}
461
+ */
462
+ is(column, value) {
463
+ return this._addFilter('is', column, value);
464
+ }
465
+
466
+ /**
467
+ * Filter: column contains value (jsonb)
468
+ * @param {string} column - Column name
469
+ * @param {any} value - Value to check
470
+ * @returns {QueryBuilder}
471
+ */
472
+ contains(column, value) {
473
+ return this._addFilter('contains', column, value);
474
+ }
475
+
476
+ /**
477
+ * Filter: column contained by value (jsonb)
478
+ * @param {string} column - Column name
479
+ * @param {any} value - Value to check
480
+ * @returns {QueryBuilder}
481
+ */
482
+ containedBy(column, value) {
483
+ return this._addFilter('containedBy', column, value);
484
+ }
485
+
486
+ /**
487
+ * Filter: column overlaps value (array)
488
+ * @param {string} column - Column name
489
+ * @param {any} value - Value to check
490
+ * @returns {QueryBuilder}
491
+ */
492
+ overlaps(column, value) {
493
+ return this._addFilter('overlaps', column, value);
494
+ }
495
+
496
+ /**
497
+ * Filter: full-text search
498
+ * @param {string} column - Column name
499
+ * @param {string} query - Search query
500
+ * @returns {QueryBuilder}
501
+ */
502
+ textSearch(column, query) {
503
+ return this._addFilter('textSearch', column, query);
504
+ }
505
+
506
+ /**
507
+ * Negate a filter
508
+ * @param {Filter} filter - Filter to negate
509
+ * @returns {QueryBuilder}
510
+ */
511
+ not(filter) {
512
+ validateFilter(filter);
513
+ this._filters.push({ op: 'not', filter });
514
+ return this;
515
+ }
516
+
517
+ /**
518
+ * OR multiple filters
519
+ * @param {Filter[]} filters - Array of filters
520
+ * @returns {QueryBuilder}
521
+ */
522
+ or(filters) {
523
+ for (const f of filters) {
524
+ validateFilter(f);
525
+ }
526
+ this._filters.push({ op: 'or', filters });
527
+ return this;
528
+ }
529
+
530
+ // ─── Modifiers ──────────────────────────────────────────────────────────
531
+
532
+ /**
533
+ * Order results by column
534
+ * @param {string} column - Column name
535
+ * @param {Object} [options] - Options
536
+ * @param {boolean} [options.ascending=true] - Sort direction
537
+ * @returns {QueryBuilder}
538
+ */
539
+ order(column, options = {}) {
540
+ validateColumnName(column);
541
+ this._order = { column, ascending: options.ascending !== false };
542
+ return this;
543
+ }
544
+
545
+ /**
546
+ * Limit results
547
+ * @param {number} count - Maximum number of rows
548
+ * @returns {QueryBuilder}
549
+ */
550
+ limit(count) {
551
+ if (!Number.isInteger(count) || count < 1 || count > 1000) {
552
+ throw new Error('Limit must be between 1 and 1000');
553
+ }
554
+ this._limit = count;
555
+ return this;
556
+ }
557
+
558
+ /**
559
+ * Offset results
560
+ * @param {number} count - Number of rows to skip
561
+ * @returns {QueryBuilder}
562
+ */
563
+ offset(count) {
564
+ if (!Number.isInteger(count) || count < 0) {
565
+ throw new Error('Offset must be non-negative');
566
+ }
567
+ this._offset = count;
568
+ return this;
569
+ }
570
+
571
+ /**
572
+ * Return single row
573
+ * @returns {Promise<Object|null>}
574
+ */
575
+ single() {
576
+ this._single = true;
577
+ this._limit = 1;
578
+ return this._execute().then(r => r.data);
579
+ }
580
+
581
+ /**
582
+ * Return single row or null
583
+ * @returns {Promise<Object|null>}
584
+ */
585
+ maybeSingle() {
586
+ this._single = true;
587
+ this._limit = 1;
588
+ return this._execute().then(r => r.data);
589
+ }
590
+
591
+ /**
592
+ * Request count
593
+ * @param {'exact'|'planned'|'estimated'} [mode='exact'] - Count mode
594
+ * @returns {QueryBuilder}
595
+ */
596
+ count(mode = 'exact') {
597
+ this._countMode = mode;
598
+ return this;
599
+ }
600
+
601
+ /**
602
+ * Set returning columns
603
+ * @param {string} columns - Comma-separated column names
604
+ * @returns {QueryBuilder}
605
+ */
606
+ returning(columns) {
607
+ this._returning = columns;
608
+ return this;
609
+ }
610
+
611
+ /**
612
+ * Execute a select / update / delete chain and return the full result.
613
+ * `insert`, `upsert`, `single` and `maybeSingle` already execute on their
614
+ * own; this terminal covers the rest. The builder is also thenable, so
615
+ * `await fenaura.from('t').select('*').eq('id', 1)` works directly
616
+ * (Supabase-style).
617
+ * @returns {Promise<QueryResult>}
618
+ */
619
+ execute() {
620
+ return this._execute();
621
+ }
622
+
623
+ then(onFulfilled, onRejected) {
624
+ return this._execute().then(onFulfilled, onRejected);
625
+ }
626
+
627
+ catch(onRejected) {
628
+ return this._execute().catch(onRejected);
629
+ }
630
+
631
+ finally(onFinally) {
632
+ return this._execute().finally(onFinally);
633
+ }
634
+
635
+ // ─── Private Methods ────────────────────────────────────────────────────
636
+
637
+ _addFilter(op, column, value) {
638
+ validateColumnName(column);
639
+ validateFilterValue(value);
640
+ this._filters.push({ column, op, value });
641
+ return this;
642
+ }
643
+
644
+ async _execute() {
645
+ const body = {
646
+ op: this._op,
647
+ table: this._table,
648
+ columns: this._columns,
649
+ filters: this._filters,
650
+ };
651
+
652
+ if (this._order) {
653
+ body.order = this._order;
654
+ }
655
+ if (this._limit !== null) {
656
+ body.limit = this._limit;
657
+ }
658
+ if (this._offset !== null) {
659
+ body.offset = this._offset;
660
+ }
661
+ if (this._countMode) {
662
+ body.count = this._countMode;
663
+ }
664
+ if (this._single) {
665
+ body.single = true;
666
+ }
667
+ if (this._returning) {
668
+ body.returning = this._returning;
669
+ }
670
+ if (this._updateData) {
671
+ body.data = this._updateData;
672
+ }
673
+
674
+ const url = `${this._url}/api/v1/data/${this._apiKey}`;
675
+
676
+ const controller = new AbortController();
677
+ const timeoutId = setTimeout(() => controller.abort(), this._timeout);
678
+
679
+ try {
680
+ const { fetchUrl, opts } = buildFetchOpts(this._url, this._apiKey, body, controller.signal);
681
+ const response = await fetch(fetchUrl, opts);
682
+
683
+ const result = await response.json();
684
+ return result;
685
+ } catch (error) {
686
+ if (error.name === 'AbortError') {
687
+ return {
688
+ status: 'error',
689
+ error: { code: 'TIMEOUT', message: 'Request timed out' },
690
+ request_id: '',
691
+ };
692
+ }
693
+ return {
694
+ status: 'error',
695
+ error: { code: 'NETWORK_ERROR', message: error.message },
696
+ request_id: '',
697
+ };
698
+ } finally {
699
+ clearTimeout(timeoutId);
700
+ }
701
+ }
702
+ }
703
+
704
+ // ─── Auth Client ─────────────────────────────────────────────────────────────
705
+
706
+ // browser:false → ask the server for the raw token in the JSON body instead
707
+ // of a Set-Cookie (plan §12). Non-web clients / backends store or forward it
708
+ // themselves. Server side: Rust should_return_token (X-Fenaura-Return-Token).
709
+ function tokenHeaders(options = {}) {
710
+ if (options && options.browser === false) return { 'X-Fenaura-Return-Token': 'json' };
711
+ return {};
712
+ }
713
+
714
+ class AuthClient {
715
+ /**
716
+ * @param {string} url - Fenaura server URL
717
+ * @param {string} apiKey - Project slug
718
+ */
719
+ constructor(url, apiKey) {
720
+ this._url = url;
721
+ this._apiKey = apiKey;
722
+ this._listeners = new Map();
723
+ }
724
+
725
+ /**
726
+ * Sign up with email and password
727
+ * @param {Object} params
728
+ * @param {string} params.email - Email address
729
+ * @param {string} params.password - Password
730
+ * @param {Object} [options] - { browser=true } — browser:false returns the raw
731
+ * token in `data.token` (no cookie set) for non-web clients / backends.
732
+ * Store or forward it yourself. WARNING: a raw token in JS memory or
733
+ * localStorage is readable by any script on the page — prefer the cookie
734
+ * flow in browsers, and never use browser:false on shared machines.
735
+ * @returns {Promise<QueryResult>}
736
+ */
737
+ async signUp({ email, password }, options = {}) {
738
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
739
+ return { data: null, error: { message: 'Invalid email format' } };
740
+ }
741
+ if (password.length < 8) {
742
+ return { data: null, error: { message: 'Password must be at least 8 characters' } };
743
+ }
744
+ return this._authRequest('/api/v1/auth/dp/registration', { email, password, project_id: this._apiKey }, 'POST', tokenHeaders(options));
745
+ }
746
+
747
+ /**
748
+ * Sign in with email and password
749
+ * @param {Object} params
750
+ * @param {string} params.email - Email address
751
+ * @param {string} params.password - Password
752
+ * @param {Object} [options] - { browser=true } — browser:false returns the raw
753
+ * token in `data.token` (no cookie set) for non-web clients / backends.
754
+ * @returns {Promise<QueryResult>}
755
+ */
756
+ async signIn({ email, password }, options = {}) {
757
+ return this._authRequest('/api/v1/auth/dp/login', { email, password, project_id: this._apiKey }, 'POST', tokenHeaders(options));
758
+ }
759
+
760
+ /**
761
+ * Sign out
762
+ * @returns {Promise<QueryResult>}
763
+ */
764
+ async signOut() {
765
+ return this._authRequest('/api/v1/auth/dp/logout', { project_id: this._apiKey });
766
+ }
767
+
768
+ /**
769
+ * Get current session
770
+ * @returns {Promise<QueryResult>}
771
+ */
772
+ async getSession() {
773
+ return this._authRequest('/api/v1/auth/dp/whoami', null, 'GET');
774
+ }
775
+
776
+ /**
777
+ * Get current user
778
+ * @returns {Promise<QueryResult>}
779
+ */
780
+ async getUser() {
781
+ return this._authRequest('/api/v1/auth/dp/whoami', null, 'GET');
782
+ }
783
+
784
+ /**
785
+ * Sign in with OAuth provider (Supabase-shaped, Fenaura transport).
786
+ * Web (default): redirects the browser to the provider; session returns as
787
+ * HttpOnly fenaura_eusid_* cookie (+ one-time ?code= for exchange).
788
+ * Non-web ({ browser:false }): no navigation — returns { data:{url} };
789
+ * open the URL in a system browser, then call exchangeCodeForSession(code)
790
+ * with the ?code= your redirect_to (deep link) receives.
791
+ * @param {string} provider - google|github|microsoft|apple|facebook|twitter|discord|linkedin|spotify|slack|gitlab|twitch
792
+ * @param {Object} [options] - { redirectTo, scopes, queryParams, skipBrowserRedirect, browser=true }
793
+ * @returns {Promise<QueryResult>} { data: { url, provider }, error }
794
+ */
795
+ async signInWithOAuth(provider, options = {}) {
796
+ const OAUTH_PROVIDERS = ['google', 'github', 'microsoft', 'apple', 'facebook', 'twitter', 'discord', 'linkedin', 'spotify', 'slack', 'gitlab', 'twitch'];
797
+ if (!OAUTH_PROVIDERS.includes(provider)) {
798
+ return { data: null, error: { message: `Unknown provider: ${provider}` } };
799
+ }
800
+ const { redirectTo, scopes, queryParams = {}, skipBrowserRedirect = false, browser = true } = options;
801
+ // Reserved keys are stripped server-side (`qp` JSON blob); SDK passes through.
802
+ const q = new URLSearchParams({ project_id: this._apiKey });
803
+ if (redirectTo) q.set('redirect_to', redirectTo);
804
+ if (scopes) q.set('scopes', scopes);
805
+ if (queryParams && Object.keys(queryParams).length > 0) {
806
+ // Global AJV removeAdditional strips unknown query keys (server.ts) —
807
+ // send extras as one JSON blob `qp`, server filters reserved keys.
808
+ q.set('qp', JSON.stringify(queryParams));
809
+ }
810
+ const url = `${this._url}/api/v1/oauth/${provider}?${q.toString()}`;
811
+ if (skipBrowserRedirect || browser === false) return { data: { url, provider }, error: null };
812
+ if (typeof window !== 'undefined' && window.location) {
813
+ window.location.assign(url);
814
+ }
815
+ return { data: { url, provider }, error: null };
816
+ }
817
+
818
+ /**
819
+ * Exchange a one-time OAuth ?code= for the session (plan §12).
820
+ * Web via /fenaura proxy: ALSO sets the first-party HttpOnly cookie.
821
+ * Non-web / backend: use `data.token` — store or forward it yourself
822
+ * (e.g. your backend sets it as a cookie for YOUR end users).
823
+ * Codes are single-use, 60s TTL; replay → INVALID_CODE.
824
+ * @param {string} code - one-time code from the callback redirect (?code=)
825
+ * @returns {Promise<QueryResult>} { data: { token, project_id }, error }
826
+ */
827
+ async exchangeCodeForSession(code) {
828
+ if (!code || typeof code !== 'string') {
829
+ return { data: null, error: { message: 'Code is required' } };
830
+ }
831
+ return this._authRequest('/api/v1/auth/oauth/exchange', { code: code.trim() });
832
+ }
833
+
834
+ /**
835
+ * Send a signup verification code (projects with verification required).
836
+ * Route 1b of the verification flow: signup (route 1) creates the account
837
+ * without a session; this sends the code; submitVerificationCode (route 2)
838
+ * completes sign-in. Signup auto-sends, so call this only for resends.
839
+ * @param {Object} params - { email }
840
+ * @returns {Promise<QueryResult>} { data: { code_sent, email }, error }
841
+ */
842
+ async sendVerificationCode({ email }) {
843
+ return this._authRequest('/api/v1/auth/dp/send-code', { email, project_id: this._apiKey });
844
+ }
845
+
846
+ /**
847
+ * Submit a signup verification code (route 2 of 2).
848
+ * On success mints the FIRST session (cookie, or raw token with browser:false).
849
+ * @param {Object} params - { email, code }
850
+ * @param {Object} [options] - { browser=true }
851
+ * @returns {Promise<QueryResult>}
852
+ */
853
+ async submitVerificationCode({ email, code }, options = {}) {
854
+ return this._authRequest('/api/v1/auth/dp/verify-code', { email, code, project_id: this._apiKey }, 'POST', tokenHeaders(options));
855
+ }
856
+
857
+ /**
858
+ * Send a password-reset code (account must exist; unknown addresses get a
859
+ * generic success without email — anti-enumeration).
860
+ * @param {Object} params - { email }
861
+ * @returns {Promise<QueryResult>} { data: { code_sent, email }, error }
862
+ */
863
+ async sendPasswordReset({ email }) {
864
+ return this._authRequest('/api/v1/auth/dp/send-code', { email, project_id: this._apiKey, type: 'password_reset' });
865
+ }
866
+
867
+ /**
868
+ * Finish a password reset: consumes the code, sets the new password, kills
869
+ * all other sessions, mints a fresh session for this device.
870
+ * @param {Object} params - { email, code, newPassword }
871
+ * @param {Object} [options] - { browser=true }
872
+ * @returns {Promise<QueryResult>}
873
+ */
874
+ async confirmPasswordReset({ email, code, newPassword }, options = {}) {
875
+ return this._authRequest('/api/v1/auth/dp/reset-password', { email, code, new_password: newPassword, project_id: this._apiKey }, 'POST', tokenHeaders(options));
876
+ }
877
+
878
+ /**
879
+ * Send a magic-link (OTP) code — passwordless sign-in for existing accounts.
880
+ * @param {Object} params - { email }
881
+ * @returns {Promise<QueryResult>} { data: { code_sent, email }, error }
882
+ */
883
+ async sendMagicLink({ email }) {
884
+ return this._authRequest('/api/v1/auth/dp/send-code', { email, project_id: this._apiKey, type: 'magic_link' });
885
+ }
886
+
887
+ /**
888
+ * Verify a magic-link code → session (no password needed).
889
+ * @param {Object} params - { email, code }
890
+ * @param {Object} [options] - { browser=true }
891
+ * @returns {Promise<QueryResult>}
892
+ */
893
+ async verifyMagicCode({ email, code }, options = {}) {
894
+ return this._authRequest('/api/v1/auth/dp/verify-code', { email, code, project_id: this._apiKey, type: 'magic_link' }, 'POST', tokenHeaders(options));
895
+ }
896
+
897
+ /**
898
+ * Handle OAuth callback page (/auth/callback on your app).
899
+ * Server already set the session cookie; just read the user.
900
+ * @returns {Promise<QueryResult>} { data: { user }, error }
901
+ */
902
+ async handleOAuthCallback() {
903
+ if (typeof window !== 'undefined') {
904
+ const sp = new URLSearchParams(window.location.search);
905
+ const err = sp.get('error');
906
+ // Hygiene (plan §18): one-time codes must not linger in history/logs.
907
+ if (sp.has('code')) {
908
+ try {
909
+ sp.delete('code');
910
+ const qs = sp.toString();
911
+ window.history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
912
+ } catch { /* ignore */ }
913
+ }
914
+ if (err) {
915
+ return { data: null, error: { message: err, hint: sp.get('detail') || sp.get('error_description') } };
916
+ }
917
+ }
918
+ return this._authRequest('/api/v1/auth/dp/whoami', null, 'GET');
919
+ }
920
+
921
+ /**
922
+ * Listen for auth state changes
923
+ * @param {Function} callback - Callback function
924
+ * @returns {Object} Subscription object
925
+ */
926
+ onAuthStateChange(callback) {
927
+ const id = Math.random().toString(36).slice(2);
928
+ if (!this._listeners.has('auth')) {
929
+ this._listeners.set('auth', new Set());
930
+ }
931
+ this._listeners.get('auth').add(callback);
932
+ return {
933
+ data: {
934
+ subscription: {
935
+ unsubscribe: () => {
936
+ this._listeners.get('auth')?.delete(callback);
937
+ },
938
+ },
939
+ },
940
+ };
941
+ }
942
+
943
+ async _authRequest(path, body, method = 'POST', extraHeaders = {}) {
944
+ const controller = new AbortController();
945
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
946
+ try {
947
+ const isProxy = isProxyBase(this._url);
948
+ const fetchUrl = `${this._url}${path}`;
949
+ const headers = { 'Content-Type': 'application/json', ...extraHeaders };
950
+ if (!isProxy) {
951
+ const tok = getSessionToken();
952
+ if (!tok) {
953
+ try { console.warn('[fenaura] direct (non-proxy) mode without setSession(token): requests will 401. Call fenaura.setSession(token) or use createClient(\'/fenaura\', id).'); } catch { /* ignore */ }
954
+ }
955
+ headers['Authorization'] = `Bearer ${tok}`;
956
+ }
957
+ const opts = { method, headers, signal: controller.signal, ...(isProxy ? { credentials: 'include' } : {}) };
958
+ if (body && method !== 'GET') opts.body = JSON.stringify(body);
959
+ const response = await fetch(fetchUrl, opts);
960
+ const result = await response.json().catch(() => ({}));
961
+ // Normalize to { data, error } shape: data endpoint nests error, auth endpoint uses top-level code/message
962
+ if (result.status === 'error' || result.code) {
963
+ const err = result.error || (result.code ? { code: result.code, message: result.message, hint: result.hint } : null);
964
+ return { data: result.data ?? null, error: err || { message: result.message || 'Request failed' }, status: 'error', request_id: result.request_id || '' };
965
+ }
966
+ if (response.ok) {
967
+ return { data: result.data ?? result, error: null, status: result.status || 'success', request_id: result.request_id || '' };
968
+ }
969
+ // HTTP error but no status field
970
+ return { data: null, error: { code: result.code || 'REQUEST_FAILED', message: result.message || `HTTP ${response.status}` }, status: 'error', request_id: result.request_id || '' };
971
+ } catch (error) {
972
+ if (error.name === 'AbortError') {
973
+ return { status: 'error', data: null, error: { code: 'TIMEOUT', message: 'Request timed out' }, request_id: '' };
974
+ }
975
+ return { status: 'error', data: null, error: { code: 'NETWORK_ERROR', message: error.message }, request_id: '' };
976
+ } finally {
977
+ clearTimeout(timeoutId);
978
+ }
979
+ }
980
+
981
+ // legacy data-op path (kept for rpc/storage fallback, not used by auth)
982
+ async _request(op, data) {
983
+ const controller = new AbortController();
984
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
985
+ try {
986
+ const { fetchUrl, opts } = buildFetchOpts(this._url, this._apiKey, { op, data }, controller.signal);
987
+ const response = await fetch(fetchUrl, opts);
988
+ const result = await response.json();
989
+ return result;
990
+ } catch (error) {
991
+ if (error.name === 'AbortError') {
992
+ return { status: 'error', error: { code: 'TIMEOUT', message: 'Request timed out' }, request_id: '' };
993
+ }
994
+ return { status: 'error', error: { code: 'NETWORK_ERROR', message: error.message }, request_id: '' };
995
+ } finally {
996
+ clearTimeout(timeoutId);
997
+ }
998
+ }
999
+ }
1000
+
1001
+ // ─── Storage Client ──────────────────────────────────────────────────────────
1002
+
1003
+ class StorageClient {
1004
+ /**
1005
+ * @param {string} url - Fenaura server URL
1006
+ * @param {string} apiKey - Project slug
1007
+ */
1008
+ constructor(url, apiKey) {
1009
+ this._url = url;
1010
+ this._apiKey = apiKey;
1011
+ }
1012
+
1013
+ /**
1014
+ * Reference a storage bucket
1015
+ * @param {string} bucket - Bucket name
1016
+ * @returns {Object} Bucket operations
1017
+ */
1018
+ from(bucket) {
1019
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(bucket)) {
1020
+ throw new Error('Invalid bucket name');
1021
+ }
1022
+
1023
+ return {
1024
+ /**
1025
+ * Upload a file in 1 MiB chunks, with live backend progress.
1026
+ * Flow: init → PUT each chunk (server answers received/total per
1027
+ * chunk) → complete (server assembles + sniffs content). A failed
1028
+ * chunk is retried alone — never the whole file.
1029
+ * @param {string} path - File path
1030
+ * @param {File|Blob} file - File to upload
1031
+ * @param {Object} [options] - Upload options
1032
+ * @param {string} [options.contentType] - Override MIME type (default: compressed output type, else file.type)
1033
+ * @param {number} [options.maxSize] - Client-side cap applied AFTER compression (default 50 MiB)
1034
+ * @param {boolean|Object} [options.compress] - true or CompressOptions
1035
+ * ({mode,maxWidth,maxHeight,quality,format,lossless,force,signal}).
1036
+ * Runs fully on-device before any byte is sent: images →
1037
+ * canvas resize/re-encode, other files → gzip. The smaller bytes are
1038
+ * what get chunked/uploaded; success data carries
1039
+ * `compression:{mode,original_size,compressed_size}` when applied.
1040
+ * @param {function} [options.onProgress] - ({sentBytes,totalBytes,chunksDone,chunksTotal,index}) per acked chunk
1041
+ * @param {AbortSignal} [options.signal] - Abort the upload
1042
+ * @returns {Promise<QueryResult>}
1043
+ */
1044
+ upload: async (path, file, options = {}) => {
1045
+ if (!isPathSafe(path)) {
1046
+ throw new Error('Invalid path: directory traversal not allowed');
1047
+ }
1048
+ // 0. compress — on-device; only the smaller bytes leave the device
1049
+ let source = file;
1050
+ let compression = null;
1051
+ if (options.compress !== undefined && options.compress !== false && options.compress !== null) {
1052
+ const res = await compressFile(file, options.compress === true ? {} : options.compress);
1053
+ source = res.file;
1054
+ if (res.compressed) {
1055
+ compression = { mode: res.mode, original_size: res.originalSize, compressed_size: res.compressedSize };
1056
+ }
1057
+ }
1058
+ const maxSize = options.maxSize || 50 * 1024 * 1024;
1059
+ if (source.size > maxSize) {
1060
+ throw new Error(`File too large: max ${maxSize} bytes`);
1061
+ }
1062
+ if (!source.size) {
1063
+ throw new Error('Cannot upload an empty file');
1064
+ }
1065
+ const isProxy = isProxyBase(this._url);
1066
+ const authHeaders = isProxy ? {} : { 'Authorization': `Bearer ${getSessionToken()}` };
1067
+ const creds = isProxy ? { credentials: 'include' } : {};
1068
+ const fail = (code, message, request_id = '') => ({ status: 'error', data: null, error: { code, message }, request_id });
1069
+
1070
+ // 1. init — server validates name/type/size and hands back an upload id
1071
+ const initRes = await fetch(`${this._url}/api/v1/blob/${this._apiKey}/init`, {
1072
+ method: 'POST',
1073
+ headers: { 'Content-Type': 'application/json', ...authHeaders },
1074
+ body: JSON.stringify({
1075
+ bucket,
1076
+ path,
1077
+ content_type: options.contentType || source.type || 'application/octet-stream',
1078
+ size: source.size,
1079
+ }),
1080
+ signal: options.signal,
1081
+ ...creds,
1082
+ });
1083
+ const init = await initRes.json().catch(() => ({}));
1084
+ if (init.status === 'error' || init.code || !init.data?.upload_id) {
1085
+ return fail(init.code || 'INIT_FAILED', init.message || `Init failed (HTTP ${initRes.status})`, init.request_id);
1086
+ }
1087
+ const { upload_id, chunk_size, chunks_total } = init.data;
1088
+ const ping = (index, sentBytes) => {
1089
+ if (typeof options.onProgress === 'function') {
1090
+ try {
1091
+ options.onProgress({ sentBytes, totalBytes: source.size, chunksDone: index + 1, chunksTotal: chunks_total, index });
1092
+ } catch { /* listener errors must never break the upload */ }
1093
+ }
1094
+ };
1095
+
1096
+ // 2. chunks — sequential 1 MiB PUTs; each response is the progress event
1097
+ for (let i = 0; i < chunks_total; i++) {
1098
+ const start = i * chunk_size;
1099
+ const end = Math.min(start + chunk_size, source.size);
1100
+ let attempt = 0;
1101
+ for (;;) {
1102
+ const put = await fetch(`${this._url}/api/v1/blob/${this._apiKey}/chunk/${upload_id}/${i}`, {
1103
+ method: 'PUT',
1104
+ headers: { 'Content-Type': 'application/octet-stream', ...authHeaders },
1105
+ body: source.slice(start, end),
1106
+ signal: options.signal,
1107
+ ...creds,
1108
+ });
1109
+ const r = await put.json().catch(() => ({}));
1110
+ if (r.status === 'error' || r.code) {
1111
+ // Retry a failed chunk twice before giving up (server kept the rest).
1112
+ if (++attempt < 3 && put.status >= 500) continue;
1113
+ return fail(r.code || 'CHUNK_FAILED', r.message || `Chunk ${i} failed (HTTP ${put.status})`, r.request_id);
1114
+ }
1115
+ ping(i, r.data?.received_bytes ?? end);
1116
+ break;
1117
+ }
1118
+ }
1119
+
1120
+ // 3. complete — server assembles, verifies size + content, stores
1121
+ const doneRes = await fetch(`${this._url}/api/v1/blob/${this._apiKey}/complete/${upload_id}`, {
1122
+ method: 'POST',
1123
+ headers: { 'Content-Type': 'application/json', ...authHeaders },
1124
+ body: '{}',
1125
+ signal: options.signal,
1126
+ ...creds,
1127
+ });
1128
+ const done = await doneRes.json().catch(() => ({}));
1129
+ if (done.status === 'error' || done.code) {
1130
+ return fail(done.code || 'COMPLETE_FAILED', done.message || `Complete failed (HTTP ${doneRes.status})`, done.request_id);
1131
+ }
1132
+ ping(chunks_total - 1, source.size);
1133
+ if (compression) {
1134
+ return { status: 'success', data: { ...done.data, compression }, error: null, request_id: done.request_id || '' };
1135
+ }
1136
+ return { status: 'success', data: done.data, error: null, request_id: done.request_id || '' };
1137
+ },
1138
+
1139
+ /**
1140
+ * Download a file's bytes.
1141
+ * @param {string} path - File path
1142
+ * @returns {Promise<QueryResult & { data: Blob }>}
1143
+ */
1144
+ download: async (path) => {
1145
+ if (!isPathSafe(path)) {
1146
+ throw new Error('Invalid path: directory traversal not allowed');
1147
+ }
1148
+ try {
1149
+ const isProxy = isProxyBase(this._url);
1150
+ const response = await fetch(`${this._url}/api/v1/blob/${this._apiKey}/${bucket}/${encodePath(path)}`, {
1151
+ ...(isProxy
1152
+ ? { credentials: 'include' }
1153
+ : { headers: { 'Authorization': `Bearer ${getSessionToken()}` } }),
1154
+ });
1155
+ if (!response.ok) {
1156
+ const err = await response.json().catch(() => ({}));
1157
+ return { status: 'error', data: null, error: { code: err.code || 'DOWNLOAD_FAILED', message: err.message || `HTTP ${response.status}` }, request_id: err.request_id || '' };
1158
+ }
1159
+ return { status: 'success', data: await response.blob(), error: null, request_id: '' };
1160
+ } catch (error) {
1161
+ return { status: 'error', data: null, error: { code: 'NETWORK_ERROR', message: error.message }, request_id: '' };
1162
+ }
1163
+ },
1164
+
1165
+ /**
1166
+ * List files
1167
+ * @param {string} [path=''] - Directory path
1168
+ * @param {Object} [options] - List options
1169
+ * @returns {Promise<QueryResult>}
1170
+ */
1171
+ list: async (path = '', options = {}) => {
1172
+ if (!isPathSafe(path)) {
1173
+ throw new Error('Invalid path: directory traversal not allowed');
1174
+ }
1175
+ return this._request({
1176
+ op: 'storage.list',
1177
+ bucket,
1178
+ path,
1179
+ options: { limit: options.limit || 100, offset: options.offset || 0 },
1180
+ });
1181
+ },
1182
+
1183
+ /**
1184
+ * Remove files
1185
+ * @param {string[]} paths - Array of file paths
1186
+ * @returns {Promise<QueryResult>}
1187
+ */
1188
+ remove: async (paths) => {
1189
+ for (const p of paths) {
1190
+ if (!isPathSafe(p)) {
1191
+ throw new Error('Invalid path: directory traversal not allowed');
1192
+ }
1193
+ }
1194
+ return this._request({ op: 'storage.remove', bucket, paths });
1195
+ },
1196
+
1197
+ /**
1198
+ * Same-origin session URL (B15: NOT public despite the name — the GET
1199
+ * still requires the caller's session cookie; outsiders get 401. For
1200
+ * sharing with outsiders use createSignedUrl, which mints a real
1201
+ * HMAC-signed expiring link server-side).
1202
+ * @param {string} path - File path
1203
+ * @returns {string} Session-authenticated object URL (segments encoded)
1204
+ */
1205
+ getPublicUrl: (path) => {
1206
+ if (!isPathSafe(path)) {
1207
+ throw new Error('Invalid path: directory traversal not allowed');
1208
+ }
1209
+ return `${this._url}/api/v1/blob/${this._apiKey}/${bucket}/${encodePath(path)}`;
1210
+ },
1211
+
1212
+ /**
1213
+ * Create a signed URL
1214
+ * @param {string} path - File path
1215
+ * @param {number} [expiresIn=3600] - Expiration in seconds
1216
+ * @returns {Promise<QueryResult>}
1217
+ */
1218
+ createSignedUrl: async (path, expiresIn = 3600) => {
1219
+ if (!isPathSafe(path)) {
1220
+ throw new Error('Invalid path: directory traversal not allowed');
1221
+ }
1222
+ const safeExpiry = Math.min(expiresIn, 7 * 24 * 60 * 60);
1223
+ return this._request({
1224
+ op: 'storage.getSignedUrl',
1225
+ bucket,
1226
+ path,
1227
+ expires_in: safeExpiry,
1228
+ });
1229
+ },
1230
+ };
1231
+ }
1232
+
1233
+ async _request(body) {
1234
+ const controller = new AbortController();
1235
+ const timeoutId = setTimeout(() => controller.abort(), 60000);
1236
+
1237
+ try {
1238
+ const { fetchUrl, opts } = buildFetchOpts(this._url, this._apiKey, body, controller.signal);
1239
+ const response = await fetch(fetchUrl, opts);
1240
+
1241
+ const result = await response.json();
1242
+ return result;
1243
+ } catch (error) {
1244
+ if (error.name === 'AbortError') {
1245
+ return {
1246
+ status: 'error',
1247
+ error: { code: 'TIMEOUT', message: 'Request timed out' },
1248
+ request_id: '',
1249
+ };
1250
+ }
1251
+ return {
1252
+ status: 'error',
1253
+ error: { code: 'NETWORK_ERROR', message: error.message },
1254
+ request_id: '',
1255
+ };
1256
+ } finally {
1257
+ clearTimeout(timeoutId);
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ // ─── Compression (client-side, pre-upload) ───────────────────────────────────
1263
+ // Zero-dependency: Canvas 2D for images, native CompressionStream for generic
1264
+ // files. Everything runs on the user's device so fewer bytes hit the network.
1265
+ // Confirmed platform facts (researched 2026-09):
1266
+ // - CompressionStream gzip/deflate: Baseline, all browsers since May 2023.
1267
+ // - canvas.toBlob: PNG guaranteed; jpeg/webp widely supported BUT Safari may
1268
+ // silently fall back to PNG when webp encode is missing — so we NEVER trust
1269
+ // the requested mime; the reported contentType is always out.type.
1270
+ // - Video transcode (360p) needs WebCodecs + a muxer lib (WebCodecs emits raw
1271
+ // packets, not a playable file) — intentionally NOT in core; see Phase 2.
1272
+
1273
+ const COMPRESS_MODES = new Set(['auto', 'image', 'gzip', 'deflate', 'deflate-raw', 'none']);
1274
+ const COMPRESS_IMAGE_FORMATS = new Set(['original', 'webp', 'jpeg', 'jpg', 'png']);
1275
+ const COMPRESS_MAX_DIM = 8192; // sanity cap per side (px)
1276
+ const COMPRESS_MAX_BYTES = 500 * 1024 * 1024; // 500 MB in/out cap (memory safety)
1277
+
1278
+ function canCompressImage() {
1279
+ return typeof createImageBitmap === 'function'
1280
+ && typeof document !== 'undefined'
1281
+ && typeof document.createElement === 'function';
1282
+ }
1283
+
1284
+ function canGzip() {
1285
+ if (typeof CompressionStream === 'function') return true;
1286
+ return typeof globalThis !== 'undefined' && typeof globalThis.CompressionStream === 'function';
1287
+ }
1288
+
1289
+ function getCompressionStream(format) {
1290
+ const CS = typeof CompressionStream === 'function' ? CompressionStream : globalThis.CompressionStream;
1291
+ return new CS(format);
1292
+ }
1293
+
1294
+ /**
1295
+ * Which compression backends exist in this environment.
1296
+ * @returns {{image: boolean, gzip: boolean}}
1297
+ */
1298
+ function supportsCompression() {
1299
+ return { image: canCompressImage(), gzip: canGzip() };
1300
+ }
1301
+
1302
+ function normalizeCompressOptions(input) {
1303
+ if (input === true) input = { mode: 'auto' };
1304
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1305
+ throw new Error('Invalid compress options: pass true or an options object');
1306
+ }
1307
+ const o = {
1308
+ mode: 'auto',
1309
+ maxWidth: 1920,
1310
+ maxHeight: 1920,
1311
+ quality: 0.8,
1312
+ format: 'original',
1313
+ lossless: false,
1314
+ force: false,
1315
+ signal: undefined,
1316
+ ...input,
1317
+ };
1318
+ if (!COMPRESS_MODES.has(o.mode)) {
1319
+ throw new Error(`Unknown compress mode: ${o.mode} (auto|image|gzip|deflate|deflate-raw|none)`);
1320
+ }
1321
+ for (const k of ['maxWidth', 'maxHeight']) {
1322
+ if (!Number.isInteger(o[k]) || o[k] < 1 || o[k] > COMPRESS_MAX_DIM) {
1323
+ throw new Error(`Invalid ${k}: integer 1-${COMPRESS_MAX_DIM}`);
1324
+ }
1325
+ }
1326
+ if (typeof o.quality !== 'number' || !(o.quality > 0) || o.quality > 1) {
1327
+ throw new Error('Invalid quality: number in (0, 1]');
1328
+ }
1329
+ const fmt = String(o.format || 'original').toLowerCase();
1330
+ if (!COMPRESS_IMAGE_FORMATS.has(fmt)) {
1331
+ throw new Error(`Unknown image format: ${o.format} (original|webp|jpeg|png)`);
1332
+ }
1333
+ o.format = fmt === 'jpg' ? 'jpeg' : fmt;
1334
+ o.lossless = o.lossless === true;
1335
+ o.force = o.force === true;
1336
+ if (o.signal !== undefined && o.signal !== null && typeof o.signal.aborted !== 'boolean') {
1337
+ throw new Error('Invalid signal: pass an AbortSignal');
1338
+ }
1339
+ return o;
1340
+ }
1341
+
1342
+ function throwIfAborted(signal) {
1343
+ if (signal && signal.aborted) {
1344
+ const e = new Error('Compression aborted');
1345
+ e.name = 'AbortError';
1346
+ throw e;
1347
+ }
1348
+ }
1349
+
1350
+ function requireBlob(file) {
1351
+ if (!file || typeof file.size !== 'number' || typeof file.slice !== 'function') {
1352
+ throw new Error('compressFile needs a File or Blob');
1353
+ }
1354
+ }
1355
+
1356
+ // Image path: decode → scale-to-fit (aspect preserved, never upscale) →
1357
+ // re-encode. Lossless (`lossless:true`) forces PNG. JPEG gets a white
1358
+ // backdrop (no alpha channel). Anti-corruption: decode/encode failures throw
1359
+ // and the caller keeps the original — we never return partial bytes.
1360
+ async function compressImageBlob(blob, o) {
1361
+ if (!canCompressImage()) {
1362
+ throw new Error('Image compression needs a browser (canvas/createImageBitmap unavailable)');
1363
+ }
1364
+ throwIfAborted(o.signal);
1365
+ let bitmap;
1366
+ try {
1367
+ bitmap = await createImageBitmap(blob);
1368
+ } catch {
1369
+ throw new Error('Could not decode image (unsupported or corrupt file — original kept)');
1370
+ }
1371
+ try {
1372
+ throwIfAborted(o.signal);
1373
+ const scale = Math.min(1, o.maxWidth / bitmap.width, o.maxHeight / bitmap.height);
1374
+ const w = Math.max(1, Math.round(bitmap.width * scale));
1375
+ const h = Math.max(1, Math.round(bitmap.height * scale));
1376
+ const canvas = document.createElement('canvas');
1377
+ canvas.width = w;
1378
+ canvas.height = h;
1379
+ const ctx = canvas.getContext('2d');
1380
+ if (!ctx) throw new Error('Canvas 2D unavailable — original kept');
1381
+ let mime;
1382
+ if (o.lossless) mime = 'image/png';
1383
+ else if (o.format === 'original') mime = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg';
1384
+ else mime = `image/${o.format}`;
1385
+ if (mime === 'image/jpeg') {
1386
+ ctx.fillStyle = '#ffffff';
1387
+ ctx.fillRect(0, 0, w, h);
1388
+ }
1389
+ ctx.drawImage(bitmap, 0, 0, w, h);
1390
+ throwIfAborted(o.signal);
1391
+ const q = (o.lossless || mime === 'image/png') ? undefined : o.quality;
1392
+ const out = await new Promise((resolve, reject) => {
1393
+ try {
1394
+ canvas.toBlob(b => (b ? resolve(b) : reject(new Error('Image encode failed — original kept'))), mime, q);
1395
+ } catch (err) {
1396
+ reject(err);
1397
+ }
1398
+ });
1399
+ canvas.width = 0;
1400
+ canvas.height = 0;
1401
+ return out;
1402
+ } finally {
1403
+ try { bitmap.close && bitmap.close(); } catch { /* noop */ }
1404
+ }
1405
+ }
1406
+
1407
+ // Generic lossless path: stream through native CompressionStream and collect.
1408
+ // Manual reader (no Response dependency) with an output cap for memory safety.
1409
+ async function compressGzipBlob(blob, format, signal) {
1410
+ if (!canGzip()) {
1411
+ throw new Error('Gzip compression needs CompressionStream (modern browser or Node 18+)');
1412
+ }
1413
+ if (typeof blob.stream !== 'function') {
1414
+ throw new Error('This file cannot be streamed for compression');
1415
+ }
1416
+ throwIfAborted(signal);
1417
+ const stream = blob.stream().pipeThrough(getCompressionStream(format));
1418
+ const reader = stream.getReader();
1419
+ const chunks = [];
1420
+ let total = 0;
1421
+ for (;;) {
1422
+ throwIfAborted(signal);
1423
+ const { done, value } = await reader.read();
1424
+ if (done) break;
1425
+ total += value.byteLength;
1426
+ if (total > COMPRESS_MAX_BYTES) {
1427
+ try { await reader.cancel(); } catch { /* noop */ }
1428
+ throw new Error('Compressed output exceeds 500 MB cap — original kept');
1429
+ }
1430
+ chunks.push(value);
1431
+ }
1432
+ const merged = new Uint8Array(total);
1433
+ let off = 0;
1434
+ for (const c of chunks) {
1435
+ merged.set(c, off);
1436
+ off += c.byteLength;
1437
+ }
1438
+ const out = new Blob([merged], { type: 'application/gzip' });
1439
+ if (!out.size) throw new Error('Compression produced empty output — original kept');
1440
+ return out;
1441
+ }
1442
+
1443
+ /**
1444
+ * Compress a file client-side before upload.
1445
+ *
1446
+ * Modes:
1447
+ * - 'auto' (default): images → canvas resize/re-encode, everything else →
1448
+ * gzip. Degrades gracefully (image w/o canvas falls through to gzip).
1449
+ * - 'image': images only. Params: maxWidth/maxHeight (default 1920, aspect
1450
+ * preserved, never upscales), quality (0,1] default 0.8 (jpeg/webp only),
1451
+ * format original|webp|jpeg|png, lossless:true forces PNG.
1452
+ * - 'gzip'|'deflate'|'deflate-raw': lossless, any file type. Output is
1453
+ * `application/gzip` — decompress after download.
1454
+ * - 'none': passthrough (no-op, still validates).
1455
+ *
1456
+ * Never corrupts: any decode/encode/stream failure throws and the original
1457
+ * Blob is untouched. Unless `force:true`, an output >= input returns the
1458
+ * original with `compressed:false`.
1459
+ *
1460
+ * @param {File|Blob} file - File to compress
1461
+ * @param {Object|boolean} [options] - true = defaults, or CompressOptions
1462
+ * @param {string} [options.mode='auto']
1463
+ * @param {number} [options.maxWidth=1920] - integer 1-8192
1464
+ * @param {number} [options.maxHeight=1920] - integer 1-8192
1465
+ * @param {number} [options.quality=0.8] - (0,1], lossy formats only
1466
+ * @param {string} [options.format='original'] - original|webp|jpeg|png
1467
+ * @param {boolean} [options.lossless=false] - force lossless PNG (images)
1468
+ * @param {boolean} [options.force=false] - keep output even if larger
1469
+ * @param {AbortSignal} [options.signal] - abort compression
1470
+ * @returns {Promise<{file: Blob, compressed: boolean, mode: string, originalSize: number, compressedSize: number, contentType: string}>}
1471
+ */
1472
+ export async function compressFile(file, options = {}) {
1473
+ const o = normalizeCompressOptions(options);
1474
+ requireBlob(file);
1475
+ if (file.size === 0) throw new Error('Cannot compress an empty file');
1476
+ if (file.size > COMPRESS_MAX_BYTES) throw new Error('File too large to compress (max 500 MB)');
1477
+ if (o.mode === 'none') {
1478
+ return { file, compressed: false, mode: 'none', originalSize: file.size, compressedSize: file.size, contentType: file.type || 'application/octet-stream' };
1479
+ }
1480
+ const isImage = typeof file.type === 'string' && file.type.startsWith('image/');
1481
+ let out = file;
1482
+ let applied = 'none';
1483
+ if (o.mode === 'auto') {
1484
+ if (isImage && canCompressImage()) {
1485
+ out = await compressImageBlob(file, o);
1486
+ applied = 'image';
1487
+ } else if (canGzip()) {
1488
+ out = await compressGzipBlob(file, 'gzip', o.signal);
1489
+ applied = 'gzip';
1490
+ } else {
1491
+ throw new Error('No compression available in this environment');
1492
+ }
1493
+ } else if (o.mode === 'image') {
1494
+ if (!isImage) throw new Error('Image mode needs an image/* file');
1495
+ out = await compressImageBlob(file, o);
1496
+ applied = 'image';
1497
+ } else {
1498
+ out = await compressGzipBlob(file, o.mode, o.signal);
1499
+ applied = o.mode;
1500
+ }
1501
+ // Trust the bytes, not the request: Safari may fall back to PNG for webp.
1502
+ const contentType = out.type || (applied === 'image' ? (file.type || 'application/octet-stream') : 'application/gzip');
1503
+ if (!o.force && out.size >= file.size) {
1504
+ return { file, compressed: false, mode: 'none', originalSize: file.size, compressedSize: file.size, contentType: file.type || 'application/octet-stream' };
1505
+ }
1506
+ const typed = out.type ? out : new Blob([out], { type: contentType });
1507
+ return { file: typed, compressed: true, mode: applied, originalSize: file.size, compressedSize: typed.size, contentType };
1508
+ }
1509
+
1510
+ compressFile.supports = supportsCompression;
1511
+
1512
+ // ─── Session Management ──────────────────────────────────────────────────────
1513
+
1514
+ let _sessionToken = null;
1515
+
1516
+ function getSessionToken() {
1517
+ return _sessionToken;
1518
+ }
1519
+
1520
+ function setSessionToken(token) {
1521
+ _sessionToken = token;
1522
+ }
1523
+
1524
+ // ─── Main Export ─────────────────────────────────────────────────────────────
1525
+
1526
+ /**
1527
+ * Create a Fenaura client
1528
+ * @param {string} url - Fenaura server URL or proxy path (e.g. '/fenaura' after `npx @fenaura/sdk init` for cookies, or 'https://api.fenaura.com' for direct Bearer)
1529
+ * @param {string} apiKey - Project slug or ID
1530
+ * @param {import('./fenaura-client.d.ts').ClientOptions} [options] - Client options { schema, timeout, proxy }
1531
+ * @returns {import('./fenaura-client.d.ts').FenauraClient} Fenaura client (fenaura.from, fenaura.auth, fenaura.storage)
1532
+ */
1533
+ export function createClient(url, apiKey, options = {}) {
1534
+ // PARANOIA: Validate inputs
1535
+ if (!url || typeof url !== 'string') {
1536
+ throw new Error('URL is required');
1537
+ }
1538
+ if (!apiKey || typeof apiKey !== 'string') {
1539
+ throw new Error('Project slug is required');
1540
+ }
1541
+ // Validate URL format (allow proxy path like '/fenaura')
1542
+ if (!url.startsWith('/')) {
1543
+ try {
1544
+ new URL(url);
1545
+ } catch {
1546
+ throw new Error('Invalid URL format');
1547
+ }
1548
+ }
1549
+ // Validate project slug
1550
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(apiKey)) {
1551
+ throw new Error('Invalid project slug');
1552
+ }
1553
+
1554
+ return {
1555
+ /**
1556
+ * Query a table
1557
+ * @param {string} table - Table name
1558
+ * @returns {QueryBuilder}
1559
+ */
1560
+ from: (table) => new QueryBuilder(url, apiKey, table, options),
1561
+
1562
+ /**
1563
+ * Switch schema
1564
+ * @param {string} schema - Schema name
1565
+ * @returns {Object} Client with schema set
1566
+ */
1567
+ schema: (schema) => createClient(url, apiKey, { ...options, schema }),
1568
+
1569
+ /**
1570
+ * Call a Postgres function
1571
+ * @param {string} fn - Function name
1572
+ * @param {Object} [args] - Function arguments
1573
+ * @param {Object} [options] - RPC options
1574
+ * @returns {Promise<QueryResult>}
1575
+ */
1576
+ rpc: async (fn, args = {}, options = {}) => {
1577
+ validateTableName(fn);
1578
+ const body = {
1579
+ op: 'rpc',
1580
+ function: fn,
1581
+ args,
1582
+ ...options,
1583
+ };
1584
+
1585
+ const controller = new AbortController();
1586
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout || 30000);
1587
+
1588
+ try {
1589
+ const isProxy = isProxyBase(url);
1590
+ const fetchUrl = `${url}/api/v1/data/${apiKey}`;
1591
+ const headers = { 'Content-Type': 'application/json' };
1592
+ if (!isProxy) {
1593
+ const tok = getSessionToken();
1594
+ if (!tok) {
1595
+ try { console.warn('[fenaura] direct (non-proxy) mode without setSession(token): requests will 401. Call fenaura.setSession(token) or use createClient(\'/fenaura\', id).'); } catch { /* ignore */ }
1596
+ }
1597
+ headers['Authorization'] = `Bearer ${tok}`;
1598
+ }
1599
+ const response = await fetch(fetchUrl, {
1600
+ method: 'POST',
1601
+ headers,
1602
+ body: JSON.stringify(body),
1603
+ signal: controller.signal,
1604
+ ...(isProxy ? { credentials: 'include' } : {}),
1605
+ });
1606
+
1607
+ const result = await response.json();
1608
+ return result;
1609
+ } catch (error) {
1610
+ if (error.name === 'AbortError') {
1611
+ return {
1612
+ status: 'error',
1613
+ error: { code: 'TIMEOUT', message: 'Request timed out' },
1614
+ request_id: '',
1615
+ };
1616
+ }
1617
+ return {
1618
+ status: 'error',
1619
+ error: { code: 'NETWORK_ERROR', message: error.message },
1620
+ request_id: '',
1621
+ };
1622
+ } finally {
1623
+ clearTimeout(timeoutId);
1624
+ }
1625
+ },
1626
+
1627
+ /**
1628
+ * Auth client
1629
+ */
1630
+ auth: new AuthClient(url, apiKey),
1631
+
1632
+ /**
1633
+ * Storage client
1634
+ */
1635
+ storage: new StorageClient(url, apiKey),
1636
+
1637
+ /**
1638
+ * Channel (placeholder for future realtime support)
1639
+ */
1640
+ channel: (name) => {
1641
+ throw new Error('Realtime not yet supported');
1642
+ },
1643
+
1644
+ /**
1645
+ * Set session token
1646
+ * @param {string} token - Session token
1647
+ */
1648
+ setSession: (token) => setSessionToken(token),
1649
+
1650
+ /**
1651
+ * Get session token
1652
+ * @returns {string|null}
1653
+ */
1654
+ getSession: () => getSessionToken(),
1655
+ };
1656
+ }
1657
+
1658
+ // ─── UMD Export ──────────────────────────────────────────────────────────────
1659
+
1660
+ if (typeof module !== 'undefined' && module.exports) {
1661
+ module.exports = { createClient, compressFile };
1662
+ }
1663
+ if (typeof window !== 'undefined') {
1664
+ window.FenauraClient = { createClient, compressFile };
1665
+ }