@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/init.js +224 -0
- package/js/fenaura-client.d.ts +376 -0
- package/js/fenaura-client.js +1665 -0
- package/package.json +47 -0
|
@@ -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
|
+
}
|