@softr/sdk 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -516
- package/dist/index.js +2 -845
- package/dist/server.d.ts +32 -2
- package/dist/server.js +243 -13
- package/dist/session-kTqxT5_K.js +891 -0
- package/dist/types-n-Sir_Y2.d.ts +482 -0
- package/package.json +1 -1
- package/dist/session-Blx62HHJ.js +0 -334
- package/dist/user-D23hwTsf.d.ts +0 -28
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import { getRequestHeader, getRequestProtocol, getResponseHeaders, setResponseHeader } from "@tanstack/react-start/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
//#region src/core/query.ts
|
|
6
|
+
const INTERNAL_KIND = Symbol("KIND");
|
|
7
|
+
/**
|
|
8
|
+
* Query builder for filter queries. This exist to alleviate the (LLM?) mental overhead. API filter schema is a bit
|
|
9
|
+
* verbose and confusing, even when dealing with very basic queries. So to fall into the pit of success, aim here
|
|
10
|
+
* is to simplify filtering as much as possible.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* single filter
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const query = q.text("title").contains("hello");
|
|
16
|
+
*
|
|
17
|
+
* const json = buildFilterCriteria(query);
|
|
18
|
+
* // result
|
|
19
|
+
* {
|
|
20
|
+
* "filterCriteria": {
|
|
21
|
+
* "filterConditionGroup": {
|
|
22
|
+
* "logicalOperator": "AND"
|
|
23
|
+
* "filterGroups": [{
|
|
24
|
+
* "logicalOperator": "AND",
|
|
25
|
+
* "filters": [{
|
|
26
|
+
* "subject": "title",
|
|
27
|
+
* "type": "TEXT",
|
|
28
|
+
* "operator": "CONTAINS",
|
|
29
|
+
* "value": "hello"
|
|
30
|
+
* }],
|
|
31
|
+
* }]
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* combining multiple filters
|
|
40
|
+
* ```tsx
|
|
41
|
+
* const query = q.and(
|
|
42
|
+
* q.text("title").contains("hello"),
|
|
43
|
+
* q.number("views").is(100),
|
|
44
|
+
* q.or(
|
|
45
|
+
* q.text("author").is("John"),
|
|
46
|
+
* q.boolean("isPublished").is(true)
|
|
47
|
+
* )
|
|
48
|
+
* );
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
const q = {
|
|
52
|
+
select(mapping) {
|
|
53
|
+
return mapping;
|
|
54
|
+
},
|
|
55
|
+
and(...filters) {
|
|
56
|
+
return {
|
|
57
|
+
logicalOperator: "AND",
|
|
58
|
+
filters,
|
|
59
|
+
[INTERNAL_KIND]: "LOGICAL_GROUP"
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
or(...filters) {
|
|
63
|
+
return {
|
|
64
|
+
logicalOperator: "OR",
|
|
65
|
+
filters,
|
|
66
|
+
[INTERNAL_KIND]: "LOGICAL_GROUP"
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
text(subject) {
|
|
70
|
+
const filter = {
|
|
71
|
+
subject,
|
|
72
|
+
type: "TEXT",
|
|
73
|
+
[INTERNAL_KIND]: "FILTER"
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
is(value) {
|
|
77
|
+
return {
|
|
78
|
+
...filter,
|
|
79
|
+
operator: "IS",
|
|
80
|
+
value
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
isNot(value) {
|
|
84
|
+
return {
|
|
85
|
+
...filter,
|
|
86
|
+
operator: "IS_NOT",
|
|
87
|
+
value
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
contains(value) {
|
|
91
|
+
return {
|
|
92
|
+
...filter,
|
|
93
|
+
operator: "CONTAINS",
|
|
94
|
+
value
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
isOneOf(value) {
|
|
98
|
+
return {
|
|
99
|
+
...filter,
|
|
100
|
+
operator: "IS_ONE_OF",
|
|
101
|
+
value
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
isNoneOf(value) {
|
|
105
|
+
return {
|
|
106
|
+
...filter,
|
|
107
|
+
operator: "IS_NONE_OF",
|
|
108
|
+
value
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
startsWith(value) {
|
|
112
|
+
return {
|
|
113
|
+
...filter,
|
|
114
|
+
operator: "STARTS_WITH",
|
|
115
|
+
value
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
endsWith(value) {
|
|
119
|
+
return {
|
|
120
|
+
...filter,
|
|
121
|
+
operator: "ENDS_WITH",
|
|
122
|
+
value
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
hasAllOf(value) {
|
|
126
|
+
return {
|
|
127
|
+
...filter,
|
|
128
|
+
operator: "HAS_ALL_OF",
|
|
129
|
+
value
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
isEmpty() {
|
|
133
|
+
return {
|
|
134
|
+
...filter,
|
|
135
|
+
operator: "IS_EMPTY"
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
isNotEmpty() {
|
|
139
|
+
return {
|
|
140
|
+
...filter,
|
|
141
|
+
operator: "IS_NOT_EMPTY"
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
boolean(subject) {
|
|
147
|
+
const filter = {
|
|
148
|
+
subject,
|
|
149
|
+
type: "BOOLEAN",
|
|
150
|
+
[INTERNAL_KIND]: "FILTER"
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
is(value) {
|
|
154
|
+
return {
|
|
155
|
+
...filter,
|
|
156
|
+
operator: "IS",
|
|
157
|
+
value
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
isNot(value) {
|
|
161
|
+
return {
|
|
162
|
+
...filter,
|
|
163
|
+
operator: "IS_NOT",
|
|
164
|
+
value
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
isEmpty() {
|
|
168
|
+
return {
|
|
169
|
+
...filter,
|
|
170
|
+
operator: "IS_EMPTY"
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
isNotEmpty() {
|
|
174
|
+
return {
|
|
175
|
+
...filter,
|
|
176
|
+
operator: "IS_NOT_EMPTY"
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
number(subject) {
|
|
182
|
+
const filter = {
|
|
183
|
+
subject,
|
|
184
|
+
type: "NUMERIC",
|
|
185
|
+
[INTERNAL_KIND]: "FILTER"
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
is(value) {
|
|
189
|
+
return {
|
|
190
|
+
...filter,
|
|
191
|
+
operator: "IS",
|
|
192
|
+
value: value.toString()
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
isNot(value) {
|
|
196
|
+
return {
|
|
197
|
+
...filter,
|
|
198
|
+
operator: "IS_NOT",
|
|
199
|
+
value: value.toString()
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
gt(value) {
|
|
203
|
+
return {
|
|
204
|
+
...filter,
|
|
205
|
+
operator: "GT",
|
|
206
|
+
value: value.toString()
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
gte(value) {
|
|
210
|
+
return {
|
|
211
|
+
...filter,
|
|
212
|
+
operator: "GTE",
|
|
213
|
+
value: value.toString()
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
lt(value) {
|
|
217
|
+
return {
|
|
218
|
+
...filter,
|
|
219
|
+
operator: "LT",
|
|
220
|
+
value: value.toString()
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
lte(value) {
|
|
224
|
+
return {
|
|
225
|
+
...filter,
|
|
226
|
+
operator: "LTE",
|
|
227
|
+
value: value.toString()
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
between(start, end) {
|
|
231
|
+
return {
|
|
232
|
+
...filter,
|
|
233
|
+
operator: "BETWEEN",
|
|
234
|
+
value: [start.toString(), end.toString()]
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
isEmpty() {
|
|
238
|
+
return {
|
|
239
|
+
...filter,
|
|
240
|
+
operator: "IS_EMPTY"
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
isNotEmpty() {
|
|
244
|
+
return {
|
|
245
|
+
...filter,
|
|
246
|
+
operator: "IS_NOT_EMPTY"
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
array(subject) {
|
|
252
|
+
const filter = {
|
|
253
|
+
subject,
|
|
254
|
+
type: "ARRAY",
|
|
255
|
+
[INTERNAL_KIND]: "FILTER"
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
is(value) {
|
|
259
|
+
return {
|
|
260
|
+
...filter,
|
|
261
|
+
operator: "IS",
|
|
262
|
+
value
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
isOneOf(value) {
|
|
266
|
+
return {
|
|
267
|
+
...filter,
|
|
268
|
+
operator: "IS_ONE_OF",
|
|
269
|
+
value
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
isNoneOf(value) {
|
|
273
|
+
return {
|
|
274
|
+
...filter,
|
|
275
|
+
operator: "IS_NONE_OF",
|
|
276
|
+
value
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
hasAllOf(value) {
|
|
280
|
+
return {
|
|
281
|
+
...filter,
|
|
282
|
+
operator: "HAS_ALL_OF",
|
|
283
|
+
value
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
isEmpty() {
|
|
287
|
+
return {
|
|
288
|
+
...filter,
|
|
289
|
+
operator: "IS_EMPTY"
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
isNotEmpty() {
|
|
293
|
+
return {
|
|
294
|
+
...filter,
|
|
295
|
+
operator: "IS_NOT_EMPTY"
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
date(subject) {
|
|
301
|
+
const filter = {
|
|
302
|
+
subject,
|
|
303
|
+
type: "DATE",
|
|
304
|
+
[INTERNAL_KIND]: "FILTER"
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
is(value) {
|
|
308
|
+
return {
|
|
309
|
+
...filter,
|
|
310
|
+
operator: "IS",
|
|
311
|
+
value
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
isNot(value) {
|
|
315
|
+
return {
|
|
316
|
+
...filter,
|
|
317
|
+
operator: "IS_NOT",
|
|
318
|
+
value
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
gt(value) {
|
|
322
|
+
return {
|
|
323
|
+
...filter,
|
|
324
|
+
operator: "GT",
|
|
325
|
+
value
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
gte(value) {
|
|
329
|
+
return {
|
|
330
|
+
...filter,
|
|
331
|
+
operator: "GTE",
|
|
332
|
+
value
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
lt(value) {
|
|
336
|
+
return {
|
|
337
|
+
...filter,
|
|
338
|
+
operator: "LT",
|
|
339
|
+
value
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
lte(value) {
|
|
343
|
+
return {
|
|
344
|
+
...filter,
|
|
345
|
+
operator: "LTE",
|
|
346
|
+
value
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
between(start, end) {
|
|
350
|
+
return {
|
|
351
|
+
...filter,
|
|
352
|
+
operator: "BETWEEN",
|
|
353
|
+
value: [start, end]
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
isNotBetween(start, end) {
|
|
357
|
+
return {
|
|
358
|
+
...filter,
|
|
359
|
+
operator: "IS_NOT_BETWEEN",
|
|
360
|
+
value: [start, end]
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
isEmpty() {
|
|
364
|
+
return {
|
|
365
|
+
...filter,
|
|
366
|
+
operator: "IS_EMPTY"
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
isNotEmpty() {
|
|
370
|
+
return {
|
|
371
|
+
...filter,
|
|
372
|
+
operator: "IS_NOT_EMPTY"
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
asc(subject) {
|
|
378
|
+
return {
|
|
379
|
+
sortingField: subject,
|
|
380
|
+
sortType: "ASC",
|
|
381
|
+
[INTERNAL_KIND]: "ORDER_BY"
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
desc(subject) {
|
|
385
|
+
return {
|
|
386
|
+
sortingField: subject,
|
|
387
|
+
sortType: "DESC",
|
|
388
|
+
[INTERNAL_KIND]: "ORDER_BY"
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* Converts filter utility conditions to a format our backend supports
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ```tsx
|
|
397
|
+
* const query = q.text("title").contains("hello");
|
|
398
|
+
*
|
|
399
|
+
* const json = buildFilterCriteria(query);
|
|
400
|
+
* // result
|
|
401
|
+
* {
|
|
402
|
+
* "filterCriteria": {
|
|
403
|
+
* "filterConditionGroup": {
|
|
404
|
+
* "logicalOperator": "AND"
|
|
405
|
+
* "filterGroups": [{
|
|
406
|
+
* "logicalOperator": "AND",
|
|
407
|
+
* "filters": [{
|
|
408
|
+
* "subject": "title",
|
|
409
|
+
* "type": "TEXT",
|
|
410
|
+
* "operator": "CONTAINS",
|
|
411
|
+
* "value": "hello"
|
|
412
|
+
* }],
|
|
413
|
+
* }]
|
|
414
|
+
* }
|
|
415
|
+
* }
|
|
416
|
+
* }
|
|
417
|
+
*
|
|
418
|
+
* ```
|
|
419
|
+
*/
|
|
420
|
+
function buildFilterCriteria(input) {
|
|
421
|
+
if (!(INTERNAL_KIND in input)) throw new Error("Filters cannot be constructed manually. Use the 'q' helper.");
|
|
422
|
+
if (isFilter(input)) return { filterConditionGroup: {
|
|
423
|
+
logicalOperator: "AND",
|
|
424
|
+
filterGroups: [{
|
|
425
|
+
logicalOperator: "AND",
|
|
426
|
+
filters: [stripInternalKind(input)]
|
|
427
|
+
}]
|
|
428
|
+
} };
|
|
429
|
+
if (isFilterGroup(input)) {
|
|
430
|
+
if (input.filters.length === 0) return { filterConditionGroup: {
|
|
431
|
+
logicalOperator: input.logicalOperator,
|
|
432
|
+
filterGroups: []
|
|
433
|
+
} };
|
|
434
|
+
return { filterConditionGroup: {
|
|
435
|
+
logicalOperator: input.logicalOperator,
|
|
436
|
+
filterGroups: input.filters.map((inner) => {
|
|
437
|
+
if (isFilter(inner)) return {
|
|
438
|
+
logicalOperator: "AND",
|
|
439
|
+
filters: [stripInternalKind(inner)]
|
|
440
|
+
};
|
|
441
|
+
if (!inner.filters.every(isFilter)) throw new Error("Cannot nest filter groups more than three levels deep.");
|
|
442
|
+
return {
|
|
443
|
+
logicalOperator: inner.logicalOperator,
|
|
444
|
+
filters: inner.filters.map(stripInternalKind)
|
|
445
|
+
};
|
|
446
|
+
})
|
|
447
|
+
} };
|
|
448
|
+
}
|
|
449
|
+
throw new Error("Unknown filter kind: " + input[INTERNAL_KIND]);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Converts order-by utility conditions to a format our backend supports
|
|
453
|
+
*
|
|
454
|
+
* @example
|
|
455
|
+
* single orderBy
|
|
456
|
+
* ```tsx
|
|
457
|
+
* const orderBy = buildOrderBy(q.asc("title"));
|
|
458
|
+
* // result: [{ sortingField: "title", sortType: "ASC" }]
|
|
459
|
+
* ```
|
|
460
|
+
*
|
|
461
|
+
* @example
|
|
462
|
+
* multiple orderBys
|
|
463
|
+
* ```tsx
|
|
464
|
+
* const orderBy = buildOrderBy([q.desc("createdAt"), q.asc("title")]);
|
|
465
|
+
* // result: [
|
|
466
|
+
* // { sortingField: "createdAt", sortType: "DESC" },
|
|
467
|
+
* // { sortingField: "title", sortType: "ASC" }
|
|
468
|
+
* // ]
|
|
469
|
+
* ```
|
|
470
|
+
*/
|
|
471
|
+
function buildOrderBy(input) {
|
|
472
|
+
const orderBys = Array.isArray(input) ? input : [input];
|
|
473
|
+
if (!orderBys.every((ob) => ob[INTERNAL_KIND] === "ORDER_BY")) throw new Error("orderBy conditions cannot be constructed manually. Use the 'q' helper.");
|
|
474
|
+
return orderBys.map((orderBy) => ({
|
|
475
|
+
sortingField: orderBy.sortingField,
|
|
476
|
+
sortType: orderBy.sortType
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
function dealiasOrderBy(orderbys, aliases) {
|
|
480
|
+
return orderbys.map((orderBy) => {
|
|
481
|
+
const subject = aliases[orderBy.sortingField];
|
|
482
|
+
if (!subject) throw new Error(`Could not find an alias for subject "${orderBy.sortingField}"`);
|
|
483
|
+
return {
|
|
484
|
+
...orderBy,
|
|
485
|
+
sortingField: subject
|
|
486
|
+
};
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Replaces filter subjects with their aliased values.
|
|
491
|
+
*
|
|
492
|
+
* Throws an error if any filter subject does not have a corresponding alias in the provided mapping.
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* ```tsx
|
|
496
|
+
* const criteria = buildFilterCriteria(q.text("name").contains("John"));
|
|
497
|
+
* const aliases = { name: "abcxy123" };
|
|
498
|
+
* const dealiased = dealiasFilterCriteria(criteria, aliases);
|
|
499
|
+
* // Result: filter subject is now "abcxyz123" instead of "name"
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
function dealiasFilterCriteria(criteria, aliases) {
|
|
503
|
+
if (!criteria.filterConditionGroup) return criteria;
|
|
504
|
+
return { filterConditionGroup: {
|
|
505
|
+
...criteria.filterConditionGroup,
|
|
506
|
+
filterGroups: criteria.filterConditionGroup.filterGroups.map((group) => ({
|
|
507
|
+
...group,
|
|
508
|
+
filters: group.filters.map(dealiasFilter)
|
|
509
|
+
}))
|
|
510
|
+
} };
|
|
511
|
+
function dealiasFilter(filter) {
|
|
512
|
+
const subject = aliases[filter.subject];
|
|
513
|
+
if (!subject) throw new Error(`Could not find an alias for subject "${subject}"`);
|
|
514
|
+
return {
|
|
515
|
+
...filter,
|
|
516
|
+
subject
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function stripInternalKind(item) {
|
|
521
|
+
const { [INTERNAL_KIND]: _,...rest } = item;
|
|
522
|
+
return rest;
|
|
523
|
+
}
|
|
524
|
+
function isFilterGroup(item) {
|
|
525
|
+
return item[INTERNAL_KIND] === "LOGICAL_GROUP";
|
|
526
|
+
}
|
|
527
|
+
function isFilter(item) {
|
|
528
|
+
return item[INTERNAL_KIND] === "FILTER";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/assertions.ts
|
|
533
|
+
function assert(condition, message) {
|
|
534
|
+
if (!condition) throwAtCaller(assert, new AssertionError(unwrapErrorMessage(message)));
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Shorthand for throw new AssertionError("Error message")
|
|
538
|
+
* Useful because it is an expression instead of a statement.
|
|
539
|
+
* Can be assigned to any value, because it never returns.
|
|
540
|
+
*/
|
|
541
|
+
function panic(message) {
|
|
542
|
+
throwAtCaller(panic, new AssertionError(unwrapErrorMessage(message)));
|
|
543
|
+
}
|
|
544
|
+
function todo(message) {
|
|
545
|
+
throwAtCaller(todo, new UnimplementedError(unwrapErrorMessage(message)));
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* A soft assertion that throws an error in non prod environments, including
|
|
549
|
+
* staging, but logs an error in production mode.
|
|
550
|
+
* This is useful for cases where something unexpected happens, but you can still
|
|
551
|
+
* continue in a slightly broken state.
|
|
552
|
+
* For example, if a block has defined a style control with a hardcoded id and
|
|
553
|
+
* that style control is not found, this is definitely a bug, but it's still
|
|
554
|
+
* possible to go on rendering with a fallback value for this style.
|
|
555
|
+
* It is recommended to liberally use soft assertions so that we fail loudly in
|
|
556
|
+
* staging and local environments.
|
|
557
|
+
*
|
|
558
|
+
* A hard assertion would be more appropriate if we wanted to display some data
|
|
559
|
+
* from an object but that object is null. It's better to throw an assertion
|
|
560
|
+
* error at the place we detect this, instead of continuing and potentially
|
|
561
|
+
* causing an exception in an unrelated part of the code.
|
|
562
|
+
*/
|
|
563
|
+
function softAssert(condition, message) {
|
|
564
|
+
if (!condition) if (process.env.NODE_ENV !== "production") throwAtCaller(softAssert, new SoftAssertionError(unwrapErrorMessage(message)));
|
|
565
|
+
else console.error(`Assertion failed: ${unwrapErrorMessage(message)}`);
|
|
566
|
+
}
|
|
567
|
+
var AssertionError = class extends Error {
|
|
568
|
+
name = "AssertionError";
|
|
569
|
+
};
|
|
570
|
+
var SoftAssertionError = class extends Error {
|
|
571
|
+
name = "SoftAssertionError";
|
|
572
|
+
};
|
|
573
|
+
var UnimplementedError = class extends Error {
|
|
574
|
+
name = "UnimplementedError";
|
|
575
|
+
};
|
|
576
|
+
function unwrapErrorMessage(message) {
|
|
577
|
+
return typeof message === "function" ? message() : message ?? "";
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Uses chrome's non-standard Error.captureStackTrace to set the top of the stack
|
|
581
|
+
* trace to start from the caller of the supplied function {@param f}.
|
|
582
|
+
* Useful in assertion functions to make sure that the stack trace shows the spot
|
|
583
|
+
* where the assertion failed, not the place where the assertion was thrown.
|
|
584
|
+
*/
|
|
585
|
+
function throwAtCaller(f, ex) {
|
|
586
|
+
if (Error.captureStackTrace) Error.captureStackTrace(ex, f);
|
|
587
|
+
throw ex;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
//#endregion
|
|
591
|
+
//#region src/cookies.ts
|
|
592
|
+
function parseCookieHeader(header) {
|
|
593
|
+
const cookies = {};
|
|
594
|
+
if (!header) return cookies;
|
|
595
|
+
for (const pair of header.split(";")) {
|
|
596
|
+
const separator = pair.indexOf("=");
|
|
597
|
+
if (separator === -1) continue;
|
|
598
|
+
const name = pair.slice(0, separator).trim();
|
|
599
|
+
if (name.length === 0) continue;
|
|
600
|
+
cookies[name] = decode(pair.slice(separator + 1).trim());
|
|
601
|
+
}
|
|
602
|
+
return cookies;
|
|
603
|
+
}
|
|
604
|
+
function decode(value) {
|
|
605
|
+
try {
|
|
606
|
+
return decodeURIComponent(value);
|
|
607
|
+
} catch {
|
|
608
|
+
return value;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function readRequestCookies() {
|
|
612
|
+
return parseCookieHeader(getRequestHeader("cookie"));
|
|
613
|
+
}
|
|
614
|
+
function serializeCookie(name, value, { maxAge, secure }) {
|
|
615
|
+
if (name.startsWith("__Host-") && !secure) throw new Error(`Refusing to write ${name} without Secure: a __Host- cookie is only valid when Secure, Path=/ and free of Domain, and a browser silently discards one that is not.`);
|
|
616
|
+
const parts = [
|
|
617
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
618
|
+
"Path=/",
|
|
619
|
+
"HttpOnly"
|
|
620
|
+
];
|
|
621
|
+
if (secure) parts.push("Secure");
|
|
622
|
+
parts.push("SameSite=Lax", `Max-Age=${maxAge}`);
|
|
623
|
+
return parts.join("; ");
|
|
624
|
+
}
|
|
625
|
+
const USER_TIMEZONE_COOKIE = "user-timezone";
|
|
626
|
+
function readUserTimezone() {
|
|
627
|
+
return readRequestCookies()[USER_TIMEZONE_COOKIE] ?? null;
|
|
628
|
+
}
|
|
629
|
+
function writeCookies(cookies) {
|
|
630
|
+
if (cookies.length === 0) return;
|
|
631
|
+
setResponseHeader("Set-Cookie", [...getResponseHeaders().getSetCookie(), ...cookies]);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/auth/config.ts
|
|
636
|
+
const DEFAULT_SESSION_MAX_AGE = 2160 * 60 * 60;
|
|
637
|
+
function loadSessionConfig() {
|
|
638
|
+
const missing = [];
|
|
639
|
+
const applicationId = process.env.SOFTR_APPLICATION_ID;
|
|
640
|
+
if (!applicationId) missing.push("SOFTR_APPLICATION_ID");
|
|
641
|
+
const sessionSecret = process.env.SOFTR_SESSION_SECRET;
|
|
642
|
+
if (!sessionSecret) missing.push("SOFTR_SESSION_SECRET");
|
|
643
|
+
if (missing.length > 0) throw new Error(`Softr auth is not configured: missing ${missing.join(", ")}.`);
|
|
644
|
+
assert(applicationId && sessionSecret);
|
|
645
|
+
const rawMaxAge = process.env.SOFTR_SESSION_MAX_AGE;
|
|
646
|
+
let sessionMaxAge = DEFAULT_SESSION_MAX_AGE;
|
|
647
|
+
if (rawMaxAge) {
|
|
648
|
+
sessionMaxAge = Number(rawMaxAge);
|
|
649
|
+
if (!Number.isInteger(sessionMaxAge) || sessionMaxAge <= 0) throw new Error(`SOFTR_SESSION_MAX_AGE must be a positive whole number of seconds, got "${rawMaxAge}".`);
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
applicationId,
|
|
653
|
+
sessionSecret,
|
|
654
|
+
sessionMaxAge
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
let cached;
|
|
658
|
+
function sessionConfig() {
|
|
659
|
+
cached ??= loadSessionConfig();
|
|
660
|
+
return cached;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/auth/platform.ts
|
|
665
|
+
const contextSchema = z.object({ platformApiUrl: z.string() });
|
|
666
|
+
/** Calls one of this app's user endpoints on the Softr platform. */
|
|
667
|
+
async function platformFetch(path, init) {
|
|
668
|
+
const { applicationId } = sessionConfig();
|
|
669
|
+
const origin = await platformApiUrl();
|
|
670
|
+
return fetch(`${origin}/v1/applications/${applicationId}/users/${path}`, init);
|
|
671
|
+
}
|
|
672
|
+
/** Origin of the Softr platform API, with no trailing slash. */
|
|
673
|
+
async function platformApiUrl() {
|
|
674
|
+
const pinned = process.env.SOFTR_PLATFORM_API_URL;
|
|
675
|
+
if (pinned) return withoutTrailingSlash(pinned);
|
|
676
|
+
const studioApiUrl = process.env.SOFTR_STUDIO_API_URL;
|
|
677
|
+
if (!studioApiUrl || process.env.NODE_ENV === "production") panic("Softr auth is not configured: SOFTR_PLATFORM_API_URL is not set.");
|
|
678
|
+
const { applicationId } = sessionConfig();
|
|
679
|
+
const url = `${withoutTrailingSlash(studioApiUrl)}/v1/api/applications/${applicationId}/context/public`;
|
|
680
|
+
let response;
|
|
681
|
+
try {
|
|
682
|
+
response = await fetch(url, { headers: { accept: "application/json" } });
|
|
683
|
+
} catch {
|
|
684
|
+
panic(`Could not reach studio-api: ${url}`);
|
|
685
|
+
}
|
|
686
|
+
if (response.status === 404) panic(`studio-api does not know application ${applicationId}`);
|
|
687
|
+
if (!response.ok) panic(`studio-api answered ${response.status} for ${url}`);
|
|
688
|
+
const body = contextSchema.safeParse(await response.json().catch(() => null));
|
|
689
|
+
if (!body.success) panic(`studio-api returned no platformApiUrl: ${url}`);
|
|
690
|
+
return withoutTrailingSlash(body.data.platformApiUrl);
|
|
691
|
+
}
|
|
692
|
+
function withoutTrailingSlash(url) {
|
|
693
|
+
return url.replace(/\/+$/, "");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/auth/me.ts
|
|
698
|
+
/**
|
|
699
|
+
* `null` means the platform refused the token — any 4xx, because `/me` answers
|
|
700
|
+
* 400 rather than 401 for an expired one, so only the class of the status is
|
|
701
|
+
* informative. Anything else that is not a 200 throws.
|
|
702
|
+
*/
|
|
703
|
+
async function fetchVisitor(token) {
|
|
704
|
+
const response = await platformFetch("me", {
|
|
705
|
+
method: "GET",
|
|
706
|
+
headers: {
|
|
707
|
+
authorization: `Bearer ${token}`,
|
|
708
|
+
accept: "application/json"
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
if (response.status >= 400 && response.status < 500) return null;
|
|
712
|
+
if (!response.ok) throw new Error(`Softr platform did not answer /me: ${response.status} ${response.statusText}`.trim());
|
|
713
|
+
return toUser(await response.json());
|
|
714
|
+
}
|
|
715
|
+
const meSchema = z.object({
|
|
716
|
+
id: z.string(),
|
|
717
|
+
full_name: z.string().nullish(),
|
|
718
|
+
credentials: z.object({ email: z.string().nullish() }).nullish()
|
|
719
|
+
});
|
|
720
|
+
function toUser(body) {
|
|
721
|
+
const me = meSchema.safeParse(body);
|
|
722
|
+
if (!me.success) throw new Error(`Softr platform answered /me with something that is not a user: ${z.prettifyError(me.error)}`);
|
|
723
|
+
return {
|
|
724
|
+
id: me.data.id,
|
|
725
|
+
email: me.data.credentials?.email ?? "",
|
|
726
|
+
name: me.data.full_name ?? "",
|
|
727
|
+
userGroupIds: []
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
//#endregion
|
|
732
|
+
//#region src/auth/session.ts
|
|
733
|
+
const SESSION_TOKEN = "session_token";
|
|
734
|
+
const SESSION_DATA = "session_data";
|
|
735
|
+
const SESSION_DATA_TTL_SECONDS = 300;
|
|
736
|
+
/**
|
|
737
|
+
* A browser refuses a `__Host-` cookie unless it is `Secure`, `Path=/` and free
|
|
738
|
+
* of `Domain` — that last clause is the one worth having, since it stops a
|
|
739
|
+
* sibling host planting a cookie of this name for us to honour.
|
|
740
|
+
*/
|
|
741
|
+
const HOST_PREFIX = "__Host-";
|
|
742
|
+
function requestIsSecure() {
|
|
743
|
+
return getRequestProtocol() === "https";
|
|
744
|
+
}
|
|
745
|
+
function sessionCookie(base) {
|
|
746
|
+
const secure = requestIsSecure();
|
|
747
|
+
return {
|
|
748
|
+
name: secure ? HOST_PREFIX + base : base,
|
|
749
|
+
secure
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/** The platform token this request carries, or `null`. */
|
|
753
|
+
function readSessionToken() {
|
|
754
|
+
const { name } = sessionCookie(SESSION_TOKEN);
|
|
755
|
+
return openEnvelope(readRequestCookies()[name], SESSION_TOKEN);
|
|
756
|
+
}
|
|
757
|
+
/** The cached visitor for `sessionToken`, or `null` on any kind of miss. */
|
|
758
|
+
function readSessionData(sessionToken) {
|
|
759
|
+
const { name } = sessionCookie(SESSION_DATA);
|
|
760
|
+
const payload = parseSessionData(openEnvelope(readRequestCookies()[name], SESSION_DATA));
|
|
761
|
+
if (payload === null) return null;
|
|
762
|
+
if (payload.exp <= Math.floor(Date.now() / 1e3)) return null;
|
|
763
|
+
if (payload.tb !== tokenBinding(sessionToken)) return null;
|
|
764
|
+
return {
|
|
765
|
+
id: payload.id,
|
|
766
|
+
email: payload.email,
|
|
767
|
+
name: payload.name,
|
|
768
|
+
userGroupIds: payload.userGroupIds
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function makeSessionTokenCookie(token) {
|
|
772
|
+
const { name, secure } = sessionCookie(SESSION_TOKEN);
|
|
773
|
+
return serializeCookie(name, sealEnvelope(token, SESSION_TOKEN), {
|
|
774
|
+
maxAge: sessionConfig().sessionMaxAge,
|
|
775
|
+
secure
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
function makeSessionDataCookie(user, sessionToken) {
|
|
779
|
+
const { name, secure } = sessionCookie(SESSION_DATA);
|
|
780
|
+
const payload = {
|
|
781
|
+
id: user.id,
|
|
782
|
+
email: user.email,
|
|
783
|
+
name: user.name,
|
|
784
|
+
userGroupIds: user.userGroupIds,
|
|
785
|
+
exp: Math.floor(Date.now() / 1e3) + SESSION_DATA_TTL_SECONDS,
|
|
786
|
+
tb: tokenBinding(sessionToken)
|
|
787
|
+
};
|
|
788
|
+
return serializeCookie(name, sealEnvelope(JSON.stringify(payload), SESSION_DATA), {
|
|
789
|
+
maxAge: SESSION_DATA_TTL_SECONDS,
|
|
790
|
+
secure
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Writes rather than returns: a forgotten list would leave the session live.
|
|
795
|
+
*/
|
|
796
|
+
function clearSession() {
|
|
797
|
+
clearCookies([SESSION_TOKEN, SESSION_DATA]);
|
|
798
|
+
}
|
|
799
|
+
const sessionDataSchema = z.object({
|
|
800
|
+
id: z.string(),
|
|
801
|
+
email: z.string(),
|
|
802
|
+
name: z.string(),
|
|
803
|
+
userGroupIds: z.array(z.string()),
|
|
804
|
+
exp: z.number(),
|
|
805
|
+
tb: z.string()
|
|
806
|
+
});
|
|
807
|
+
/**
|
|
808
|
+
* Both spellings on https: a jar can hold both pairs across a move between
|
|
809
|
+
* schemes, and the one left behind would revive if the scheme changed back.
|
|
810
|
+
*/
|
|
811
|
+
function clearCookies(bases) {
|
|
812
|
+
const secure = requestIsSecure();
|
|
813
|
+
writeCookies(bases.flatMap((base) => secure ? [HOST_PREFIX + base, base] : [base]).map((name) => serializeCookie(name, "", {
|
|
814
|
+
maxAge: 0,
|
|
815
|
+
secure
|
|
816
|
+
})));
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Over the plaintext token, never the cookie value: that is resealed with a
|
|
820
|
+
* fresh iv on every write, so a binding over its bytes would be a permanent
|
|
821
|
+
* cache miss.
|
|
822
|
+
*/
|
|
823
|
+
function tokenBinding(sessionToken) {
|
|
824
|
+
return createHash("sha256").update(sessionToken).digest("base64url");
|
|
825
|
+
}
|
|
826
|
+
function parseSessionData(json) {
|
|
827
|
+
if (json === null) return null;
|
|
828
|
+
let payload;
|
|
829
|
+
try {
|
|
830
|
+
payload = JSON.parse(json);
|
|
831
|
+
} catch {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
const data = sessionDataSchema.safeParse(payload);
|
|
835
|
+
return data.success ? data.data : null;
|
|
836
|
+
}
|
|
837
|
+
const IV_BYTES = 12;
|
|
838
|
+
const TAG_BYTES = 16;
|
|
839
|
+
/** Iv + one byte of ciphertext + tag. Anything shorter is not ours. */
|
|
840
|
+
const MIN_ENVELOPE_BYTES = IV_BYTES + TAG_BYTES + 1;
|
|
841
|
+
/**
|
|
842
|
+
* `Buffer.from(…, "base64url")` skips stray characters rather than throwing.
|
|
843
|
+
*/
|
|
844
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
845
|
+
/**
|
|
846
|
+
* One key for both cookies. The application id goes in with the secret so a
|
|
847
|
+
* cookie from staging is inert in production even when the two share a secret.
|
|
848
|
+
*/
|
|
849
|
+
function sessionKey() {
|
|
850
|
+
const { applicationId, sessionSecret } = sessionConfig();
|
|
851
|
+
return createHash("sha256").update(Buffer.from(sessionSecret, "hex")).update(applicationId).digest();
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* `base64url(iv ‖ ct ‖ tag)`, sealed with AES-256-GCM — an AEAD, so opening
|
|
855
|
+
* throws on a single flipped bit and needs no separate signature. The iv must
|
|
856
|
+
* be fresh per seal: nonce reuse under one key leaks the payloads and the
|
|
857
|
+
* authentication subkey.
|
|
858
|
+
*
|
|
859
|
+
* The purpose is bound in as additional authenticated data rather than by
|
|
860
|
+
* giving each cookie a key of its own: one key, and a `session_data` value
|
|
861
|
+
* moved into the token slot then fails to open at all instead of opening into
|
|
862
|
+
* a later field check.
|
|
863
|
+
*/
|
|
864
|
+
function sealEnvelope(payload, purpose) {
|
|
865
|
+
const iv = randomBytes(IV_BYTES);
|
|
866
|
+
const cipher = createCipheriv("aes-256-gcm", sessionKey(), iv);
|
|
867
|
+
cipher.setAAD(Buffer.from(purpose));
|
|
868
|
+
const sealed = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
|
|
869
|
+
return Buffer.concat([
|
|
870
|
+
iv,
|
|
871
|
+
sealed,
|
|
872
|
+
cipher.getAuthTag()
|
|
873
|
+
]).toString("base64url");
|
|
874
|
+
}
|
|
875
|
+
/** `null` for anything that is not one of ours, with no reason attached. */
|
|
876
|
+
function openEnvelope(raw, purpose) {
|
|
877
|
+
if (!raw || !BASE64URL.test(raw)) return null;
|
|
878
|
+
try {
|
|
879
|
+
const bytes = Buffer.from(raw, "base64url");
|
|
880
|
+
if (bytes.length < MIN_ENVELOPE_BYTES) return null;
|
|
881
|
+
const decipher = createDecipheriv("aes-256-gcm", sessionKey(), bytes.subarray(0, IV_BYTES));
|
|
882
|
+
decipher.setAAD(Buffer.from(purpose));
|
|
883
|
+
decipher.setAuthTag(bytes.subarray(bytes.length - TAG_BYTES));
|
|
884
|
+
return Buffer.concat([decipher.update(bytes.subarray(IV_BYTES, bytes.length - TAG_BYTES)), decipher.final()]).toString("utf8");
|
|
885
|
+
} catch {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
//#endregion
|
|
891
|
+
export { dealiasOrderBy as _, readSessionToken as a, readUserTimezone as c, panic as d, softAssert as f, dealiasFilterCriteria as g, buildOrderBy as h, readSessionData as i, writeCookies as l, buildFilterCriteria as m, makeSessionDataCookie as n, fetchVisitor as o, todo as p, makeSessionTokenCookie as r, platformFetch as s, clearSession as t, assert as u, q as v };
|