@saacms/core 0.1.5 → 0.1.6
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-dy25fetw.js +3037 -0
- package/dist/index.js +1 -1
- package/dist/runtime/index.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,3037 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveHooksFor,
|
|
3
|
+
runHooks
|
|
4
|
+
} from "./index-zgbq60fy.js";
|
|
5
|
+
import {
|
|
6
|
+
collectionToOpenApiPaths,
|
|
7
|
+
filterOpenApiForUser,
|
|
8
|
+
slugToTableName
|
|
9
|
+
} from "./index-a3pnt8yz.js";
|
|
10
|
+
import {
|
|
11
|
+
withMediaFields
|
|
12
|
+
} from "./index-8g8ymd37.js";
|
|
13
|
+
|
|
14
|
+
// src/runtime/index.ts
|
|
15
|
+
import { Hono } from "hono";
|
|
16
|
+
|
|
17
|
+
// src/runtime/auth-middleware.ts
|
|
18
|
+
var LOG_PREFIX = "[saacms/auth]";
|
|
19
|
+
function mountAuthMiddleware(app, config) {
|
|
20
|
+
const adapter = config.auth;
|
|
21
|
+
if (adapter == null) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
app.use("*", async (c, next) => {
|
|
25
|
+
try {
|
|
26
|
+
const user = await adapter.getSession(c.req.raw);
|
|
27
|
+
if (user != null) {
|
|
28
|
+
c.set("user", user);
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.warn(`${LOG_PREFIX} adapter "${adapter.name}" threw:`, err);
|
|
32
|
+
}
|
|
33
|
+
await next();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/runtime/create-route.ts
|
|
38
|
+
import { Cause, Effect, Either, Exit, Schema } from "effect";
|
|
39
|
+
import { ArrayFormatter, TreeFormatter } from "effect/ParseResult";
|
|
40
|
+
|
|
41
|
+
// src/storage/index.ts
|
|
42
|
+
class RowStorageError extends Error {
|
|
43
|
+
code;
|
|
44
|
+
cause;
|
|
45
|
+
name = "RowStorageError";
|
|
46
|
+
constructor(code, message, cause) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.cause = cause;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class UniqueConstraintError extends Error {
|
|
54
|
+
table;
|
|
55
|
+
name = "UniqueConstraintError";
|
|
56
|
+
constructor(table, cause) {
|
|
57
|
+
super(`Unique constraint violated on table "${table}"`);
|
|
58
|
+
this.table = table;
|
|
59
|
+
if (cause != null)
|
|
60
|
+
this.cause = cause;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/runtime/services.ts
|
|
65
|
+
var CONTEXT_KEY = "saacmsServices";
|
|
66
|
+
var EMPTY_SERVICES = Object.freeze({});
|
|
67
|
+
function collectServices(config) {
|
|
68
|
+
const merged = {};
|
|
69
|
+
for (const plugin of config.plugins ?? []) {
|
|
70
|
+
const services = plugin.services;
|
|
71
|
+
if (services == null) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
Object.assign(merged, services);
|
|
75
|
+
}
|
|
76
|
+
return Object.freeze(merged);
|
|
77
|
+
}
|
|
78
|
+
function getServices(c) {
|
|
79
|
+
const raw = c.get(CONTEXT_KEY);
|
|
80
|
+
return raw != null && typeof raw === "object" ? raw : EMPTY_SERVICES;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/observability/audit.ts
|
|
84
|
+
var NO_OP_SINK = Object.freeze({ emit() {} });
|
|
85
|
+
function recordAuditEvent(c, event) {
|
|
86
|
+
const sink = getServices(c).auditSink ?? NO_OP_SINK;
|
|
87
|
+
let result;
|
|
88
|
+
try {
|
|
89
|
+
result = sink.emit(event);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.warn("[saacms/audit] sink threw synchronously (best-effort, not propagated):", err);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (result instanceof Promise) {
|
|
95
|
+
result.catch((err) => {
|
|
96
|
+
console.warn("[saacms/audit] sink rejected async (best-effort, not propagated):", err);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/runtime/cache.ts
|
|
102
|
+
var inIsolateStore = new Map;
|
|
103
|
+
var inIsolateTagIndex = new Map;
|
|
104
|
+
function unindexKey(key, tags) {
|
|
105
|
+
for (const tag of tags) {
|
|
106
|
+
const keys = inIsolateTagIndex.get(tag);
|
|
107
|
+
if (keys == null)
|
|
108
|
+
continue;
|
|
109
|
+
keys.delete(key);
|
|
110
|
+
if (keys.size === 0)
|
|
111
|
+
inIsolateTagIndex.delete(tag);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
var clock = () => Date.now();
|
|
115
|
+
var inIsolateCache = {
|
|
116
|
+
async get(key) {
|
|
117
|
+
const cell = inIsolateStore.get(key);
|
|
118
|
+
if (cell == null)
|
|
119
|
+
return null;
|
|
120
|
+
if (cell.expiresAtMs != null && clock() >= cell.expiresAtMs) {
|
|
121
|
+
inIsolateStore.delete(key);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return cell.entry;
|
|
125
|
+
},
|
|
126
|
+
async put(key, value, opts) {
|
|
127
|
+
const tags = opts?.tags ?? [];
|
|
128
|
+
const ttl = opts?.expirationTtl;
|
|
129
|
+
const expiresAtMs = ttl != null && ttl > 0 ? clock() + ttl * 1000 : null;
|
|
130
|
+
const prev = inIsolateStore.get(key);
|
|
131
|
+
if (prev != null)
|
|
132
|
+
unindexKey(key, prev.entry.tags);
|
|
133
|
+
inIsolateStore.set(key, {
|
|
134
|
+
entry: { value, tags, storedAt: new Date(clock()).toISOString() },
|
|
135
|
+
expiresAtMs
|
|
136
|
+
});
|
|
137
|
+
for (const tag of tags) {
|
|
138
|
+
let keys = inIsolateTagIndex.get(tag);
|
|
139
|
+
if (keys == null) {
|
|
140
|
+
keys = new Set;
|
|
141
|
+
inIsolateTagIndex.set(tag, keys);
|
|
142
|
+
}
|
|
143
|
+
keys.add(key);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
async purgeByTag(tag) {
|
|
147
|
+
const keys = inIsolateTagIndex.get(tag);
|
|
148
|
+
if (keys == null)
|
|
149
|
+
return { purgedKeys: 0 };
|
|
150
|
+
let purgedKeys = 0;
|
|
151
|
+
for (const key of [...keys]) {
|
|
152
|
+
const cell = inIsolateStore.get(key);
|
|
153
|
+
if (cell == null)
|
|
154
|
+
continue;
|
|
155
|
+
unindexKey(key, cell.entry.tags);
|
|
156
|
+
inIsolateStore.delete(key);
|
|
157
|
+
purgedKeys++;
|
|
158
|
+
}
|
|
159
|
+
inIsolateTagIndex.delete(tag);
|
|
160
|
+
return { purgedKeys };
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
function hasGetPut(x) {
|
|
164
|
+
return x != null && typeof x === "object" && typeof x.get === "function" && typeof x.put === "function";
|
|
165
|
+
}
|
|
166
|
+
function hasPurgeByTag(x) {
|
|
167
|
+
return typeof x.purgeByTag === "function";
|
|
168
|
+
}
|
|
169
|
+
var warnedNoPurge = false;
|
|
170
|
+
function withNoopPurge(base) {
|
|
171
|
+
return {
|
|
172
|
+
get: (key) => base.get(key),
|
|
173
|
+
put: (key, value, opts) => base.put(key, value, opts),
|
|
174
|
+
async purgeByTag(_tag) {
|
|
175
|
+
if (!warnedNoPurge) {
|
|
176
|
+
warnedNoPurge = true;
|
|
177
|
+
console.warn("[saacms/cache] service has no purgeByTag; tag invalidation is a no-op");
|
|
178
|
+
}
|
|
179
|
+
return { purgedKeys: 0 };
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function resolveCache(c) {
|
|
184
|
+
const candidate = getServices(c).cache;
|
|
185
|
+
if (!hasGetPut(candidate))
|
|
186
|
+
return inIsolateCache;
|
|
187
|
+
return hasPurgeByTag(candidate) ? candidate : withNoopPurge(candidate);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/runtime/boolean-columns.ts
|
|
191
|
+
import { SchemaAST as AST } from "effect";
|
|
192
|
+
function isNullKeyword(ast) {
|
|
193
|
+
return AST.isLiteral(ast) && ast.literal === null;
|
|
194
|
+
}
|
|
195
|
+
function resolveInner(signature) {
|
|
196
|
+
let t = signature.type;
|
|
197
|
+
if (AST.isUnion(t)) {
|
|
198
|
+
const nonNullish = t.types.filter((m) => !AST.isUndefinedKeyword(m) && !isNullKeyword(m));
|
|
199
|
+
if (nonNullish.length === 1) {
|
|
200
|
+
t = nonNullish[0];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
while (AST.isRefinement(t)) {
|
|
204
|
+
t = t.from;
|
|
205
|
+
}
|
|
206
|
+
return t;
|
|
207
|
+
}
|
|
208
|
+
function booleanColumnNames(schema) {
|
|
209
|
+
const out = new Set;
|
|
210
|
+
const root = schema.ast;
|
|
211
|
+
if (!AST.isTypeLiteral(root))
|
|
212
|
+
return out;
|
|
213
|
+
for (const ps of root.propertySignatures) {
|
|
214
|
+
const name = String(ps.name);
|
|
215
|
+
if (name === "id" || name === "createdAt" || name === "updatedAt")
|
|
216
|
+
continue;
|
|
217
|
+
if (AST.isBooleanKeyword(resolveInner(ps)))
|
|
218
|
+
out.add(name);
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
function encodeBooleanColumns(schema, record) {
|
|
223
|
+
const cols = booleanColumnNames(schema);
|
|
224
|
+
if (cols.size === 0)
|
|
225
|
+
return record;
|
|
226
|
+
const out = { ...record };
|
|
227
|
+
for (const key of cols) {
|
|
228
|
+
if (!(key in out))
|
|
229
|
+
continue;
|
|
230
|
+
const v = out[key];
|
|
231
|
+
if (typeof v !== "boolean")
|
|
232
|
+
continue;
|
|
233
|
+
out[key] = v ? 1 : 0;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
function decodeBooleanColumns(schema, record) {
|
|
238
|
+
const cols = booleanColumnNames(schema);
|
|
239
|
+
if (cols.size === 0)
|
|
240
|
+
return record;
|
|
241
|
+
const out = { ...record };
|
|
242
|
+
for (const key of cols) {
|
|
243
|
+
if (!(key in out))
|
|
244
|
+
continue;
|
|
245
|
+
const v = out[key];
|
|
246
|
+
if (typeof v === "number") {
|
|
247
|
+
if (v === 1)
|
|
248
|
+
out[key] = true;
|
|
249
|
+
else if (v === 0)
|
|
250
|
+
out[key] = false;
|
|
251
|
+
} else if (typeof v === "bigint") {
|
|
252
|
+
if (v === 1n)
|
|
253
|
+
out[key] = true;
|
|
254
|
+
else if (v === 0n)
|
|
255
|
+
out[key] = false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/runtime/json-columns.ts
|
|
262
|
+
import { SchemaAST as AST2 } from "effect";
|
|
263
|
+
function isNullKeyword2(ast) {
|
|
264
|
+
return AST2.isLiteral(ast) && ast.literal === null;
|
|
265
|
+
}
|
|
266
|
+
function resolveInner2(signature) {
|
|
267
|
+
let t = signature.type;
|
|
268
|
+
if (AST2.isUnion(t)) {
|
|
269
|
+
const nonNullish = t.types.filter((m) => !AST2.isUndefinedKeyword(m) && !isNullKeyword2(m));
|
|
270
|
+
if (nonNullish.length === 1) {
|
|
271
|
+
t = nonNullish[0];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
while (AST2.isRefinement(t)) {
|
|
275
|
+
t = t.from;
|
|
276
|
+
}
|
|
277
|
+
return t;
|
|
278
|
+
}
|
|
279
|
+
function isJsonColumnType(inner) {
|
|
280
|
+
return AST2.isTupleType(inner) || AST2.isTypeLiteral(inner) || AST2.isUnknownKeyword(inner) || AST2.isAnyKeyword(inner) || AST2.isObjectKeyword(inner);
|
|
281
|
+
}
|
|
282
|
+
function jsonColumnNames(schema) {
|
|
283
|
+
const out = new Set;
|
|
284
|
+
const root = schema.ast;
|
|
285
|
+
if (!AST2.isTypeLiteral(root))
|
|
286
|
+
return out;
|
|
287
|
+
for (const ps of root.propertySignatures) {
|
|
288
|
+
const name = String(ps.name);
|
|
289
|
+
if (name === "id" || name === "createdAt" || name === "updatedAt")
|
|
290
|
+
continue;
|
|
291
|
+
if (isJsonColumnType(resolveInner2(ps)))
|
|
292
|
+
out.add(name);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
function encodeJsonColumns(schema, record) {
|
|
297
|
+
const cols = jsonColumnNames(schema);
|
|
298
|
+
if (cols.size === 0)
|
|
299
|
+
return record;
|
|
300
|
+
const out = { ...record };
|
|
301
|
+
for (const key of cols) {
|
|
302
|
+
if (!(key in out))
|
|
303
|
+
continue;
|
|
304
|
+
const v = out[key];
|
|
305
|
+
if (v === null || v === undefined || typeof v === "string")
|
|
306
|
+
continue;
|
|
307
|
+
out[key] = JSON.stringify(v);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
function decodeJsonColumns(schema, record) {
|
|
312
|
+
const cols = jsonColumnNames(schema);
|
|
313
|
+
if (cols.size === 0)
|
|
314
|
+
return record;
|
|
315
|
+
const out = { ...record };
|
|
316
|
+
for (const key of cols) {
|
|
317
|
+
if (!(key in out))
|
|
318
|
+
continue;
|
|
319
|
+
const v = out[key];
|
|
320
|
+
if (typeof v !== "string")
|
|
321
|
+
continue;
|
|
322
|
+
try {
|
|
323
|
+
out[key] = JSON.parse(v);
|
|
324
|
+
} catch {}
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/runtime/problem-details.ts
|
|
330
|
+
var PROBLEM_TYPE_BASE = "https://saacms.dev/errors";
|
|
331
|
+
function problemDetails(c, options) {
|
|
332
|
+
const body = {
|
|
333
|
+
type: `${PROBLEM_TYPE_BASE}/${options.code}`,
|
|
334
|
+
title: options.title,
|
|
335
|
+
status: options.status
|
|
336
|
+
};
|
|
337
|
+
if (options.detail !== undefined)
|
|
338
|
+
body.detail = options.detail;
|
|
339
|
+
if (options.instance !== undefined)
|
|
340
|
+
body.instance = options.instance;
|
|
341
|
+
if (options.extensions !== undefined) {
|
|
342
|
+
for (const [k, v] of Object.entries(options.extensions)) {
|
|
343
|
+
if (k in body)
|
|
344
|
+
continue;
|
|
345
|
+
body[k] = v;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return c.newResponse(JSON.stringify(body), options.status, {
|
|
349
|
+
"Content-Type": "application/problem+json"
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/runtime/create-route.ts
|
|
354
|
+
var CREATE_PATH = "/api/saacms/v1/:collection";
|
|
355
|
+
var PATH_PREFIX = "/api/saacms/v1/";
|
|
356
|
+
function mountCreateRoute(app, config) {
|
|
357
|
+
const collections = config.collections ?? [];
|
|
358
|
+
app.post(CREATE_PATH, async (c) => {
|
|
359
|
+
const slug = c.req.param("collection") ?? "";
|
|
360
|
+
const collection = collections.find((coll) => coll.slug === slug);
|
|
361
|
+
if (collection == null) {
|
|
362
|
+
return problemDetails(c, {
|
|
363
|
+
code: "collection-not-found",
|
|
364
|
+
title: "Collection not found",
|
|
365
|
+
status: 404,
|
|
366
|
+
detail: slug
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const user = readUser(c);
|
|
370
|
+
const rows = config.storage?.rows;
|
|
371
|
+
if (rows == null) {
|
|
372
|
+
return problemDetails(c, {
|
|
373
|
+
code: "storage-unavailable",
|
|
374
|
+
title: "Row storage adapter not configured",
|
|
375
|
+
status: 503,
|
|
376
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
380
|
+
return problemDetails(c, {
|
|
381
|
+
code: "unsupported-media-type",
|
|
382
|
+
title: "Unsupported media type",
|
|
383
|
+
status: 415,
|
|
384
|
+
detail: "Content-Type must be application/json"
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
let parsedBody;
|
|
388
|
+
try {
|
|
389
|
+
parsedBody = await c.req.json();
|
|
390
|
+
} catch {
|
|
391
|
+
return problemDetails(c, {
|
|
392
|
+
code: "malformed-body",
|
|
393
|
+
title: "Malformed request body",
|
|
394
|
+
status: 400,
|
|
395
|
+
detail: "Request body is not valid JSON"
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const decode = Schema.decodeUnknownEither(collection.schema);
|
|
399
|
+
const decoded = decode(parsedBody);
|
|
400
|
+
if (Either.isLeft(decoded)) {
|
|
401
|
+
const error = decoded.left;
|
|
402
|
+
return problemDetails(c, {
|
|
403
|
+
code: "invalid-body",
|
|
404
|
+
title: "Request body failed schema validation",
|
|
405
|
+
status: 422,
|
|
406
|
+
detail: TreeFormatter.formatErrorSync(error),
|
|
407
|
+
extensions: { issues: ArrayFormatter.formatErrorSync(error) }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
const validatedBody = decoded.right;
|
|
411
|
+
let finalBody = validatedBody;
|
|
412
|
+
const beforeHooks = resolveHooksFor("beforeChange", {
|
|
413
|
+
collection,
|
|
414
|
+
plugins: config.plugins
|
|
415
|
+
});
|
|
416
|
+
if (beforeHooks.length > 0) {
|
|
417
|
+
const ctx = {
|
|
418
|
+
op: "create",
|
|
419
|
+
user,
|
|
420
|
+
data: validatedBody
|
|
421
|
+
};
|
|
422
|
+
const exit = await Effect.runPromiseExit(runHooks("beforeChange", ctx, beforeHooks));
|
|
423
|
+
if (Exit.isFailure(exit)) {
|
|
424
|
+
return problemDetails(c, {
|
|
425
|
+
code: "hook-rejected",
|
|
426
|
+
title: "Operation rejected by a lifecycle hook",
|
|
427
|
+
status: 422,
|
|
428
|
+
detail: hookRejectionDetail(exit.cause)
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (isHookObject(exit.value))
|
|
432
|
+
finalBody = exit.value;
|
|
433
|
+
}
|
|
434
|
+
const createPredicate = collection.access?.create;
|
|
435
|
+
if (createPredicate != null) {
|
|
436
|
+
const result = await createPredicate({ user, record: finalBody });
|
|
437
|
+
if (!allows(result, finalBody)) {
|
|
438
|
+
recordAuditEvent(c, {
|
|
439
|
+
op: "create",
|
|
440
|
+
collection: slug,
|
|
441
|
+
user,
|
|
442
|
+
decision: "denied",
|
|
443
|
+
reason: "access.create predicate denied",
|
|
444
|
+
at: new Date().toISOString()
|
|
445
|
+
});
|
|
446
|
+
return problemDetails(c, {
|
|
447
|
+
code: "forbidden",
|
|
448
|
+
title: "Forbidden",
|
|
449
|
+
status: 403,
|
|
450
|
+
detail: slug
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
recordAuditEvent(c, {
|
|
454
|
+
op: "create",
|
|
455
|
+
collection: slug,
|
|
456
|
+
user,
|
|
457
|
+
decision: "granted",
|
|
458
|
+
at: new Date().toISOString()
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
let newId;
|
|
462
|
+
try {
|
|
463
|
+
const storageBody = encodeJsonColumns(collection.schema, encodeBooleanColumns(collection.schema, finalBody));
|
|
464
|
+
const result = await rows.insert(slugToTableName(collection.slug), storageBody);
|
|
465
|
+
newId = result.id;
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (err instanceof UniqueConstraintError) {
|
|
468
|
+
return problemDetails(c, {
|
|
469
|
+
code: "unique-constraint-violated",
|
|
470
|
+
title: "Unique constraint violated",
|
|
471
|
+
status: 409,
|
|
472
|
+
detail: "A record with the same value(s) for the unique fields already exists."
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
const afterHooks = resolveHooksFor("afterChange", {
|
|
478
|
+
collection,
|
|
479
|
+
plugins: config.plugins
|
|
480
|
+
});
|
|
481
|
+
if (afterHooks.length > 0) {
|
|
482
|
+
const ctx = {
|
|
483
|
+
op: "create",
|
|
484
|
+
user,
|
|
485
|
+
record: { ...finalBody, id: newId }
|
|
486
|
+
};
|
|
487
|
+
const exit = await Effect.runPromiseExit(runHooks("afterChange", ctx, afterHooks));
|
|
488
|
+
if (Exit.isFailure(exit)) {
|
|
489
|
+
console.warn("[saacms/hooks] afterChange (create) failOnError hook failed");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const collectionTag = `saacms:collection:${collection.slug}`;
|
|
493
|
+
try {
|
|
494
|
+
await resolveCache(c).purgeByTag(collectionTag);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
console.warn(`[saacms/cache] purge failed for tag ${collectionTag}:`, err);
|
|
497
|
+
}
|
|
498
|
+
const href = `${PATH_PREFIX}${collection.slug}/${newId}`;
|
|
499
|
+
const envelope = {
|
|
500
|
+
data: { ...finalBody, id: newId },
|
|
501
|
+
_links: {
|
|
502
|
+
self: { href },
|
|
503
|
+
collection: { href: `${PATH_PREFIX}${collection.slug}` }
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
return c.json(envelope, 201, {
|
|
507
|
+
"Content-Type": "application/json",
|
|
508
|
+
"Cache-Control": "no-store",
|
|
509
|
+
Location: href
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
function readUser(c) {
|
|
514
|
+
const raw = c.get("user");
|
|
515
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
516
|
+
}
|
|
517
|
+
function isJsonContentType(header) {
|
|
518
|
+
if (header == null)
|
|
519
|
+
return false;
|
|
520
|
+
const semi = header.indexOf(";");
|
|
521
|
+
const mediaType = (semi === -1 ? header : header.slice(0, semi)).trim().toLowerCase();
|
|
522
|
+
return mediaType === "application/json";
|
|
523
|
+
}
|
|
524
|
+
function allows(result, record) {
|
|
525
|
+
if (typeof result === "boolean")
|
|
526
|
+
return result;
|
|
527
|
+
const where = result.where;
|
|
528
|
+
for (const [key, value] of Object.entries(where)) {
|
|
529
|
+
if (record[key] !== value)
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
function isHookObject(value) {
|
|
535
|
+
return typeof value === "object" && value !== null;
|
|
536
|
+
}
|
|
537
|
+
function hookRejectionDetail(cause) {
|
|
538
|
+
const err = Cause.squash(cause);
|
|
539
|
+
if (err instanceof Error)
|
|
540
|
+
return err.message;
|
|
541
|
+
if (typeof err === "string")
|
|
542
|
+
return err;
|
|
543
|
+
try {
|
|
544
|
+
return JSON.stringify(err);
|
|
545
|
+
} catch {
|
|
546
|
+
return String(err);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/runtime/delete-route.ts
|
|
551
|
+
import { Cause as Cause2, Effect as Effect2, Exit as Exit2 } from "effect";
|
|
552
|
+
var DELETE_PATH = "/api/saacms/v1/:collection/:id";
|
|
553
|
+
function mountDeleteRoute(app, config) {
|
|
554
|
+
const collections = config.collections ?? [];
|
|
555
|
+
app.delete(DELETE_PATH, async (c) => {
|
|
556
|
+
const slug = c.req.param("collection") ?? "";
|
|
557
|
+
const recordId = c.req.param("id") ?? "";
|
|
558
|
+
const collection = collections.find((coll) => coll.slug === slug);
|
|
559
|
+
if (collection == null) {
|
|
560
|
+
return problemDetails(c, {
|
|
561
|
+
code: "collection-not-found",
|
|
562
|
+
title: "Collection not found",
|
|
563
|
+
status: 404,
|
|
564
|
+
detail: slug
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const rows = config.storage?.rows;
|
|
568
|
+
if (rows == null) {
|
|
569
|
+
return problemDetails(c, {
|
|
570
|
+
code: "storage-unavailable",
|
|
571
|
+
title: "Row storage not configured",
|
|
572
|
+
status: 503,
|
|
573
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
const user = readUser2(c);
|
|
577
|
+
const ifMatch = c.req.header("if-match");
|
|
578
|
+
const table = slugToTableName(collection.slug);
|
|
579
|
+
const rawRecord = await rows.getById(table, recordId);
|
|
580
|
+
const record = rawRecord == null ? null : decodeBooleanColumns(collection.schema, decodeJsonColumns(collection.schema, rawRecord));
|
|
581
|
+
if (record == null) {
|
|
582
|
+
if (ifMatch != null) {
|
|
583
|
+
return problemDetails(c, {
|
|
584
|
+
code: "precondition-failed",
|
|
585
|
+
title: "Precondition failed",
|
|
586
|
+
status: 412,
|
|
587
|
+
detail: `${slug}/${recordId}`
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
return new Response(null, {
|
|
591
|
+
status: 204,
|
|
592
|
+
headers: { "Cache-Control": "no-store" }
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
if (ifMatch != null) {
|
|
596
|
+
const etag = computeWeakEtag(slug, recordId, record);
|
|
597
|
+
if (etag == null || ifMatch !== etag) {
|
|
598
|
+
return problemDetails(c, {
|
|
599
|
+
code: "precondition-failed",
|
|
600
|
+
title: "Precondition failed",
|
|
601
|
+
status: 412,
|
|
602
|
+
detail: `${slug}/${recordId}`
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const deletePredicate = collection.access?.delete;
|
|
607
|
+
if (deletePredicate != null) {
|
|
608
|
+
const result = await deletePredicate({ user, record });
|
|
609
|
+
if (!allows2(result, record)) {
|
|
610
|
+
recordAuditEvent(c, {
|
|
611
|
+
op: "delete",
|
|
612
|
+
collection: slug,
|
|
613
|
+
user,
|
|
614
|
+
decision: "denied",
|
|
615
|
+
reason: "access.delete predicate denied",
|
|
616
|
+
recordId,
|
|
617
|
+
at: new Date().toISOString()
|
|
618
|
+
});
|
|
619
|
+
return problemDetails(c, {
|
|
620
|
+
code: "forbidden",
|
|
621
|
+
title: "Forbidden",
|
|
622
|
+
status: 403,
|
|
623
|
+
detail: `${slug}/${recordId}`
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
recordAuditEvent(c, {
|
|
627
|
+
op: "delete",
|
|
628
|
+
collection: slug,
|
|
629
|
+
user,
|
|
630
|
+
decision: "granted",
|
|
631
|
+
recordId,
|
|
632
|
+
at: new Date().toISOString()
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
const beforeHooks = resolveHooksFor("beforeDelete", {
|
|
636
|
+
collection,
|
|
637
|
+
plugins: config.plugins
|
|
638
|
+
});
|
|
639
|
+
if (beforeHooks.length > 0) {
|
|
640
|
+
const ctx = {
|
|
641
|
+
op: "delete",
|
|
642
|
+
user,
|
|
643
|
+
record
|
|
644
|
+
};
|
|
645
|
+
const exit = await Effect2.runPromiseExit(runHooks("beforeDelete", ctx, beforeHooks));
|
|
646
|
+
if (Exit2.isFailure(exit)) {
|
|
647
|
+
return problemDetails(c, {
|
|
648
|
+
code: "hook-rejected",
|
|
649
|
+
title: "Operation rejected by a lifecycle hook",
|
|
650
|
+
status: 409,
|
|
651
|
+
detail: hookRejectionDetail2(exit.cause)
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
await rows.delete(table, recordId);
|
|
656
|
+
const afterHooks = resolveHooksFor("afterDelete", {
|
|
657
|
+
collection,
|
|
658
|
+
plugins: config.plugins
|
|
659
|
+
});
|
|
660
|
+
if (afterHooks.length > 0) {
|
|
661
|
+
const ctx = {
|
|
662
|
+
op: "delete",
|
|
663
|
+
user,
|
|
664
|
+
record
|
|
665
|
+
};
|
|
666
|
+
const exit = await Effect2.runPromiseExit(runHooks("afterDelete", ctx, afterHooks));
|
|
667
|
+
if (Exit2.isFailure(exit)) {
|
|
668
|
+
console.warn("[saacms/hooks] afterDelete (delete) failOnError hook failed");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const cache = resolveCache(c);
|
|
672
|
+
for (const tag of [
|
|
673
|
+
`saacms:record:${slug}:${recordId}`,
|
|
674
|
+
`saacms:collection:${slug}`
|
|
675
|
+
]) {
|
|
676
|
+
try {
|
|
677
|
+
await cache.purgeByTag(tag);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
console.warn(`[saacms/cache] purge failed for tag ${tag}:`, err);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return new Response(null, {
|
|
683
|
+
status: 204,
|
|
684
|
+
headers: { "Cache-Control": "no-store" }
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
function readUser2(c) {
|
|
689
|
+
const raw = c.get("user");
|
|
690
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
691
|
+
}
|
|
692
|
+
function allows2(result, record) {
|
|
693
|
+
if (typeof result === "boolean")
|
|
694
|
+
return result;
|
|
695
|
+
for (const [key, value] of Object.entries(result.where)) {
|
|
696
|
+
if (record[key] !== value)
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
function hookRejectionDetail2(cause) {
|
|
702
|
+
const err = Cause2.squash(cause);
|
|
703
|
+
if (err instanceof Error)
|
|
704
|
+
return err.message;
|
|
705
|
+
if (typeof err === "string")
|
|
706
|
+
return err;
|
|
707
|
+
try {
|
|
708
|
+
return JSON.stringify(err);
|
|
709
|
+
} catch {
|
|
710
|
+
return String(err);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function computeWeakEtag(slug, recordId, record) {
|
|
714
|
+
const updatedAt = record["updatedAt"];
|
|
715
|
+
if (typeof updatedAt !== "string" || updatedAt.length === 0)
|
|
716
|
+
return null;
|
|
717
|
+
return `W/"${slug}:${recordId}:${updatedAt}"`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/runtime/drafts-route.ts
|
|
721
|
+
import { Effect as Effect3, Exit as Exit3 } from "effect";
|
|
722
|
+
var DRAFTS_TABLE = "saacms_drafts";
|
|
723
|
+
var BASE_HREF = "/api/saacms/v1/drafts";
|
|
724
|
+
var PATH = `${BASE_HREF}/:pageId`;
|
|
725
|
+
function mountDraftsRoute(app, config) {
|
|
726
|
+
app.get(PATH, async (c) => {
|
|
727
|
+
const rows = config.storage?.rows;
|
|
728
|
+
if (rows == null)
|
|
729
|
+
return storageUnavailable(c);
|
|
730
|
+
const pageId = c.req.param("pageId") ?? "";
|
|
731
|
+
const user = readUser3(c);
|
|
732
|
+
if (user == null)
|
|
733
|
+
return notFound(c, pageId);
|
|
734
|
+
const record = await rows.getById(DRAFTS_TABLE, pageId);
|
|
735
|
+
if (record == null)
|
|
736
|
+
return notFound(c, pageId);
|
|
737
|
+
const etag = computeWeakEtag2(pageId, record.updatedAt);
|
|
738
|
+
if (etag != null) {
|
|
739
|
+
const ifNoneMatch = c.req.header("if-none-match");
|
|
740
|
+
if (ifNoneMatch != null && ifNoneMatch === etag) {
|
|
741
|
+
return new Response(null, {
|
|
742
|
+
status: 304,
|
|
743
|
+
headers: { ETag: etag, "Cache-Control": "no-store" }
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const envelope = {
|
|
748
|
+
data: toView(pageId, record),
|
|
749
|
+
_links: { self: { href: `${BASE_HREF}/${pageId}` } }
|
|
750
|
+
};
|
|
751
|
+
const headers = {
|
|
752
|
+
"Content-Type": "application/json",
|
|
753
|
+
"Cache-Control": "no-store"
|
|
754
|
+
};
|
|
755
|
+
if (etag != null)
|
|
756
|
+
headers["ETag"] = etag;
|
|
757
|
+
return c.newResponse(JSON.stringify(envelope), 200, headers);
|
|
758
|
+
});
|
|
759
|
+
app.put(PATH, async (c) => {
|
|
760
|
+
const rows = config.storage?.rows;
|
|
761
|
+
if (rows == null)
|
|
762
|
+
return storageUnavailable(c);
|
|
763
|
+
const user = readUser3(c);
|
|
764
|
+
if (user == null)
|
|
765
|
+
return forbidden(c);
|
|
766
|
+
if (!isJsonContentType2(c.req.header("content-type"))) {
|
|
767
|
+
return problemDetails(c, {
|
|
768
|
+
code: "unsupported-media-type",
|
|
769
|
+
title: "Unsupported media type",
|
|
770
|
+
status: 415,
|
|
771
|
+
detail: "Content-Type must be application/json"
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
let parsedBody;
|
|
775
|
+
try {
|
|
776
|
+
parsedBody = await c.req.json();
|
|
777
|
+
} catch {
|
|
778
|
+
return invalidBody(c, [
|
|
779
|
+
{ path: [], message: "Request body is not valid JSON" }
|
|
780
|
+
]);
|
|
781
|
+
}
|
|
782
|
+
const issues = validateTree(parsedBody);
|
|
783
|
+
if (issues.length > 0)
|
|
784
|
+
return invalidBody(c, issues);
|
|
785
|
+
const tree = parsedBody;
|
|
786
|
+
const pageId = c.req.param("pageId") ?? "";
|
|
787
|
+
const existing = await rows.getById(DRAFTS_TABLE, pageId);
|
|
788
|
+
const ifMatch = c.req.header("if-match");
|
|
789
|
+
if (ifMatch != null) {
|
|
790
|
+
if (existing == null)
|
|
791
|
+
return preconditionFailed(c, pageId);
|
|
792
|
+
const currentEtag = computeWeakEtag2(pageId, existing.updatedAt);
|
|
793
|
+
if (currentEtag == null || currentEtag !== ifMatch) {
|
|
794
|
+
return preconditionFailed(c, pageId);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const now = new Date().toISOString();
|
|
798
|
+
const serialized = JSON.stringify(tree);
|
|
799
|
+
const created = existing == null;
|
|
800
|
+
if (created) {
|
|
801
|
+
await rows.insert(DRAFTS_TABLE, {
|
|
802
|
+
id: pageId,
|
|
803
|
+
tree: serialized,
|
|
804
|
+
createdBy: user.id ?? null,
|
|
805
|
+
createdAt: now,
|
|
806
|
+
updatedAt: now
|
|
807
|
+
});
|
|
808
|
+
} else {
|
|
809
|
+
await rows.update(DRAFTS_TABLE, pageId, {
|
|
810
|
+
tree: serialized,
|
|
811
|
+
updatedAt: now
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
const stored = await rows.getById(DRAFTS_TABLE, pageId);
|
|
815
|
+
if (stored == null)
|
|
816
|
+
return notFound(c, pageId);
|
|
817
|
+
await runAfterHook("afterChange", created ? "create" : "update", user, stored, config);
|
|
818
|
+
const etag = computeWeakEtag2(pageId, stored.updatedAt);
|
|
819
|
+
const href = `${BASE_HREF}/${pageId}`;
|
|
820
|
+
const envelope = {
|
|
821
|
+
data: toView(pageId, stored),
|
|
822
|
+
_links: { self: { href } }
|
|
823
|
+
};
|
|
824
|
+
const headers = {
|
|
825
|
+
"Content-Type": "application/json",
|
|
826
|
+
"Cache-Control": "no-store"
|
|
827
|
+
};
|
|
828
|
+
if (etag != null)
|
|
829
|
+
headers["ETag"] = etag;
|
|
830
|
+
if (created)
|
|
831
|
+
headers["Location"] = href;
|
|
832
|
+
return c.newResponse(JSON.stringify(envelope), created ? 201 : 200, headers);
|
|
833
|
+
});
|
|
834
|
+
app.delete(PATH, async (c) => {
|
|
835
|
+
const rows = config.storage?.rows;
|
|
836
|
+
if (rows == null)
|
|
837
|
+
return storageUnavailable(c);
|
|
838
|
+
const user = readUser3(c);
|
|
839
|
+
if (user == null)
|
|
840
|
+
return forbidden(c);
|
|
841
|
+
const pageId = c.req.param("pageId") ?? "";
|
|
842
|
+
const ifMatch = c.req.header("if-match");
|
|
843
|
+
const record = await rows.getById(DRAFTS_TABLE, pageId);
|
|
844
|
+
if (record == null) {
|
|
845
|
+
if (ifMatch != null)
|
|
846
|
+
return preconditionFailed(c, pageId);
|
|
847
|
+
return new Response(null, {
|
|
848
|
+
status: 204,
|
|
849
|
+
headers: { "Cache-Control": "no-store" }
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
if (ifMatch != null) {
|
|
853
|
+
const etag = computeWeakEtag2(pageId, record.updatedAt);
|
|
854
|
+
if (etag == null || ifMatch !== etag) {
|
|
855
|
+
return preconditionFailed(c, pageId);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
await rows.delete(DRAFTS_TABLE, pageId);
|
|
859
|
+
await runAfterHook("afterDelete", "delete", user, record, config);
|
|
860
|
+
return new Response(null, {
|
|
861
|
+
status: 204,
|
|
862
|
+
headers: { "Cache-Control": "no-store" }
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
function readUser3(c) {
|
|
867
|
+
const raw = c.get("user");
|
|
868
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
869
|
+
}
|
|
870
|
+
function toView(pageId, row) {
|
|
871
|
+
let tree = row.tree;
|
|
872
|
+
if (typeof row.tree === "string") {
|
|
873
|
+
try {
|
|
874
|
+
tree = JSON.parse(row.tree);
|
|
875
|
+
} catch {
|
|
876
|
+
tree = row.tree;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
pageId,
|
|
881
|
+
tree,
|
|
882
|
+
createdBy: row.createdBy ?? null,
|
|
883
|
+
createdAt: row.createdAt,
|
|
884
|
+
updatedAt: row.updatedAt
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
function storageUnavailable(c) {
|
|
888
|
+
return problemDetails(c, {
|
|
889
|
+
code: "storage-unavailable",
|
|
890
|
+
title: "Row storage not configured",
|
|
891
|
+
status: 503,
|
|
892
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
function forbidden(c) {
|
|
896
|
+
return problemDetails(c, {
|
|
897
|
+
code: "forbidden",
|
|
898
|
+
title: "Forbidden",
|
|
899
|
+
status: 403,
|
|
900
|
+
detail: "A logged-in editor is required to modify Drafts"
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
function notFound(c, pageId) {
|
|
904
|
+
return problemDetails(c, {
|
|
905
|
+
code: "draft-not-found",
|
|
906
|
+
title: "Draft not found",
|
|
907
|
+
status: 404,
|
|
908
|
+
detail: `${DRAFTS_TABLE}/${pageId}`
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
function preconditionFailed(c, pageId) {
|
|
912
|
+
return problemDetails(c, {
|
|
913
|
+
code: "precondition-failed",
|
|
914
|
+
title: "Precondition failed",
|
|
915
|
+
status: 412,
|
|
916
|
+
detail: `${DRAFTS_TABLE}/${pageId}`
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
function invalidBody(c, issues) {
|
|
920
|
+
return problemDetails(c, {
|
|
921
|
+
code: "invalid-body",
|
|
922
|
+
title: "Request body failed validation",
|
|
923
|
+
status: 422,
|
|
924
|
+
detail: issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
|
|
925
|
+
extensions: { issues }
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
function validateTree(body) {
|
|
929
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
930
|
+
return [{ path: [], message: "Draft tree must be a JSON object" }];
|
|
931
|
+
}
|
|
932
|
+
const obj = body;
|
|
933
|
+
if (!Array.isArray(obj["nodes"])) {
|
|
934
|
+
return [{ path: ["nodes"], message: "nodes is required and must be an array" }];
|
|
935
|
+
}
|
|
936
|
+
return [];
|
|
937
|
+
}
|
|
938
|
+
function computeWeakEtag2(pageId, updatedAt) {
|
|
939
|
+
if (typeof updatedAt !== "string" || updatedAt.length === 0)
|
|
940
|
+
return null;
|
|
941
|
+
return `W/"${DRAFTS_TABLE}:${pageId}:${updatedAt}"`;
|
|
942
|
+
}
|
|
943
|
+
async function runAfterHook(moment, op, user, record, config) {
|
|
944
|
+
const hooks = resolveHooksFor(moment, { plugins: config.plugins });
|
|
945
|
+
if (hooks.length === 0)
|
|
946
|
+
return;
|
|
947
|
+
const ctx = {
|
|
948
|
+
op,
|
|
949
|
+
user,
|
|
950
|
+
record
|
|
951
|
+
};
|
|
952
|
+
const exit = await Effect3.runPromiseExit(runHooks(moment, ctx, hooks));
|
|
953
|
+
if (Exit3.isFailure(exit)) {
|
|
954
|
+
console.warn(`[saacms/hooks] ${moment} (${op}) failOnError hook failed`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
function isJsonContentType2(header) {
|
|
958
|
+
if (header == null)
|
|
959
|
+
return false;
|
|
960
|
+
const semi = header.indexOf(";");
|
|
961
|
+
const mediaType = (semi === -1 ? header : header.slice(0, semi)).trim().toLowerCase();
|
|
962
|
+
return mediaType === "application/json";
|
|
963
|
+
}
|
|
964
|
+
// package.json
|
|
965
|
+
var package_default = {
|
|
966
|
+
name: "@saacms/core",
|
|
967
|
+
version: "0.1.6",
|
|
968
|
+
type: "module",
|
|
969
|
+
exports: {
|
|
970
|
+
".": "./src/index.ts",
|
|
971
|
+
"./schema": "./src/schema/index.ts",
|
|
972
|
+
"./access": "./src/access/index.ts",
|
|
973
|
+
"./hooks": "./src/hooks/index.ts",
|
|
974
|
+
"./runtime": "./src/runtime/index.ts",
|
|
975
|
+
"./signals": "./src/signals/index.ts",
|
|
976
|
+
"./types": "./src/types/index.ts",
|
|
977
|
+
"./codegen": "./src/codegen/index.ts"
|
|
978
|
+
},
|
|
979
|
+
files: [
|
|
980
|
+
"dist",
|
|
981
|
+
"README.md"
|
|
982
|
+
],
|
|
983
|
+
scripts: {
|
|
984
|
+
build: "tsc --build",
|
|
985
|
+
typecheck: "tsc --build --noEmit",
|
|
986
|
+
prepack: "cp package.json package.json.pack-bak && bun run ../../scripts/prepack-pkg.ts",
|
|
987
|
+
postpack: "mv package.json.pack-bak package.json"
|
|
988
|
+
},
|
|
989
|
+
publishConfig: {
|
|
990
|
+
access: "public"
|
|
991
|
+
},
|
|
992
|
+
dependencies: {
|
|
993
|
+
"@preact/signals-core": "^1.8.0",
|
|
994
|
+
"drizzle-orm": "^0.36.0",
|
|
995
|
+
effect: "^3.10.0",
|
|
996
|
+
hono: "^4.6.0"
|
|
997
|
+
},
|
|
998
|
+
devDependencies: {
|
|
999
|
+
"@types/bun": "latest",
|
|
1000
|
+
typescript: "^5.7.0"
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
// src/runtime/health-route.ts
|
|
1005
|
+
var HEALTH_PATH = "/api/saacms/v1/health";
|
|
1006
|
+
var VERSION = package_default.version;
|
|
1007
|
+
var BOOT_MS = Date.now();
|
|
1008
|
+
function mountHealthRoute(app, _config) {
|
|
1009
|
+
app.get(HEALTH_PATH, (c) => c.json({
|
|
1010
|
+
status: "ok",
|
|
1011
|
+
version: VERSION,
|
|
1012
|
+
ts: new Date().toISOString(),
|
|
1013
|
+
uptimeMs: Date.now() - BOOT_MS
|
|
1014
|
+
}, 200, { "Cache-Control": "no-store" }));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/runtime/list-route.ts
|
|
1018
|
+
import { Cause as Cause3, Effect as Effect4, Exit as Exit4 } from "effect";
|
|
1019
|
+
var LIST_PATH = "/api/saacms/v1/:collection";
|
|
1020
|
+
var PATH_PREFIX2 = "/api/saacms/v1/";
|
|
1021
|
+
var DEFAULT_LIMIT = 50;
|
|
1022
|
+
var MAX_LIMIT = 1000;
|
|
1023
|
+
function parseListQuery(params) {
|
|
1024
|
+
const rawLimit = params.get("limit");
|
|
1025
|
+
let limit = DEFAULT_LIMIT;
|
|
1026
|
+
if (rawLimit != null) {
|
|
1027
|
+
const parsed = Number.parseInt(rawLimit, 10);
|
|
1028
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
1029
|
+
limit = Math.min(parsed, MAX_LIMIT);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const cursor = params.get("cursor");
|
|
1033
|
+
const orderBy = params.getAll("sort").map((raw) => {
|
|
1034
|
+
if (raw.startsWith("-")) {
|
|
1035
|
+
return { col: raw.slice(1), dir: "desc" };
|
|
1036
|
+
}
|
|
1037
|
+
return { col: raw, dir: "asc" };
|
|
1038
|
+
});
|
|
1039
|
+
const FILTER_OP_SET = new Set(["eq", "ne", "gt", "gte", "lt", "lte"]);
|
|
1040
|
+
const filterWhere = {};
|
|
1041
|
+
for (const [key, value] of params.entries()) {
|
|
1042
|
+
const m = /^filter\[(.+)\]$/.exec(key);
|
|
1043
|
+
if (m == null || m[1] == null)
|
|
1044
|
+
continue;
|
|
1045
|
+
const inner = m[1];
|
|
1046
|
+
const dotIdx = inner.lastIndexOf(".");
|
|
1047
|
+
if (dotIdx > 0) {
|
|
1048
|
+
const col = inner.slice(0, dotIdx);
|
|
1049
|
+
const op = inner.slice(dotIdx + 1);
|
|
1050
|
+
if (FILTER_OP_SET.has(op)) {
|
|
1051
|
+
filterWhere[col] = { op, value };
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
filterWhere[inner] = value;
|
|
1056
|
+
}
|
|
1057
|
+
return { limit, cursor, orderBy, filterWhere };
|
|
1058
|
+
}
|
|
1059
|
+
function readUser4(c) {
|
|
1060
|
+
const raw = c.get("user");
|
|
1061
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
1062
|
+
}
|
|
1063
|
+
function buildSelfHref(slug, params) {
|
|
1064
|
+
const qs = params.toString();
|
|
1065
|
+
return qs.length > 0 ? `${PATH_PREFIX2}${slug}?${qs}` : `${PATH_PREFIX2}${slug}`;
|
|
1066
|
+
}
|
|
1067
|
+
function buildNextHref(slug, params, nextCursor) {
|
|
1068
|
+
const cloned = new URLSearchParams(params);
|
|
1069
|
+
cloned.set("cursor", nextCursor);
|
|
1070
|
+
return `${PATH_PREFIX2}${slug}?${cloned.toString()}`;
|
|
1071
|
+
}
|
|
1072
|
+
function sortedSearchParamsString(params) {
|
|
1073
|
+
const entries = [...params.entries()].sort((a, b) => {
|
|
1074
|
+
if (a[0] !== b[0])
|
|
1075
|
+
return a[0] < b[0] ? -1 : 1;
|
|
1076
|
+
if (a[1] !== b[1])
|
|
1077
|
+
return a[1] < b[1] ? -1 : 1;
|
|
1078
|
+
return 0;
|
|
1079
|
+
});
|
|
1080
|
+
return entries.map(([k, v]) => `${k}=${v}`).join("&");
|
|
1081
|
+
}
|
|
1082
|
+
function mountListRoute(app, config) {
|
|
1083
|
+
const collections = config.collections ?? [];
|
|
1084
|
+
app.get(LIST_PATH, async (c) => {
|
|
1085
|
+
const slug = c.req.param("collection");
|
|
1086
|
+
const collection = slug != null ? collections.find((coll) => coll.slug === slug) : undefined;
|
|
1087
|
+
if (collection == null) {
|
|
1088
|
+
return problemDetails(c, {
|
|
1089
|
+
code: "collection-not-found",
|
|
1090
|
+
title: "Collection not found",
|
|
1091
|
+
status: 404,
|
|
1092
|
+
detail: slug ?? ""
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
const user = readUser4(c);
|
|
1096
|
+
const readPredicate = collection.access?.read;
|
|
1097
|
+
let scopedWhere = null;
|
|
1098
|
+
if (readPredicate != null) {
|
|
1099
|
+
const result = await readPredicate({ user });
|
|
1100
|
+
if (result === false) {
|
|
1101
|
+
recordAuditEvent(c, {
|
|
1102
|
+
op: "list",
|
|
1103
|
+
collection: slug ?? "",
|
|
1104
|
+
user,
|
|
1105
|
+
decision: "denied",
|
|
1106
|
+
reason: "collection-level read predicate denied",
|
|
1107
|
+
at: new Date().toISOString()
|
|
1108
|
+
});
|
|
1109
|
+
return problemDetails(c, {
|
|
1110
|
+
code: "collection-not-found",
|
|
1111
|
+
title: "Collection not found",
|
|
1112
|
+
status: 404,
|
|
1113
|
+
detail: slug ?? ""
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
if (result !== true && typeof result === "object") {
|
|
1117
|
+
scopedWhere = result.where;
|
|
1118
|
+
}
|
|
1119
|
+
recordAuditEvent(c, {
|
|
1120
|
+
op: "list",
|
|
1121
|
+
collection: slug ?? "",
|
|
1122
|
+
user,
|
|
1123
|
+
decision: "granted",
|
|
1124
|
+
at: new Date().toISOString()
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
const rows = config.storage?.rows;
|
|
1128
|
+
if (rows == null) {
|
|
1129
|
+
return problemDetails(c, {
|
|
1130
|
+
code: "storage-unavailable",
|
|
1131
|
+
title: "Row storage adapter not configured",
|
|
1132
|
+
status: 503
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
const url = new URL(c.req.url);
|
|
1136
|
+
const parsed = parseListQuery(url.searchParams);
|
|
1137
|
+
const where = {
|
|
1138
|
+
...scopedWhere ?? {},
|
|
1139
|
+
...parsed.filterWhere
|
|
1140
|
+
};
|
|
1141
|
+
const hasWhere = Object.keys(where).length > 0;
|
|
1142
|
+
const beforeHooks = resolveHooksFor("beforeRead", {
|
|
1143
|
+
collection,
|
|
1144
|
+
plugins: config.plugins
|
|
1145
|
+
});
|
|
1146
|
+
if (beforeHooks.length > 0) {
|
|
1147
|
+
const ctx = { op: "read", user };
|
|
1148
|
+
const exit = await Effect4.runPromiseExit(runHooks("beforeRead", ctx, beforeHooks));
|
|
1149
|
+
if (Exit4.isFailure(exit)) {
|
|
1150
|
+
return problemDetails(c, {
|
|
1151
|
+
code: "hook-rejected",
|
|
1152
|
+
title: "Read rejected by a lifecycle hook",
|
|
1153
|
+
status: 403,
|
|
1154
|
+
detail: hookRejectionDetail3(exit.cause)
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const cacheKey = `saacms:collection:${collection.slug}:list:${sortedSearchParamsString(url.searchParams)}`;
|
|
1159
|
+
const cache = resolveCache(c);
|
|
1160
|
+
const hit = await cache.get(cacheKey);
|
|
1161
|
+
let listResult;
|
|
1162
|
+
if (hit != null) {
|
|
1163
|
+
listResult = hit.value;
|
|
1164
|
+
} else {
|
|
1165
|
+
listResult = await rows.list(slugToTableName(collection.slug), {
|
|
1166
|
+
limit: parsed.limit,
|
|
1167
|
+
...parsed.cursor != null ? { cursor: parsed.cursor } : {},
|
|
1168
|
+
...hasWhere ? { where } : {},
|
|
1169
|
+
...parsed.orderBy.length > 0 ? { orderBy: parsed.orderBy } : {}
|
|
1170
|
+
});
|
|
1171
|
+
await cache.put(cacheKey, listResult, {
|
|
1172
|
+
tags: [`saacms:collection:${collection.slug}`],
|
|
1173
|
+
expirationTtl: 15
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
const decodedRows = listResult.rows.map((r) => decodeBooleanColumns(collection.schema, decodeJsonColumns(collection.schema, r)));
|
|
1177
|
+
let outgoing = decodedRows;
|
|
1178
|
+
const afterHooks = resolveHooksFor("afterRead", {
|
|
1179
|
+
collection,
|
|
1180
|
+
plugins: config.plugins
|
|
1181
|
+
});
|
|
1182
|
+
if (afterHooks.length > 0) {
|
|
1183
|
+
const ctx = {
|
|
1184
|
+
op: "read",
|
|
1185
|
+
user,
|
|
1186
|
+
record: decodedRows
|
|
1187
|
+
};
|
|
1188
|
+
const exit = await Effect4.runPromiseExit(runAfterRead(ctx, afterHooks));
|
|
1189
|
+
if (Exit4.isFailure(exit)) {
|
|
1190
|
+
console.warn("[saacms/hooks] afterRead failOnError hook failed");
|
|
1191
|
+
} else if (Array.isArray(exit.value)) {
|
|
1192
|
+
outgoing = exit.value;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const selfHref = buildSelfHref(collection.slug, url.searchParams);
|
|
1196
|
+
const nextHref = listResult.nextCursor != null ? buildNextHref(collection.slug, url.searchParams, listResult.nextCursor) : null;
|
|
1197
|
+
const body = {
|
|
1198
|
+
data: outgoing,
|
|
1199
|
+
_links: {
|
|
1200
|
+
self: { href: selfHref },
|
|
1201
|
+
next: nextHref != null ? { href: nextHref } : null
|
|
1202
|
+
},
|
|
1203
|
+
_meta: {
|
|
1204
|
+
limit: parsed.limit,
|
|
1205
|
+
cursor: parsed.cursor
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
return c.newResponse(JSON.stringify(body), 200, {
|
|
1209
|
+
"Content-Type": "application/json",
|
|
1210
|
+
"Cache-Control": "private, max-age=15",
|
|
1211
|
+
Vary: "Authorization"
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
function runAfterRead(ctx, hooks) {
|
|
1216
|
+
return hooks.reduce((acc, hook) => Effect4.flatMap(acc, (prev) => {
|
|
1217
|
+
const ran = hook.fn(ctx);
|
|
1218
|
+
const guarded = hook.options?.failOnError === true ? ran : Effect4.catchAll(ran, (err) => Effect4.sync(() => {
|
|
1219
|
+
console.warn("[saacms/hooks] afterRead hook failed:", err);
|
|
1220
|
+
return prev;
|
|
1221
|
+
}));
|
|
1222
|
+
return Effect4.map(guarded, (out) => isHookObject2(out) ? out : prev);
|
|
1223
|
+
}), Effect4.succeed(ctx.record));
|
|
1224
|
+
}
|
|
1225
|
+
function isHookObject2(value) {
|
|
1226
|
+
return typeof value === "object" && value !== null;
|
|
1227
|
+
}
|
|
1228
|
+
function hookRejectionDetail3(cause) {
|
|
1229
|
+
const err = Cause3.squash(cause);
|
|
1230
|
+
if (err instanceof Error)
|
|
1231
|
+
return err.message;
|
|
1232
|
+
if (typeof err === "string")
|
|
1233
|
+
return err;
|
|
1234
|
+
try {
|
|
1235
|
+
return JSON.stringify(err);
|
|
1236
|
+
} catch {
|
|
1237
|
+
return String(err);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// src/runtime/openapi-route.ts
|
|
1242
|
+
var OPENAPI_PATH = "/api/saacms/v1/openapi.json";
|
|
1243
|
+
var SECURITY_SCHEMES = {
|
|
1244
|
+
cookieAuth: {
|
|
1245
|
+
type: "apiKey",
|
|
1246
|
+
in: "cookie",
|
|
1247
|
+
name: "better-auth.session_token",
|
|
1248
|
+
description: "Better Auth session cookie set by the host adapter's sign-in flow. Per ADR 0008."
|
|
1249
|
+
},
|
|
1250
|
+
bearerAuth: {
|
|
1251
|
+
type: "http",
|
|
1252
|
+
scheme: "bearer",
|
|
1253
|
+
bearerFormat: "JWT",
|
|
1254
|
+
description: "JWT bearer token (`Authorization: Bearer ...`) for non-browser clients. Per ADR 0008."
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
function mountOpenApiRoute(app, config) {
|
|
1258
|
+
const collections = config.collections ?? [];
|
|
1259
|
+
app.get(OPENAPI_PATH, async (c) => {
|
|
1260
|
+
const raw = c.get("user");
|
|
1261
|
+
const user = raw != null && typeof raw === "object" ? raw : null;
|
|
1262
|
+
const headers = {
|
|
1263
|
+
"Cache-Control": "private, max-age=60",
|
|
1264
|
+
Vary: "Authorization"
|
|
1265
|
+
};
|
|
1266
|
+
const role = user?.role ?? "anon";
|
|
1267
|
+
const cacheKey = `saacms:openapi:role:${role}`;
|
|
1268
|
+
const cache = resolveCache(c);
|
|
1269
|
+
const cached = await cache.get(cacheKey);
|
|
1270
|
+
if (cached != null) {
|
|
1271
|
+
return c.json(cached.value, 200, headers);
|
|
1272
|
+
}
|
|
1273
|
+
const assembled = assembleSpec(collections);
|
|
1274
|
+
const filtered = await filterOpenApiForUser({
|
|
1275
|
+
spec: assembled,
|
|
1276
|
+
collections,
|
|
1277
|
+
user
|
|
1278
|
+
});
|
|
1279
|
+
const doc = {
|
|
1280
|
+
openapi: "3.1.0",
|
|
1281
|
+
info: {
|
|
1282
|
+
title: config.title ?? "saacms",
|
|
1283
|
+
version: config.version ?? "0.0.0",
|
|
1284
|
+
...config.description != null ? { description: config.description } : {}
|
|
1285
|
+
},
|
|
1286
|
+
servers: [{ url: "/api/saacms/v1" }],
|
|
1287
|
+
paths: filtered.paths,
|
|
1288
|
+
components: {
|
|
1289
|
+
schemas: filtered.components.schemas,
|
|
1290
|
+
securitySchemes: SECURITY_SCHEMES
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
await cache.put(cacheKey, doc, {
|
|
1294
|
+
tags: ["saacms:openapi", `saacms:openapi:role:${role}`],
|
|
1295
|
+
expirationTtl: 60
|
|
1296
|
+
});
|
|
1297
|
+
return c.json(doc, 200, headers);
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
function assembleSpec(collections) {
|
|
1301
|
+
const specs = collections.map((c) => collectionToOpenApiPaths(c));
|
|
1302
|
+
return mergeOpenApiSpecs(specs);
|
|
1303
|
+
}
|
|
1304
|
+
function mergeOpenApiSpecs(specs) {
|
|
1305
|
+
const paths = {};
|
|
1306
|
+
const schemas = {};
|
|
1307
|
+
for (const spec of specs) {
|
|
1308
|
+
for (const [p, item] of Object.entries(spec.paths)) {
|
|
1309
|
+
if (p in paths) {
|
|
1310
|
+
throw new Error(`mergeOpenApiSpecs: duplicate path "${p}" — two Collections share a slug`);
|
|
1311
|
+
}
|
|
1312
|
+
paths[p] = item;
|
|
1313
|
+
}
|
|
1314
|
+
for (const [name, schema] of Object.entries(spec.components.schemas)) {
|
|
1315
|
+
if (name === "ProblemDetails") {
|
|
1316
|
+
schemas[name] = schema;
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
if (name in schemas) {
|
|
1320
|
+
throw new Error(`mergeOpenApiSpecs: duplicate components.schemas key "${name}" — two Collections share a PascalCase identity`);
|
|
1321
|
+
}
|
|
1322
|
+
schemas[name] = schema;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return { paths, components: { schemas } };
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// src/runtime/pattern-route.ts
|
|
1329
|
+
import { Effect as Effect5, Exit as Exit5 } from "effect";
|
|
1330
|
+
var PATTERN_TABLE = "saacms_patterns";
|
|
1331
|
+
var BASE_HREF2 = "/api/saacms/v1/patterns";
|
|
1332
|
+
var LIST_CACHE_KEY = "saacms:patterns:list";
|
|
1333
|
+
var LIST_CACHE_TAG = "saacms:patterns";
|
|
1334
|
+
var LIST_TTL = 30;
|
|
1335
|
+
var RECORD_TTL = 30;
|
|
1336
|
+
var NAME_MAX = 120;
|
|
1337
|
+
var DEFAULT_LIMIT2 = 50;
|
|
1338
|
+
var MAX_LIMIT2 = 200;
|
|
1339
|
+
var CACHE_CONTROL_READ = "private, max-age=30";
|
|
1340
|
+
var VARY = "Authorization";
|
|
1341
|
+
function mountPatternRoute(app, config) {
|
|
1342
|
+
app.get(BASE_HREF2, async (c) => {
|
|
1343
|
+
const rows = config.storage?.rows;
|
|
1344
|
+
if (rows == null)
|
|
1345
|
+
return storageUnavailable2(c);
|
|
1346
|
+
const url = new URL(c.req.url);
|
|
1347
|
+
const limit = parseLimit(url.searchParams.get("limit"));
|
|
1348
|
+
const cache = resolveCache(c);
|
|
1349
|
+
const hit = await cache.get(LIST_CACHE_KEY);
|
|
1350
|
+
let data;
|
|
1351
|
+
if (hit != null) {
|
|
1352
|
+
data = hit.value;
|
|
1353
|
+
} else {
|
|
1354
|
+
const result = await rows.list(PATTERN_TABLE, { limit });
|
|
1355
|
+
data = result.rows;
|
|
1356
|
+
await cache.put(LIST_CACHE_KEY, data, {
|
|
1357
|
+
tags: [LIST_CACHE_TAG],
|
|
1358
|
+
expirationTtl: LIST_TTL
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
const body = {
|
|
1362
|
+
data,
|
|
1363
|
+
_links: { self: { href: BASE_HREF2 } },
|
|
1364
|
+
_meta: { count: data.length }
|
|
1365
|
+
};
|
|
1366
|
+
return c.newResponse(JSON.stringify(body), 200, {
|
|
1367
|
+
"Content-Type": "application/json",
|
|
1368
|
+
"Cache-Control": CACHE_CONTROL_READ,
|
|
1369
|
+
Vary: VARY
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
app.post(BASE_HREF2, async (c) => {
|
|
1373
|
+
const rows = config.storage?.rows;
|
|
1374
|
+
if (rows == null)
|
|
1375
|
+
return storageUnavailable2(c);
|
|
1376
|
+
const user = readUser5(c);
|
|
1377
|
+
if (user == null)
|
|
1378
|
+
return forbidden2(c);
|
|
1379
|
+
if (!isJsonContentType3(c.req.header("content-type"))) {
|
|
1380
|
+
return problemDetails(c, {
|
|
1381
|
+
code: "unsupported-media-type",
|
|
1382
|
+
title: "Unsupported media type",
|
|
1383
|
+
status: 415,
|
|
1384
|
+
detail: "Content-Type must be application/json"
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
let parsedBody;
|
|
1388
|
+
try {
|
|
1389
|
+
parsedBody = await c.req.json();
|
|
1390
|
+
} catch {
|
|
1391
|
+
return problemDetails(c, {
|
|
1392
|
+
code: "malformed-body",
|
|
1393
|
+
title: "Malformed request body",
|
|
1394
|
+
status: 400,
|
|
1395
|
+
detail: "Request body is not valid JSON"
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
const issues = validatePatternInput(parsedBody);
|
|
1399
|
+
if (issues.length > 0) {
|
|
1400
|
+
return problemDetails(c, {
|
|
1401
|
+
code: "invalid-body",
|
|
1402
|
+
title: "Request body failed validation",
|
|
1403
|
+
status: 422,
|
|
1404
|
+
detail: issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
|
|
1405
|
+
extensions: { issues }
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
const input = parsedBody;
|
|
1409
|
+
const now = new Date().toISOString();
|
|
1410
|
+
const record = {
|
|
1411
|
+
id: `pat_${crypto.randomUUID()}`,
|
|
1412
|
+
name: input.name,
|
|
1413
|
+
tree: input.tree,
|
|
1414
|
+
createdBy: user.id ?? null,
|
|
1415
|
+
createdAt: now,
|
|
1416
|
+
updatedAt: now
|
|
1417
|
+
};
|
|
1418
|
+
await rows.insert(PATTERN_TABLE, record);
|
|
1419
|
+
await runAfterHook2("afterChange", "create", user, record, config);
|
|
1420
|
+
await purgeTags(c, [LIST_CACHE_TAG]);
|
|
1421
|
+
const href = `${BASE_HREF2}/${record.id}`;
|
|
1422
|
+
const envelope = {
|
|
1423
|
+
data: record,
|
|
1424
|
+
_links: { self: { href }, collection: { href: BASE_HREF2 } }
|
|
1425
|
+
};
|
|
1426
|
+
return c.newResponse(JSON.stringify(envelope), 201, {
|
|
1427
|
+
"Content-Type": "application/json",
|
|
1428
|
+
"Cache-Control": "no-store",
|
|
1429
|
+
Location: href
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
1432
|
+
app.get(`${BASE_HREF2}/:id`, async (c) => {
|
|
1433
|
+
const rows = config.storage?.rows;
|
|
1434
|
+
if (rows == null)
|
|
1435
|
+
return storageUnavailable2(c);
|
|
1436
|
+
const recordId = c.req.param("id") ?? "";
|
|
1437
|
+
const cacheKey = recordCacheKey(recordId);
|
|
1438
|
+
const cache = resolveCache(c);
|
|
1439
|
+
const hit = await cache.get(cacheKey);
|
|
1440
|
+
const record = hit != null ? hit.value : await rows.getById(PATTERN_TABLE, recordId);
|
|
1441
|
+
if (record == null) {
|
|
1442
|
+
return problemDetails(c, {
|
|
1443
|
+
code: "record-not-found",
|
|
1444
|
+
title: "Record not found",
|
|
1445
|
+
status: 404,
|
|
1446
|
+
detail: `${PATTERN_TABLE}/${recordId}`
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
if (hit == null) {
|
|
1450
|
+
await cache.put(cacheKey, record, {
|
|
1451
|
+
tags: [cacheKey, LIST_CACHE_TAG],
|
|
1452
|
+
expirationTtl: RECORD_TTL
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
const etag = computeWeakEtag3(recordId, record);
|
|
1456
|
+
if (etag != null) {
|
|
1457
|
+
const ifNoneMatch = c.req.header("if-none-match");
|
|
1458
|
+
if (ifNoneMatch != null && ifNoneMatch === etag) {
|
|
1459
|
+
return new Response(null, {
|
|
1460
|
+
status: 304,
|
|
1461
|
+
headers: {
|
|
1462
|
+
ETag: etag,
|
|
1463
|
+
"Cache-Control": CACHE_CONTROL_READ,
|
|
1464
|
+
Vary: VARY
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
const envelope = {
|
|
1470
|
+
data: record,
|
|
1471
|
+
_links: {
|
|
1472
|
+
self: { href: `${BASE_HREF2}/${recordId}` },
|
|
1473
|
+
collection: { href: BASE_HREF2 }
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
const headers = {
|
|
1477
|
+
"Content-Type": "application/json",
|
|
1478
|
+
"Cache-Control": CACHE_CONTROL_READ,
|
|
1479
|
+
Vary: VARY
|
|
1480
|
+
};
|
|
1481
|
+
if (etag != null)
|
|
1482
|
+
headers["ETag"] = etag;
|
|
1483
|
+
return c.newResponse(JSON.stringify(envelope), 200, headers);
|
|
1484
|
+
});
|
|
1485
|
+
app.delete(`${BASE_HREF2}/:id`, async (c) => {
|
|
1486
|
+
const rows = config.storage?.rows;
|
|
1487
|
+
if (rows == null)
|
|
1488
|
+
return storageUnavailable2(c);
|
|
1489
|
+
const user = readUser5(c);
|
|
1490
|
+
if (user == null)
|
|
1491
|
+
return forbidden2(c);
|
|
1492
|
+
const recordId = c.req.param("id") ?? "";
|
|
1493
|
+
const ifMatch = c.req.header("if-match");
|
|
1494
|
+
const record = await rows.getById(PATTERN_TABLE, recordId);
|
|
1495
|
+
if (record == null) {
|
|
1496
|
+
if (ifMatch != null) {
|
|
1497
|
+
return problemDetails(c, {
|
|
1498
|
+
code: "precondition-failed",
|
|
1499
|
+
title: "Precondition failed",
|
|
1500
|
+
status: 412,
|
|
1501
|
+
detail: `${PATTERN_TABLE}/${recordId}`
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
return new Response(null, {
|
|
1505
|
+
status: 204,
|
|
1506
|
+
headers: { "Cache-Control": "no-store" }
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
if (ifMatch != null) {
|
|
1510
|
+
const etag = computeWeakEtag3(recordId, record);
|
|
1511
|
+
if (etag == null || ifMatch !== etag) {
|
|
1512
|
+
return problemDetails(c, {
|
|
1513
|
+
code: "precondition-failed",
|
|
1514
|
+
title: "Precondition failed",
|
|
1515
|
+
status: 412,
|
|
1516
|
+
detail: `${PATTERN_TABLE}/${recordId}`
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
await rows.delete(PATTERN_TABLE, recordId);
|
|
1521
|
+
await runAfterHook2("afterDelete", "delete", user, record, config);
|
|
1522
|
+
await purgeTags(c, [recordCacheKey(recordId), LIST_CACHE_TAG]);
|
|
1523
|
+
return new Response(null, {
|
|
1524
|
+
status: 204,
|
|
1525
|
+
headers: { "Cache-Control": "no-store" }
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
function readUser5(c) {
|
|
1530
|
+
const raw = c.get("user");
|
|
1531
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
1532
|
+
}
|
|
1533
|
+
function storageUnavailable2(c) {
|
|
1534
|
+
return problemDetails(c, {
|
|
1535
|
+
code: "storage-unavailable",
|
|
1536
|
+
title: "Row storage not configured",
|
|
1537
|
+
status: 503,
|
|
1538
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
function forbidden2(c) {
|
|
1542
|
+
return problemDetails(c, {
|
|
1543
|
+
code: "forbidden",
|
|
1544
|
+
title: "Forbidden",
|
|
1545
|
+
status: 403,
|
|
1546
|
+
detail: "A logged-in editor is required to modify Patterns"
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
function recordCacheKey(recordId) {
|
|
1550
|
+
return `saacms:record:${PATTERN_TABLE}:${recordId}`;
|
|
1551
|
+
}
|
|
1552
|
+
function parseLimit(raw) {
|
|
1553
|
+
if (raw == null)
|
|
1554
|
+
return DEFAULT_LIMIT2;
|
|
1555
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1556
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
1557
|
+
return DEFAULT_LIMIT2;
|
|
1558
|
+
return Math.min(parsed, MAX_LIMIT2);
|
|
1559
|
+
}
|
|
1560
|
+
function isJsonContentType3(header) {
|
|
1561
|
+
if (header == null)
|
|
1562
|
+
return false;
|
|
1563
|
+
const semi = header.indexOf(";");
|
|
1564
|
+
const mediaType = (semi === -1 ? header : header.slice(0, semi)).trim().toLowerCase();
|
|
1565
|
+
return mediaType === "application/json";
|
|
1566
|
+
}
|
|
1567
|
+
function validatePatternInput(body) {
|
|
1568
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
1569
|
+
return [{ path: [], message: "Request body must be a JSON object" }];
|
|
1570
|
+
}
|
|
1571
|
+
const obj = body;
|
|
1572
|
+
const issues = [];
|
|
1573
|
+
const name = obj["name"];
|
|
1574
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1575
|
+
issues.push({
|
|
1576
|
+
path: ["name"],
|
|
1577
|
+
message: "name is required and must be a non-empty string"
|
|
1578
|
+
});
|
|
1579
|
+
} else if (name.length > NAME_MAX) {
|
|
1580
|
+
issues.push({
|
|
1581
|
+
path: ["name"],
|
|
1582
|
+
message: `name must be at most ${NAME_MAX} characters`
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
if (!("tree" in obj)) {
|
|
1586
|
+
issues.push({ path: ["tree"], message: "tree is required" });
|
|
1587
|
+
}
|
|
1588
|
+
return issues;
|
|
1589
|
+
}
|
|
1590
|
+
function computeWeakEtag3(recordId, record) {
|
|
1591
|
+
const updatedAt = record.updatedAt;
|
|
1592
|
+
if (typeof updatedAt !== "string" || updatedAt.length === 0)
|
|
1593
|
+
return null;
|
|
1594
|
+
return `W/"${PATTERN_TABLE}:${recordId}:${updatedAt}"`;
|
|
1595
|
+
}
|
|
1596
|
+
async function runAfterHook2(moment, op, user, record, config) {
|
|
1597
|
+
const hooks = resolveHooksFor(moment, { plugins: config.plugins });
|
|
1598
|
+
if (hooks.length === 0)
|
|
1599
|
+
return;
|
|
1600
|
+
const ctx = {
|
|
1601
|
+
op,
|
|
1602
|
+
user,
|
|
1603
|
+
record
|
|
1604
|
+
};
|
|
1605
|
+
const exit = await Effect5.runPromiseExit(runHooks(moment, ctx, hooks));
|
|
1606
|
+
if (Exit5.isFailure(exit)) {
|
|
1607
|
+
console.warn(`[saacms/hooks] ${moment} (${op}) failOnError hook failed`);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async function purgeTags(c, tags) {
|
|
1611
|
+
const cache = resolveCache(c);
|
|
1612
|
+
for (const tag of tags) {
|
|
1613
|
+
try {
|
|
1614
|
+
await cache.purgeByTag(tag);
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
console.warn(`[saacms/cache] purge failed for tag ${tag}:`, err);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// src/runtime/scheme-route.ts
|
|
1622
|
+
import { Effect as Effect6, Exit as Exit6 } from "effect";
|
|
1623
|
+
var SCHEME_TABLE = "saacms_schemes";
|
|
1624
|
+
var BASE_HREF3 = "/api/saacms/v1/schemes";
|
|
1625
|
+
var LIST_CACHE_KEY2 = "saacms:schemes:list";
|
|
1626
|
+
var LIST_CACHE_TAG2 = "saacms:schemes";
|
|
1627
|
+
var LIST_TTL2 = 30;
|
|
1628
|
+
var RECORD_TTL2 = 30;
|
|
1629
|
+
var NAME_MAX2 = 120;
|
|
1630
|
+
var DEFAULT_LIMIT3 = 50;
|
|
1631
|
+
var MAX_LIMIT3 = 200;
|
|
1632
|
+
var CACHE_CONTROL_READ2 = "private, max-age=30";
|
|
1633
|
+
var VARY2 = "Authorization";
|
|
1634
|
+
function mountSchemeRoute(app, config) {
|
|
1635
|
+
app.get(BASE_HREF3, async (c) => {
|
|
1636
|
+
const rows = config.storage?.rows;
|
|
1637
|
+
if (rows == null)
|
|
1638
|
+
return storageUnavailable3(c);
|
|
1639
|
+
const url = new URL(c.req.url);
|
|
1640
|
+
const limit = parseLimit2(url.searchParams.get("limit"));
|
|
1641
|
+
const cache = resolveCache(c);
|
|
1642
|
+
const hit = await cache.get(LIST_CACHE_KEY2);
|
|
1643
|
+
let data;
|
|
1644
|
+
if (hit != null) {
|
|
1645
|
+
data = hit.value;
|
|
1646
|
+
} else {
|
|
1647
|
+
const result = await rows.list(SCHEME_TABLE, { limit });
|
|
1648
|
+
data = result.rows;
|
|
1649
|
+
await cache.put(LIST_CACHE_KEY2, data, {
|
|
1650
|
+
tags: [LIST_CACHE_TAG2],
|
|
1651
|
+
expirationTtl: LIST_TTL2
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
const body = {
|
|
1655
|
+
data,
|
|
1656
|
+
_links: { self: { href: BASE_HREF3 } },
|
|
1657
|
+
_meta: { count: data.length }
|
|
1658
|
+
};
|
|
1659
|
+
return c.newResponse(JSON.stringify(body), 200, {
|
|
1660
|
+
"Content-Type": "application/json",
|
|
1661
|
+
"Cache-Control": CACHE_CONTROL_READ2,
|
|
1662
|
+
Vary: VARY2
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
app.post(BASE_HREF3, async (c) => {
|
|
1666
|
+
const rows = config.storage?.rows;
|
|
1667
|
+
if (rows == null)
|
|
1668
|
+
return storageUnavailable3(c);
|
|
1669
|
+
const user = readUser6(c);
|
|
1670
|
+
if (user == null)
|
|
1671
|
+
return forbidden3(c);
|
|
1672
|
+
if (!isJsonContentType4(c.req.header("content-type"))) {
|
|
1673
|
+
return problemDetails(c, {
|
|
1674
|
+
code: "unsupported-media-type",
|
|
1675
|
+
title: "Unsupported media type",
|
|
1676
|
+
status: 415,
|
|
1677
|
+
detail: "Content-Type must be application/json"
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
let parsedBody;
|
|
1681
|
+
try {
|
|
1682
|
+
parsedBody = await c.req.json();
|
|
1683
|
+
} catch {
|
|
1684
|
+
return problemDetails(c, {
|
|
1685
|
+
code: "malformed-body",
|
|
1686
|
+
title: "Malformed request body",
|
|
1687
|
+
status: 400,
|
|
1688
|
+
detail: "Request body is not valid JSON"
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
const issues = validateSchemeInput(parsedBody, config);
|
|
1692
|
+
if (issues.length > 0) {
|
|
1693
|
+
return problemDetails(c, {
|
|
1694
|
+
code: "invalid-body",
|
|
1695
|
+
title: "Request body failed validation",
|
|
1696
|
+
status: 422,
|
|
1697
|
+
detail: issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
|
|
1698
|
+
extensions: { issues }
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
const input = parsedBody;
|
|
1702
|
+
const now = new Date().toISOString();
|
|
1703
|
+
const record = {
|
|
1704
|
+
id: `sch_${crypto.randomUUID()}`,
|
|
1705
|
+
name: input.name,
|
|
1706
|
+
tokens: input.tokens,
|
|
1707
|
+
...input.darkTokens != null ? { darkTokens: input.darkTokens } : {},
|
|
1708
|
+
createdBy: user.id ?? null,
|
|
1709
|
+
createdAt: now,
|
|
1710
|
+
updatedAt: now
|
|
1711
|
+
};
|
|
1712
|
+
await rows.insert(SCHEME_TABLE, record);
|
|
1713
|
+
await runAfterHook3("afterChange", "create", user, record, config);
|
|
1714
|
+
await purgeTags2(c, [LIST_CACHE_TAG2]);
|
|
1715
|
+
const href = `${BASE_HREF3}/${record.id}`;
|
|
1716
|
+
const envelope = {
|
|
1717
|
+
data: record,
|
|
1718
|
+
_links: { self: { href }, collection: { href: BASE_HREF3 } }
|
|
1719
|
+
};
|
|
1720
|
+
return c.newResponse(JSON.stringify(envelope), 201, {
|
|
1721
|
+
"Content-Type": "application/json",
|
|
1722
|
+
"Cache-Control": "no-store",
|
|
1723
|
+
Location: href
|
|
1724
|
+
});
|
|
1725
|
+
});
|
|
1726
|
+
app.get(`${BASE_HREF3}/:id`, async (c) => {
|
|
1727
|
+
const rows = config.storage?.rows;
|
|
1728
|
+
if (rows == null)
|
|
1729
|
+
return storageUnavailable3(c);
|
|
1730
|
+
const recordId = c.req.param("id") ?? "";
|
|
1731
|
+
const cacheKey = recordCacheKey2(recordId);
|
|
1732
|
+
const cache = resolveCache(c);
|
|
1733
|
+
const hit = await cache.get(cacheKey);
|
|
1734
|
+
const record = hit != null ? hit.value : await rows.getById(SCHEME_TABLE, recordId);
|
|
1735
|
+
if (record == null) {
|
|
1736
|
+
return problemDetails(c, {
|
|
1737
|
+
code: "record-not-found",
|
|
1738
|
+
title: "Record not found",
|
|
1739
|
+
status: 404,
|
|
1740
|
+
detail: `${SCHEME_TABLE}/${recordId}`
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
if (hit == null) {
|
|
1744
|
+
await cache.put(cacheKey, record, {
|
|
1745
|
+
tags: [cacheKey, LIST_CACHE_TAG2],
|
|
1746
|
+
expirationTtl: RECORD_TTL2
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
const etag = computeWeakEtag4(recordId, record);
|
|
1750
|
+
if (etag != null) {
|
|
1751
|
+
const ifNoneMatch = c.req.header("if-none-match");
|
|
1752
|
+
if (ifNoneMatch != null && ifNoneMatch === etag) {
|
|
1753
|
+
return new Response(null, {
|
|
1754
|
+
status: 304,
|
|
1755
|
+
headers: {
|
|
1756
|
+
ETag: etag,
|
|
1757
|
+
"Cache-Control": CACHE_CONTROL_READ2,
|
|
1758
|
+
Vary: VARY2
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const envelope = {
|
|
1764
|
+
data: record,
|
|
1765
|
+
_links: {
|
|
1766
|
+
self: { href: `${BASE_HREF3}/${recordId}` },
|
|
1767
|
+
collection: { href: BASE_HREF3 }
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
const headers = {
|
|
1771
|
+
"Content-Type": "application/json",
|
|
1772
|
+
"Cache-Control": CACHE_CONTROL_READ2,
|
|
1773
|
+
Vary: VARY2
|
|
1774
|
+
};
|
|
1775
|
+
if (etag != null)
|
|
1776
|
+
headers["ETag"] = etag;
|
|
1777
|
+
return c.newResponse(JSON.stringify(envelope), 200, headers);
|
|
1778
|
+
});
|
|
1779
|
+
app.delete(`${BASE_HREF3}/:id`, async (c) => {
|
|
1780
|
+
const rows = config.storage?.rows;
|
|
1781
|
+
if (rows == null)
|
|
1782
|
+
return storageUnavailable3(c);
|
|
1783
|
+
const user = readUser6(c);
|
|
1784
|
+
if (user == null)
|
|
1785
|
+
return forbidden3(c);
|
|
1786
|
+
const recordId = c.req.param("id") ?? "";
|
|
1787
|
+
const ifMatch = c.req.header("if-match");
|
|
1788
|
+
const record = await rows.getById(SCHEME_TABLE, recordId);
|
|
1789
|
+
if (record == null) {
|
|
1790
|
+
if (ifMatch != null) {
|
|
1791
|
+
return problemDetails(c, {
|
|
1792
|
+
code: "precondition-failed",
|
|
1793
|
+
title: "Precondition failed",
|
|
1794
|
+
status: 412,
|
|
1795
|
+
detail: `${SCHEME_TABLE}/${recordId}`
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
return new Response(null, {
|
|
1799
|
+
status: 204,
|
|
1800
|
+
headers: { "Cache-Control": "no-store" }
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
if (ifMatch != null) {
|
|
1804
|
+
const etag = computeWeakEtag4(recordId, record);
|
|
1805
|
+
if (etag == null || ifMatch !== etag) {
|
|
1806
|
+
return problemDetails(c, {
|
|
1807
|
+
code: "precondition-failed",
|
|
1808
|
+
title: "Precondition failed",
|
|
1809
|
+
status: 412,
|
|
1810
|
+
detail: `${SCHEME_TABLE}/${recordId}`
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
await rows.delete(SCHEME_TABLE, recordId);
|
|
1815
|
+
await runAfterHook3("afterDelete", "delete", user, record, config);
|
|
1816
|
+
await purgeTags2(c, [recordCacheKey2(recordId), LIST_CACHE_TAG2]);
|
|
1817
|
+
return new Response(null, {
|
|
1818
|
+
status: 204,
|
|
1819
|
+
headers: { "Cache-Control": "no-store" }
|
|
1820
|
+
});
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
function readUser6(c) {
|
|
1824
|
+
const raw = c.get("user");
|
|
1825
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
1826
|
+
}
|
|
1827
|
+
function storageUnavailable3(c) {
|
|
1828
|
+
return problemDetails(c, {
|
|
1829
|
+
code: "storage-unavailable",
|
|
1830
|
+
title: "Row storage not configured",
|
|
1831
|
+
status: 503,
|
|
1832
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
function forbidden3(c) {
|
|
1836
|
+
return problemDetails(c, {
|
|
1837
|
+
code: "forbidden",
|
|
1838
|
+
title: "Forbidden",
|
|
1839
|
+
status: 403,
|
|
1840
|
+
detail: "A logged-in editor is required to modify Schemes"
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
function recordCacheKey2(recordId) {
|
|
1844
|
+
return `saacms:record:${SCHEME_TABLE}:${recordId}`;
|
|
1845
|
+
}
|
|
1846
|
+
function parseLimit2(raw) {
|
|
1847
|
+
if (raw == null)
|
|
1848
|
+
return DEFAULT_LIMIT3;
|
|
1849
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1850
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
1851
|
+
return DEFAULT_LIMIT3;
|
|
1852
|
+
return Math.min(parsed, MAX_LIMIT3);
|
|
1853
|
+
}
|
|
1854
|
+
function isJsonContentType4(header) {
|
|
1855
|
+
if (header == null)
|
|
1856
|
+
return false;
|
|
1857
|
+
const semi = header.indexOf(";");
|
|
1858
|
+
const mediaType = (semi === -1 ? header : header.slice(0, semi)).trim().toLowerCase();
|
|
1859
|
+
return mediaType === "application/json";
|
|
1860
|
+
}
|
|
1861
|
+
function validateSchemeInput(body, config) {
|
|
1862
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
1863
|
+
return [{ path: [], message: "Request body must be a JSON object" }];
|
|
1864
|
+
}
|
|
1865
|
+
const obj = body;
|
|
1866
|
+
const issues = [];
|
|
1867
|
+
const name = obj["name"];
|
|
1868
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1869
|
+
issues.push({
|
|
1870
|
+
path: ["name"],
|
|
1871
|
+
message: "name is required and must be a non-empty string"
|
|
1872
|
+
});
|
|
1873
|
+
} else if (name.length > NAME_MAX2) {
|
|
1874
|
+
issues.push({
|
|
1875
|
+
path: ["name"],
|
|
1876
|
+
message: `name must be at most ${NAME_MAX2} characters`
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
const tokens = obj["tokens"];
|
|
1880
|
+
if (tokens == null || typeof tokens !== "object" || Array.isArray(tokens)) {
|
|
1881
|
+
issues.push({
|
|
1882
|
+
path: ["tokens"],
|
|
1883
|
+
message: "tokens is required and must be a JSON object"
|
|
1884
|
+
});
|
|
1885
|
+
} else if (config.theme != null) {
|
|
1886
|
+
const knownNames = new Set(config.theme.tokens.map((t) => t.name));
|
|
1887
|
+
for (const key of Object.keys(tokens)) {
|
|
1888
|
+
if (!knownNames.has(key)) {
|
|
1889
|
+
issues.push({
|
|
1890
|
+
path: ["tokens", key],
|
|
1891
|
+
message: `unknown token "${key}" — not declared in the active Theme contract`
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
const darkTokens = obj["darkTokens"];
|
|
1897
|
+
if (darkTokens != null) {
|
|
1898
|
+
if (typeof darkTokens !== "object" || Array.isArray(darkTokens)) {
|
|
1899
|
+
issues.push({
|
|
1900
|
+
path: ["darkTokens"],
|
|
1901
|
+
message: "darkTokens must be a JSON object when present"
|
|
1902
|
+
});
|
|
1903
|
+
} else if (config.theme != null) {
|
|
1904
|
+
const darkCapable = new Set(config.theme.tokens.filter((t) => t.dark === true).map((t) => t.name));
|
|
1905
|
+
for (const key of Object.keys(darkTokens)) {
|
|
1906
|
+
if (!darkCapable.has(key)) {
|
|
1907
|
+
issues.push({
|
|
1908
|
+
path: ["darkTokens", key],
|
|
1909
|
+
message: `unknown dark token "${key}" — not declared as a dark-capable token in the active Theme contract`
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
return issues;
|
|
1916
|
+
}
|
|
1917
|
+
function computeWeakEtag4(recordId, record) {
|
|
1918
|
+
const updatedAt = record.updatedAt;
|
|
1919
|
+
if (typeof updatedAt !== "string" || updatedAt.length === 0)
|
|
1920
|
+
return null;
|
|
1921
|
+
return `W/"${SCHEME_TABLE}:${recordId}:${updatedAt}"`;
|
|
1922
|
+
}
|
|
1923
|
+
async function runAfterHook3(moment, op, user, record, config) {
|
|
1924
|
+
const hooks = resolveHooksFor(moment, { plugins: config.plugins });
|
|
1925
|
+
if (hooks.length === 0)
|
|
1926
|
+
return;
|
|
1927
|
+
const ctx = {
|
|
1928
|
+
op,
|
|
1929
|
+
user,
|
|
1930
|
+
record
|
|
1931
|
+
};
|
|
1932
|
+
const exit = await Effect6.runPromiseExit(runHooks(moment, ctx, hooks));
|
|
1933
|
+
if (Exit6.isFailure(exit)) {
|
|
1934
|
+
console.warn(`[saacms/hooks] ${moment} (${op}) failOnError hook failed`);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
async function purgeTags2(c, tags) {
|
|
1938
|
+
const cache = resolveCache(c);
|
|
1939
|
+
for (const tag of tags) {
|
|
1940
|
+
try {
|
|
1941
|
+
await cache.purgeByTag(tag);
|
|
1942
|
+
} catch (err) {
|
|
1943
|
+
console.warn(`[saacms/cache] purge failed for tag ${tag}:`, err);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// src/runtime/put-route.ts
|
|
1949
|
+
import { Either as Either2, Schema as Schema2 } from "effect";
|
|
1950
|
+
import { ArrayFormatter as ArrayFormatter2, TreeFormatter as TreeFormatter2 } from "effect/ParseResult";
|
|
1951
|
+
var PUT_PATH = "/api/saacms/v1/:collection/:id";
|
|
1952
|
+
var CACHE_CONTROL = "no-store";
|
|
1953
|
+
function mountPutRoute(app, config) {
|
|
1954
|
+
const collections = config.collections ?? [];
|
|
1955
|
+
app.put(PUT_PATH, async (c) => {
|
|
1956
|
+
const slug = c.req.param("collection") ?? "";
|
|
1957
|
+
const recordId = c.req.param("id") ?? "";
|
|
1958
|
+
const collection = collections.find((coll) => coll.slug === slug);
|
|
1959
|
+
if (collection == null) {
|
|
1960
|
+
return problemDetails(c, {
|
|
1961
|
+
code: "collection-not-found",
|
|
1962
|
+
title: "Collection not found",
|
|
1963
|
+
status: 404,
|
|
1964
|
+
detail: slug
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
const user = readUser7(c);
|
|
1968
|
+
const rows = config.storage?.rows;
|
|
1969
|
+
if (rows == null) {
|
|
1970
|
+
return problemDetails(c, {
|
|
1971
|
+
code: "storage-unavailable",
|
|
1972
|
+
title: "Row storage not configured",
|
|
1973
|
+
status: 503,
|
|
1974
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
if (!isJsonContentType5(c.req.header("content-type"))) {
|
|
1978
|
+
return problemDetails(c, {
|
|
1979
|
+
code: "unsupported-media-type",
|
|
1980
|
+
title: "Unsupported Media Type",
|
|
1981
|
+
status: 415,
|
|
1982
|
+
detail: "Content-Type must be application/json"
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
let parsedBody;
|
|
1986
|
+
try {
|
|
1987
|
+
parsedBody = await c.req.json();
|
|
1988
|
+
} catch {
|
|
1989
|
+
return problemDetails(c, {
|
|
1990
|
+
code: "malformed-body",
|
|
1991
|
+
title: "Malformed JSON body",
|
|
1992
|
+
status: 400,
|
|
1993
|
+
detail: "Request body is not valid JSON"
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
if (!isPlainObject(parsedBody)) {
|
|
1997
|
+
return problemDetails(c, {
|
|
1998
|
+
code: "malformed-body",
|
|
1999
|
+
title: "Body must be a JSON object",
|
|
2000
|
+
status: 400,
|
|
2001
|
+
detail: "PUT body MUST be a JSON object representing the full record"
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
const schema = collection.schema;
|
|
2005
|
+
const decoded = Schema2.decodeUnknownEither(schema)(parsedBody);
|
|
2006
|
+
if (Either2.isLeft(decoded)) {
|
|
2007
|
+
const error = decoded.left;
|
|
2008
|
+
return problemDetails(c, {
|
|
2009
|
+
code: "invalid-body",
|
|
2010
|
+
title: "Request body failed schema validation",
|
|
2011
|
+
status: 422,
|
|
2012
|
+
detail: TreeFormatter2.formatErrorSync(error),
|
|
2013
|
+
extensions: { issues: ArrayFormatter2.formatErrorSync(error) }
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
const validatedBody = decoded.right;
|
|
2017
|
+
const table = slugToTableName(collection.slug);
|
|
2018
|
+
const rawExisting = await rows.getById(table, recordId);
|
|
2019
|
+
const existing = rawExisting == null ? null : decodeBooleanColumns(schema, decodeJsonColumns(schema, rawExisting));
|
|
2020
|
+
if (existing == null) {
|
|
2021
|
+
return problemDetails(c, {
|
|
2022
|
+
code: "record-not-found",
|
|
2023
|
+
title: "Record not found",
|
|
2024
|
+
status: 404,
|
|
2025
|
+
detail: `${slug}/${recordId}`
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
const ifMatch = c.req.header("if-match");
|
|
2029
|
+
if (ifMatch != null) {
|
|
2030
|
+
const currentEtag = computeWeakEtag5(slug, recordId, existing);
|
|
2031
|
+
if (currentEtag == null || currentEtag !== ifMatch) {
|
|
2032
|
+
return problemDetails(c, {
|
|
2033
|
+
code: "precondition-failed",
|
|
2034
|
+
title: "Precondition Failed",
|
|
2035
|
+
status: 412,
|
|
2036
|
+
detail: currentEtag == null ? "Record has no updatedAt; If-Match cannot be satisfied" : "If-Match header does not match current resource etag"
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
const updatePredicate = collection.access?.update;
|
|
2041
|
+
if (updatePredicate != null) {
|
|
2042
|
+
const ctx = {
|
|
2043
|
+
user,
|
|
2044
|
+
record: existing,
|
|
2045
|
+
input: validatedBody
|
|
2046
|
+
};
|
|
2047
|
+
const result = await updatePredicate(ctx);
|
|
2048
|
+
if (!allows3(result, existing)) {
|
|
2049
|
+
recordAuditEvent(c, {
|
|
2050
|
+
op: "put",
|
|
2051
|
+
collection: slug,
|
|
2052
|
+
user,
|
|
2053
|
+
decision: "denied",
|
|
2054
|
+
reason: "access.update predicate denied",
|
|
2055
|
+
recordId,
|
|
2056
|
+
at: new Date().toISOString()
|
|
2057
|
+
});
|
|
2058
|
+
return problemDetails(c, {
|
|
2059
|
+
code: "forbidden",
|
|
2060
|
+
title: "Forbidden",
|
|
2061
|
+
status: 403,
|
|
2062
|
+
detail: `${slug}/${recordId}`
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
recordAuditEvent(c, {
|
|
2066
|
+
op: "put",
|
|
2067
|
+
collection: slug,
|
|
2068
|
+
user,
|
|
2069
|
+
decision: "granted",
|
|
2070
|
+
recordId,
|
|
2071
|
+
at: new Date().toISOString()
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
try {
|
|
2075
|
+
await rows.update(table, recordId, encodeJsonColumns(schema, encodeBooleanColumns(schema, validatedBody)));
|
|
2076
|
+
} catch (err) {
|
|
2077
|
+
if (err instanceof UniqueConstraintError) {
|
|
2078
|
+
return problemDetails(c, {
|
|
2079
|
+
code: "unique-constraint-violated",
|
|
2080
|
+
title: "Unique constraint violated",
|
|
2081
|
+
status: 409,
|
|
2082
|
+
detail: "A record with the same value(s) for the unique fields already exists."
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
throw err;
|
|
2086
|
+
}
|
|
2087
|
+
const rawUpdated = await rows.getById(table, recordId);
|
|
2088
|
+
const updated = rawUpdated == null ? null : decodeBooleanColumns(schema, decodeJsonColumns(schema, rawUpdated));
|
|
2089
|
+
if (updated == null) {
|
|
2090
|
+
return problemDetails(c, {
|
|
2091
|
+
code: "record-not-found",
|
|
2092
|
+
title: "Record vanished after update",
|
|
2093
|
+
status: 404,
|
|
2094
|
+
detail: `${slug}/${recordId}`
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
const envelope = {
|
|
2098
|
+
data: updated,
|
|
2099
|
+
_links: {
|
|
2100
|
+
self: { href: `/api/saacms/v1/${slug}/${recordId}` },
|
|
2101
|
+
collection: { href: `/api/saacms/v1/${slug}` }
|
|
2102
|
+
}
|
|
2103
|
+
};
|
|
2104
|
+
const headers = {
|
|
2105
|
+
"Content-Type": "application/json",
|
|
2106
|
+
"Cache-Control": CACHE_CONTROL
|
|
2107
|
+
};
|
|
2108
|
+
const newEtag = computeWeakEtag5(slug, recordId, updated);
|
|
2109
|
+
if (newEtag != null)
|
|
2110
|
+
headers["ETag"] = newEtag;
|
|
2111
|
+
return c.newResponse(JSON.stringify(envelope), 200, headers);
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
function readUser7(c) {
|
|
2115
|
+
const raw = c.get("user");
|
|
2116
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
2117
|
+
}
|
|
2118
|
+
function allows3(result, record) {
|
|
2119
|
+
if (typeof result === "boolean")
|
|
2120
|
+
return result;
|
|
2121
|
+
for (const [key, value] of Object.entries(result.where)) {
|
|
2122
|
+
if (record[key] !== value)
|
|
2123
|
+
return false;
|
|
2124
|
+
}
|
|
2125
|
+
return true;
|
|
2126
|
+
}
|
|
2127
|
+
function isPlainObject(v) {
|
|
2128
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
2129
|
+
}
|
|
2130
|
+
function isJsonContentType5(header) {
|
|
2131
|
+
if (header == null)
|
|
2132
|
+
return false;
|
|
2133
|
+
const semi = header.indexOf(";");
|
|
2134
|
+
const mediaType = (semi === -1 ? header : header.slice(0, semi)).trim().toLowerCase();
|
|
2135
|
+
return mediaType === "application/json";
|
|
2136
|
+
}
|
|
2137
|
+
function computeWeakEtag5(slug, recordId, record) {
|
|
2138
|
+
const updatedAt = record["updatedAt"];
|
|
2139
|
+
if (typeof updatedAt !== "string" || updatedAt.length === 0)
|
|
2140
|
+
return null;
|
|
2141
|
+
return `W/"${slug}:${recordId}:${updatedAt}"`;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// src/runtime/read-route.ts
|
|
2145
|
+
import { Cause as Cause4, Effect as Effect7, Exit as Exit7 } from "effect";
|
|
2146
|
+
var READ_PATH = "/api/saacms/v1/:collection/:id";
|
|
2147
|
+
var CACHE_CONTROL2 = "private, max-age=30";
|
|
2148
|
+
var VARY3 = "Authorization";
|
|
2149
|
+
function mountReadRoute(app, config) {
|
|
2150
|
+
const collections = config.collections ?? [];
|
|
2151
|
+
app.get(READ_PATH, async (c) => {
|
|
2152
|
+
const slug = c.req.param("collection") ?? "";
|
|
2153
|
+
const recordId = c.req.param("id") ?? "";
|
|
2154
|
+
const collection = collections.find((coll) => coll.slug === slug);
|
|
2155
|
+
if (collection == null) {
|
|
2156
|
+
return problemDetails(c, {
|
|
2157
|
+
code: "collection-not-found",
|
|
2158
|
+
title: "Collection not found",
|
|
2159
|
+
status: 404,
|
|
2160
|
+
detail: slug
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
const user = readUser8(c);
|
|
2164
|
+
const rows = config.storage?.rows;
|
|
2165
|
+
if (rows == null) {
|
|
2166
|
+
return problemDetails(c, {
|
|
2167
|
+
code: "storage-unavailable",
|
|
2168
|
+
title: "Row storage not configured",
|
|
2169
|
+
status: 503,
|
|
2170
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
const beforeHooks = resolveHooksFor("beforeRead", {
|
|
2174
|
+
collection,
|
|
2175
|
+
plugins: config.plugins
|
|
2176
|
+
});
|
|
2177
|
+
if (beforeHooks.length > 0) {
|
|
2178
|
+
const ctx = { op: "read", user };
|
|
2179
|
+
const exit = await Effect7.runPromiseExit(runHooks("beforeRead", ctx, beforeHooks));
|
|
2180
|
+
if (Exit7.isFailure(exit)) {
|
|
2181
|
+
return problemDetails(c, {
|
|
2182
|
+
code: "hook-rejected",
|
|
2183
|
+
title: "Read rejected by a lifecycle hook",
|
|
2184
|
+
status: 403,
|
|
2185
|
+
detail: hookRejectionDetail4(exit.cause)
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
const readPredicate = collection.access?.read;
|
|
2190
|
+
if (readPredicate != null) {
|
|
2191
|
+
const collectionLevelResult = await readPredicate({ user });
|
|
2192
|
+
if (collectionLevelResult === false) {
|
|
2193
|
+
recordAuditEvent(c, {
|
|
2194
|
+
op: "read",
|
|
2195
|
+
collection: slug,
|
|
2196
|
+
user,
|
|
2197
|
+
decision: "denied",
|
|
2198
|
+
reason: "collection-level read predicate denied",
|
|
2199
|
+
at: new Date().toISOString()
|
|
2200
|
+
});
|
|
2201
|
+
return problemDetails(c, {
|
|
2202
|
+
code: "collection-not-found",
|
|
2203
|
+
title: "Collection not found",
|
|
2204
|
+
status: 404,
|
|
2205
|
+
detail: slug
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
const cacheKey = `saacms:record:${slug}:${recordId}`;
|
|
2210
|
+
const cache = resolveCache(c);
|
|
2211
|
+
const hit = await cache.get(cacheKey);
|
|
2212
|
+
const rawRecord = hit != null ? hit.value : await rows.getById(slugToTableName(collection.slug), recordId);
|
|
2213
|
+
if (rawRecord == null) {
|
|
2214
|
+
return problemDetails(c, {
|
|
2215
|
+
code: "record-not-found",
|
|
2216
|
+
title: "Record not found",
|
|
2217
|
+
status: 404,
|
|
2218
|
+
detail: `${slug}/${recordId}`
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
if (hit == null) {
|
|
2222
|
+
await cache.put(cacheKey, rawRecord, {
|
|
2223
|
+
tags: [cacheKey, `saacms:collection:${slug}`],
|
|
2224
|
+
expirationTtl: 30
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
const record = decodeBooleanColumns(collection.schema, decodeJsonColumns(collection.schema, rawRecord));
|
|
2228
|
+
if (readPredicate != null) {
|
|
2229
|
+
const result = await readPredicate({ user, record });
|
|
2230
|
+
if (!allows4(result, record)) {
|
|
2231
|
+
recordAuditEvent(c, {
|
|
2232
|
+
op: "read",
|
|
2233
|
+
collection: slug,
|
|
2234
|
+
user,
|
|
2235
|
+
decision: "denied",
|
|
2236
|
+
reason: "per-record read predicate denied",
|
|
2237
|
+
recordId,
|
|
2238
|
+
at: new Date().toISOString()
|
|
2239
|
+
});
|
|
2240
|
+
return problemDetails(c, {
|
|
2241
|
+
code: "forbidden",
|
|
2242
|
+
title: "Forbidden",
|
|
2243
|
+
status: 403,
|
|
2244
|
+
detail: `${slug}/${recordId}`
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
recordAuditEvent(c, {
|
|
2248
|
+
op: "read",
|
|
2249
|
+
collection: slug,
|
|
2250
|
+
user,
|
|
2251
|
+
decision: "granted",
|
|
2252
|
+
recordId,
|
|
2253
|
+
at: new Date().toISOString()
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
const etag = computeWeakEtag6(slug, recordId, record);
|
|
2257
|
+
if (etag != null) {
|
|
2258
|
+
const ifNoneMatch = c.req.header("if-none-match");
|
|
2259
|
+
if (ifNoneMatch != null && ifNoneMatch === etag) {
|
|
2260
|
+
return new Response(null, {
|
|
2261
|
+
status: 304,
|
|
2262
|
+
headers: {
|
|
2263
|
+
ETag: etag,
|
|
2264
|
+
"Cache-Control": CACHE_CONTROL2,
|
|
2265
|
+
Vary: VARY3
|
|
2266
|
+
}
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
let outgoing = record;
|
|
2271
|
+
const afterHooks = resolveHooksFor("afterRead", {
|
|
2272
|
+
collection,
|
|
2273
|
+
plugins: config.plugins
|
|
2274
|
+
});
|
|
2275
|
+
if (afterHooks.length > 0) {
|
|
2276
|
+
const ctx = { op: "read", user, record };
|
|
2277
|
+
const exit = await Effect7.runPromiseExit(runAfterRead2(ctx, afterHooks));
|
|
2278
|
+
if (Exit7.isFailure(exit)) {
|
|
2279
|
+
console.warn("[saacms/hooks] afterRead failOnError hook failed");
|
|
2280
|
+
} else if (isHookObject3(exit.value)) {
|
|
2281
|
+
outgoing = exit.value;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
const envelope = {
|
|
2285
|
+
data: outgoing,
|
|
2286
|
+
_links: {
|
|
2287
|
+
self: { href: `/api/saacms/v1/${slug}/${recordId}` },
|
|
2288
|
+
collection: { href: `/api/saacms/v1/${slug}` }
|
|
2289
|
+
}
|
|
2290
|
+
};
|
|
2291
|
+
const headers = {
|
|
2292
|
+
"Cache-Control": CACHE_CONTROL2,
|
|
2293
|
+
Vary: VARY3
|
|
2294
|
+
};
|
|
2295
|
+
if (etag != null)
|
|
2296
|
+
headers["ETag"] = etag;
|
|
2297
|
+
return c.json(envelope, 200, headers);
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
function readUser8(c) {
|
|
2301
|
+
const raw = c.get("user");
|
|
2302
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
2303
|
+
}
|
|
2304
|
+
function allows4(result, record) {
|
|
2305
|
+
if (typeof result === "boolean")
|
|
2306
|
+
return result;
|
|
2307
|
+
for (const [key, value] of Object.entries(result.where)) {
|
|
2308
|
+
if (record[key] !== value)
|
|
2309
|
+
return false;
|
|
2310
|
+
}
|
|
2311
|
+
return true;
|
|
2312
|
+
}
|
|
2313
|
+
function computeWeakEtag6(slug, recordId, record) {
|
|
2314
|
+
const updatedAt = record["updatedAt"];
|
|
2315
|
+
if (typeof updatedAt !== "string" || updatedAt.length === 0)
|
|
2316
|
+
return null;
|
|
2317
|
+
return `W/"${slug}:${recordId}:${updatedAt}"`;
|
|
2318
|
+
}
|
|
2319
|
+
function runAfterRead2(ctx, hooks) {
|
|
2320
|
+
return hooks.reduce((acc, hook) => Effect7.flatMap(acc, (prev) => {
|
|
2321
|
+
const ran = hook.fn(ctx);
|
|
2322
|
+
const guarded = hook.options?.failOnError === true ? ran : Effect7.catchAll(ran, (err) => Effect7.sync(() => {
|
|
2323
|
+
console.warn("[saacms/hooks] afterRead hook failed:", err);
|
|
2324
|
+
return prev;
|
|
2325
|
+
}));
|
|
2326
|
+
return Effect7.map(guarded, (out) => isHookObject3(out) ? out : prev);
|
|
2327
|
+
}), Effect7.succeed(ctx.record));
|
|
2328
|
+
}
|
|
2329
|
+
function isHookObject3(value) {
|
|
2330
|
+
return typeof value === "object" && value !== null;
|
|
2331
|
+
}
|
|
2332
|
+
function hookRejectionDetail4(cause) {
|
|
2333
|
+
const err = Cause4.squash(cause);
|
|
2334
|
+
if (err instanceof Error)
|
|
2335
|
+
return err.message;
|
|
2336
|
+
if (typeof err === "string")
|
|
2337
|
+
return err;
|
|
2338
|
+
try {
|
|
2339
|
+
return JSON.stringify(err);
|
|
2340
|
+
} catch {
|
|
2341
|
+
return String(err);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/runtime/upload-route.ts
|
|
2346
|
+
import { Either as Either3, Schema as Schema3 } from "effect";
|
|
2347
|
+
import { ArrayFormatter as ArrayFormatter3, TreeFormatter as TreeFormatter3 } from "effect/ParseResult";
|
|
2348
|
+
var ACTION_SUFFIX = ":upload";
|
|
2349
|
+
var UPLOAD_PATH = "/api/saacms/v1/:collectionAction{[^/]+:upload}";
|
|
2350
|
+
var PATH_PREFIX3 = "/api/saacms/v1/";
|
|
2351
|
+
var FILE_FIELD = "file";
|
|
2352
|
+
var ANON_UPLOADER = "anonymous";
|
|
2353
|
+
function mountUploadRoute(app, config) {
|
|
2354
|
+
const collections = config.collections ?? [];
|
|
2355
|
+
app.post(UPLOAD_PATH, async (c) => {
|
|
2356
|
+
const raw = c.req.param("collectionAction") ?? "";
|
|
2357
|
+
const slug = raw.endsWith(ACTION_SUFFIX) ? raw.slice(0, -ACTION_SUFFIX.length) : raw;
|
|
2358
|
+
const collection = collections.find((coll) => coll.slug === slug);
|
|
2359
|
+
if (collection == null) {
|
|
2360
|
+
return problemDetails(c, {
|
|
2361
|
+
code: "collection-not-found",
|
|
2362
|
+
title: "Collection not found",
|
|
2363
|
+
status: 404,
|
|
2364
|
+
detail: slug
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
if (collection.kind !== "media") {
|
|
2368
|
+
return problemDetails(c, {
|
|
2369
|
+
code: "not-a-media-collection",
|
|
2370
|
+
title: "Action not valid for this Collection",
|
|
2371
|
+
status: 409,
|
|
2372
|
+
detail: `:upload is only valid on a kind:"media" Collection; ` + `"${slug}" is kind:"${collection.kind ?? "data"}". ` + `Declare the Collection with kind:"media" (ADR 0009) to accept uploads.`
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
const user = readUser9(c);
|
|
2376
|
+
const media = config.storage?.media;
|
|
2377
|
+
const rows = config.storage?.rows;
|
|
2378
|
+
if (media == null || rows == null) {
|
|
2379
|
+
const missing = media == null && rows == null ? "config.storage.media and config.storage.rows" : media == null ? "config.storage.media" : "config.storage.rows";
|
|
2380
|
+
return problemDetails(c, {
|
|
2381
|
+
code: "media-storage-misconfigured",
|
|
2382
|
+
title: "Media storage not configured",
|
|
2383
|
+
status: 503,
|
|
2384
|
+
detail: `${missing} must be wired to serve bucket-mode media uploads. ` + `Wire an @saacms/storage-r2 r2Adapter as config.storage.media ` + `(ADR 0024) and a row adapter as config.storage.rows (ADR 0020). ` + `Repo-mode media (commit-on-publish) is a later wave — use the ` + `Publish flow for repo-mode Collections, not this endpoint.`
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
if (!isMultipartContentType(c.req.header("content-type"))) {
|
|
2388
|
+
return problemDetails(c, {
|
|
2389
|
+
code: "unsupported-media-type",
|
|
2390
|
+
title: "Unsupported media type",
|
|
2391
|
+
status: 415,
|
|
2392
|
+
detail: `Content-Type must be multipart/form-data; the "${FILE_FIELD}" part carries the binary`
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
let form;
|
|
2396
|
+
try {
|
|
2397
|
+
form = await c.req.formData();
|
|
2398
|
+
} catch {
|
|
2399
|
+
return problemDetails(c, {
|
|
2400
|
+
code: "malformed-body",
|
|
2401
|
+
title: "Malformed request body",
|
|
2402
|
+
status: 400,
|
|
2403
|
+
detail: "Request body is not valid multipart/form-data"
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
const filePart = form.get(FILE_FIELD);
|
|
2407
|
+
if (!isFilePart(filePart)) {
|
|
2408
|
+
return problemDetails(c, {
|
|
2409
|
+
code: "invalid-body",
|
|
2410
|
+
title: "Request body failed schema validation",
|
|
2411
|
+
status: 422,
|
|
2412
|
+
detail: `Missing required multipart file part "${FILE_FIELD}"`,
|
|
2413
|
+
extensions: {
|
|
2414
|
+
issues: [
|
|
2415
|
+
{
|
|
2416
|
+
_tag: "Missing",
|
|
2417
|
+
path: [FILE_FIELD],
|
|
2418
|
+
message: `multipart/form-data must include a file part named "${FILE_FIELD}"`
|
|
2419
|
+
}
|
|
2420
|
+
]
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
const bytes = toFreshBytes(new Uint8Array(await filePart.arrayBuffer()));
|
|
2425
|
+
const size = bytes.byteLength;
|
|
2426
|
+
const mime = filePart.type !== "" ? filePart.type : "application/octet-stream";
|
|
2427
|
+
const supplied = readDimensions(form);
|
|
2428
|
+
const parsed = supplied ?? sniffImageDimensions(bytes);
|
|
2429
|
+
const width = parsed?.width ?? 0;
|
|
2430
|
+
const height = parsed?.height ?? 0;
|
|
2431
|
+
const key = await contentHashKey(bytes, mime);
|
|
2432
|
+
const originalKey = key;
|
|
2433
|
+
const originalUrl = await media.signedUrl(key);
|
|
2434
|
+
const uploadedBy = user != null ? String(user.id) : ANON_UPLOADER;
|
|
2435
|
+
const authorFields = readAuthorFields(form);
|
|
2436
|
+
const candidate = {
|
|
2437
|
+
...authorFields,
|
|
2438
|
+
mime,
|
|
2439
|
+
size,
|
|
2440
|
+
width,
|
|
2441
|
+
height,
|
|
2442
|
+
uploadedBy,
|
|
2443
|
+
originalKey,
|
|
2444
|
+
originalUrl
|
|
2445
|
+
};
|
|
2446
|
+
let mediaSchema;
|
|
2447
|
+
try {
|
|
2448
|
+
mediaSchema = withMediaFields(collection).schema;
|
|
2449
|
+
} catch (err) {
|
|
2450
|
+
return problemDetails(c, {
|
|
2451
|
+
code: "media-storage-misconfigured",
|
|
2452
|
+
title: "Media Collection schema is invalid",
|
|
2453
|
+
status: 503,
|
|
2454
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
const decoded = Schema3.decodeUnknownEither(mediaSchema)(candidate);
|
|
2458
|
+
if (Either3.isLeft(decoded)) {
|
|
2459
|
+
const error = decoded.left;
|
|
2460
|
+
return problemDetails(c, {
|
|
2461
|
+
code: "invalid-body",
|
|
2462
|
+
title: "Request body failed schema validation",
|
|
2463
|
+
status: 422,
|
|
2464
|
+
detail: TreeFormatter3.formatErrorSync(error),
|
|
2465
|
+
extensions: { issues: ArrayFormatter3.formatErrorSync(error) }
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
const validated = decoded.right;
|
|
2469
|
+
const createPredicate = collection.access?.create;
|
|
2470
|
+
if (createPredicate != null) {
|
|
2471
|
+
const result = await createPredicate({ user, record: validated });
|
|
2472
|
+
if (!allows5(result, validated)) {
|
|
2473
|
+
return problemDetails(c, {
|
|
2474
|
+
code: "forbidden",
|
|
2475
|
+
title: "Forbidden",
|
|
2476
|
+
status: 403,
|
|
2477
|
+
detail: slug
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
try {
|
|
2482
|
+
await media.put(key, bytes, { contentType: mime });
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
return problemDetails(c, {
|
|
2485
|
+
code: "storage-write-failed",
|
|
2486
|
+
title: "Media object write failed",
|
|
2487
|
+
status: 500,
|
|
2488
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
let newId;
|
|
2492
|
+
try {
|
|
2493
|
+
({ id: newId } = await rows.insert(collection.slug, validated));
|
|
2494
|
+
} catch (err) {
|
|
2495
|
+
return problemDetails(c, {
|
|
2496
|
+
code: "storage-write-failed",
|
|
2497
|
+
title: "Media record write failed",
|
|
2498
|
+
status: 500,
|
|
2499
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
const href = `${PATH_PREFIX3}${collection.slug}/${newId}`;
|
|
2503
|
+
const envelope = {
|
|
2504
|
+
data: { ...validated, id: newId },
|
|
2505
|
+
_links: {
|
|
2506
|
+
self: { href },
|
|
2507
|
+
collection: { href: `${PATH_PREFIX3}${collection.slug}` }
|
|
2508
|
+
}
|
|
2509
|
+
};
|
|
2510
|
+
return c.json(envelope, 201, {
|
|
2511
|
+
"Content-Type": "application/json",
|
|
2512
|
+
"Cache-Control": "no-store",
|
|
2513
|
+
Location: href
|
|
2514
|
+
});
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
function readUser9(c) {
|
|
2518
|
+
const raw = c.get("user");
|
|
2519
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
2520
|
+
}
|
|
2521
|
+
function toFreshBytes(src) {
|
|
2522
|
+
const out = new Uint8Array(src.byteLength);
|
|
2523
|
+
out.set(src);
|
|
2524
|
+
return out;
|
|
2525
|
+
}
|
|
2526
|
+
function isMultipartContentType(header) {
|
|
2527
|
+
if (header == null)
|
|
2528
|
+
return false;
|
|
2529
|
+
const semi = header.indexOf(";");
|
|
2530
|
+
const mediaType = (semi === -1 ? header : header.slice(0, semi)).trim().toLowerCase();
|
|
2531
|
+
return mediaType === "multipart/form-data";
|
|
2532
|
+
}
|
|
2533
|
+
function isFilePart(v) {
|
|
2534
|
+
return v != null && typeof v !== "string" && typeof v.arrayBuffer === "function";
|
|
2535
|
+
}
|
|
2536
|
+
function readDimensions(form) {
|
|
2537
|
+
const w = form.get("width");
|
|
2538
|
+
const h = form.get("height");
|
|
2539
|
+
if (typeof w !== "string" && typeof h !== "string")
|
|
2540
|
+
return;
|
|
2541
|
+
const width = typeof w === "string" ? Number(w) : NaN;
|
|
2542
|
+
const height = typeof h === "string" ? Number(h) : NaN;
|
|
2543
|
+
if (Number.isFinite(width) || Number.isFinite(height)) {
|
|
2544
|
+
return {
|
|
2545
|
+
width: Number.isFinite(width) ? width : 0,
|
|
2546
|
+
height: Number.isFinite(height) ? height : 0
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
var RESERVED_FORM_KEYS = new Set([
|
|
2552
|
+
FILE_FIELD,
|
|
2553
|
+
"width",
|
|
2554
|
+
"height",
|
|
2555
|
+
"mime",
|
|
2556
|
+
"size",
|
|
2557
|
+
"uploadedBy",
|
|
2558
|
+
"originalKey",
|
|
2559
|
+
"originalUrl"
|
|
2560
|
+
]);
|
|
2561
|
+
function readAuthorFields(form) {
|
|
2562
|
+
const out = {};
|
|
2563
|
+
for (const [k, v] of form.entries()) {
|
|
2564
|
+
if (RESERVED_FORM_KEYS.has(k))
|
|
2565
|
+
continue;
|
|
2566
|
+
if (typeof v === "string")
|
|
2567
|
+
out[k] = v;
|
|
2568
|
+
}
|
|
2569
|
+
return out;
|
|
2570
|
+
}
|
|
2571
|
+
function allows5(result, record) {
|
|
2572
|
+
if (typeof result === "boolean")
|
|
2573
|
+
return result;
|
|
2574
|
+
for (const [key, value] of Object.entries(result.where)) {
|
|
2575
|
+
if (record[key] !== value)
|
|
2576
|
+
return false;
|
|
2577
|
+
}
|
|
2578
|
+
return true;
|
|
2579
|
+
}
|
|
2580
|
+
async function contentHashKey(bytes, mime) {
|
|
2581
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
2582
|
+
const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2583
|
+
return `${hex}${extensionForMime(mime)}`;
|
|
2584
|
+
}
|
|
2585
|
+
function extensionForMime(mime) {
|
|
2586
|
+
switch (mime) {
|
|
2587
|
+
case "image/png":
|
|
2588
|
+
return ".png";
|
|
2589
|
+
case "image/jpeg":
|
|
2590
|
+
return ".jpg";
|
|
2591
|
+
case "image/gif":
|
|
2592
|
+
return ".gif";
|
|
2593
|
+
case "image/webp":
|
|
2594
|
+
return ".webp";
|
|
2595
|
+
case "image/avif":
|
|
2596
|
+
return ".avif";
|
|
2597
|
+
case "image/svg+xml":
|
|
2598
|
+
return ".svg";
|
|
2599
|
+
default:
|
|
2600
|
+
return "";
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
function sniffImageDimensions(bytes) {
|
|
2604
|
+
return parsePng(bytes) ?? parseJpeg(bytes);
|
|
2605
|
+
}
|
|
2606
|
+
var PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
2607
|
+
function parsePng(b) {
|
|
2608
|
+
if (b.length < 24)
|
|
2609
|
+
return;
|
|
2610
|
+
for (let i = 0;i < PNG_SIG.length; i++) {
|
|
2611
|
+
if (b[i] !== PNG_SIG[i])
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
if (b[12] !== 73 || b[13] !== 72 || b[14] !== 68 || b[15] !== 82) {
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
2618
|
+
const width = view.getUint32(16, false);
|
|
2619
|
+
const height = view.getUint32(20, false);
|
|
2620
|
+
if (width === 0 || height === 0)
|
|
2621
|
+
return;
|
|
2622
|
+
return { width, height };
|
|
2623
|
+
}
|
|
2624
|
+
function parseJpeg(b) {
|
|
2625
|
+
if (b.length < 4 || b[0] !== 255 || b[1] !== 216)
|
|
2626
|
+
return;
|
|
2627
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
2628
|
+
let off = 2;
|
|
2629
|
+
while (off + 1 < b.length) {
|
|
2630
|
+
if (b[off] !== 255) {
|
|
2631
|
+
off++;
|
|
2632
|
+
continue;
|
|
2633
|
+
}
|
|
2634
|
+
const marker = b[off + 1] ?? 0;
|
|
2635
|
+
off += 2;
|
|
2636
|
+
if (marker === 255 || marker === 216 || marker === 217)
|
|
2637
|
+
continue;
|
|
2638
|
+
if (marker >= 208 && marker <= 215)
|
|
2639
|
+
continue;
|
|
2640
|
+
if (off + 1 >= b.length)
|
|
2641
|
+
return;
|
|
2642
|
+
const segLen = view.getUint16(off, false);
|
|
2643
|
+
const isSof = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
|
|
2644
|
+
if (isSof) {
|
|
2645
|
+
if (off + 5 >= b.length)
|
|
2646
|
+
return;
|
|
2647
|
+
const height = view.getUint16(off + 3, false);
|
|
2648
|
+
const width = view.getUint16(off + 5, false);
|
|
2649
|
+
if (width === 0 || height === 0)
|
|
2650
|
+
return;
|
|
2651
|
+
return { width, height };
|
|
2652
|
+
}
|
|
2653
|
+
off += segLen;
|
|
2654
|
+
}
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// src/runtime/update-route.ts
|
|
2659
|
+
import { Cause as Cause5, Effect as Effect8, Either as Either4, Exit as Exit8, ParseResult, Schema as Schema4 } from "effect";
|
|
2660
|
+
var UPDATE_PATH = "/api/saacms/v1/:collection/:id";
|
|
2661
|
+
var CACHE_CONTROL3 = "no-store";
|
|
2662
|
+
var MERGE_STYLE = "shallow-rfc7396";
|
|
2663
|
+
var ALLOWED_CONTENT_TYPES = new Set([
|
|
2664
|
+
"application/json",
|
|
2665
|
+
"application/merge-patch+json"
|
|
2666
|
+
]);
|
|
2667
|
+
function mountUpdateRoute(app, config) {
|
|
2668
|
+
const collections = config.collections ?? [];
|
|
2669
|
+
app.patch(UPDATE_PATH, async (c) => {
|
|
2670
|
+
const slug = c.req.param("collection") ?? "";
|
|
2671
|
+
const recordId = c.req.param("id") ?? "";
|
|
2672
|
+
const collection = collections.find((coll) => coll.slug === slug);
|
|
2673
|
+
if (collection == null) {
|
|
2674
|
+
return problemDetails(c, {
|
|
2675
|
+
code: "collection-not-found",
|
|
2676
|
+
title: "Collection not found",
|
|
2677
|
+
status: 404,
|
|
2678
|
+
detail: slug
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
const user = readUser10(c);
|
|
2682
|
+
const rows = config.storage?.rows;
|
|
2683
|
+
if (rows == null) {
|
|
2684
|
+
return problemDetails(c, {
|
|
2685
|
+
code: "storage-unavailable",
|
|
2686
|
+
title: "Row storage not configured",
|
|
2687
|
+
status: 503,
|
|
2688
|
+
detail: "config.storage.rows is required to serve this endpoint"
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
const mediaType = (c.req.header("content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
2692
|
+
if (!ALLOWED_CONTENT_TYPES.has(mediaType)) {
|
|
2693
|
+
return problemDetails(c, {
|
|
2694
|
+
code: "unsupported-media-type",
|
|
2695
|
+
title: "Unsupported Media Type",
|
|
2696
|
+
status: 415,
|
|
2697
|
+
detail: `Expected application/json or application/merge-patch+json; received '${mediaType}'`
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
let patch;
|
|
2701
|
+
try {
|
|
2702
|
+
patch = await c.req.json();
|
|
2703
|
+
} catch {
|
|
2704
|
+
return problemDetails(c, {
|
|
2705
|
+
code: "malformed-body",
|
|
2706
|
+
title: "Malformed JSON body",
|
|
2707
|
+
status: 400,
|
|
2708
|
+
detail: "Request body is not valid JSON"
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
if (!isPlainObject2(patch)) {
|
|
2712
|
+
return problemDetails(c, {
|
|
2713
|
+
code: "malformed-body",
|
|
2714
|
+
title: "Body must be a JSON object",
|
|
2715
|
+
status: 400,
|
|
2716
|
+
detail: "RFC 7396 merge patches MUST be JSON objects"
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
const table = slugToTableName(collection.slug);
|
|
2720
|
+
const schemaForJson = collection.schema;
|
|
2721
|
+
const rawExisting = await rows.getById(table, recordId);
|
|
2722
|
+
const existing = rawExisting == null ? null : decodeBooleanColumns(schemaForJson, decodeJsonColumns(schemaForJson, rawExisting));
|
|
2723
|
+
if (existing == null) {
|
|
2724
|
+
return problemDetails(c, {
|
|
2725
|
+
code: "record-not-found",
|
|
2726
|
+
title: "Record not found",
|
|
2727
|
+
status: 404,
|
|
2728
|
+
detail: `${slug}/${recordId}`
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
const ifMatch = c.req.header("if-match");
|
|
2732
|
+
if (ifMatch != null) {
|
|
2733
|
+
const currentEtag = computeWeakEtag7(slug, recordId, existing);
|
|
2734
|
+
if (currentEtag == null || currentEtag !== ifMatch) {
|
|
2735
|
+
return problemDetails(c, {
|
|
2736
|
+
code: "precondition-failed",
|
|
2737
|
+
title: "Precondition Failed",
|
|
2738
|
+
status: 412,
|
|
2739
|
+
detail: currentEtag == null ? "Record has no updatedAt; If-Match cannot be satisfied" : "If-Match header does not match current resource etag"
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
const merged = { ...existing };
|
|
2744
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
2745
|
+
merged[k] = v;
|
|
2746
|
+
}
|
|
2747
|
+
const schema = collection.schema;
|
|
2748
|
+
const decoded = Schema4.decodeUnknownEither(schema)(merged);
|
|
2749
|
+
if (Either4.isLeft(decoded)) {
|
|
2750
|
+
const issues = ParseResult.ArrayFormatter.formatErrorSync(decoded.left);
|
|
2751
|
+
return problemDetails(c, {
|
|
2752
|
+
code: "invalid-body",
|
|
2753
|
+
title: "Merged record fails schema validation",
|
|
2754
|
+
status: 422,
|
|
2755
|
+
detail: `${slug}/${recordId}`,
|
|
2756
|
+
extensions: { issues }
|
|
2757
|
+
});
|
|
2758
|
+
}
|
|
2759
|
+
let storagePatch = patch;
|
|
2760
|
+
const beforeHooks = resolveHooksFor("beforeChange", {
|
|
2761
|
+
collection,
|
|
2762
|
+
plugins: config.plugins
|
|
2763
|
+
});
|
|
2764
|
+
if (beforeHooks.length > 0) {
|
|
2765
|
+
const ctx = {
|
|
2766
|
+
op: "update",
|
|
2767
|
+
user,
|
|
2768
|
+
data: merged,
|
|
2769
|
+
record: existing
|
|
2770
|
+
};
|
|
2771
|
+
const exit = await Effect8.runPromiseExit(runHooks("beforeChange", ctx, beforeHooks));
|
|
2772
|
+
if (Exit8.isFailure(exit)) {
|
|
2773
|
+
return problemDetails(c, {
|
|
2774
|
+
code: "hook-rejected",
|
|
2775
|
+
title: "Operation rejected by a lifecycle hook",
|
|
2776
|
+
status: 422,
|
|
2777
|
+
detail: hookRejectionDetail5(exit.cause)
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
if (isHookObject4(exit.value)) {
|
|
2781
|
+
storagePatch = exit.value;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
const updatePredicate = collection.access?.update;
|
|
2785
|
+
if (updatePredicate != null) {
|
|
2786
|
+
const ctx = {
|
|
2787
|
+
user,
|
|
2788
|
+
record: existing,
|
|
2789
|
+
input: patch
|
|
2790
|
+
};
|
|
2791
|
+
const result = await updatePredicate(ctx);
|
|
2792
|
+
if (!allows6(result, existing)) {
|
|
2793
|
+
recordAuditEvent(c, {
|
|
2794
|
+
op: "update",
|
|
2795
|
+
collection: slug,
|
|
2796
|
+
user,
|
|
2797
|
+
decision: "denied",
|
|
2798
|
+
reason: "access.update predicate denied",
|
|
2799
|
+
recordId,
|
|
2800
|
+
at: new Date().toISOString()
|
|
2801
|
+
});
|
|
2802
|
+
return problemDetails(c, {
|
|
2803
|
+
code: "forbidden",
|
|
2804
|
+
title: "Forbidden",
|
|
2805
|
+
status: 403,
|
|
2806
|
+
detail: `${slug}/${recordId}`
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
recordAuditEvent(c, {
|
|
2810
|
+
op: "update",
|
|
2811
|
+
collection: slug,
|
|
2812
|
+
user,
|
|
2813
|
+
decision: "granted",
|
|
2814
|
+
recordId,
|
|
2815
|
+
at: new Date().toISOString()
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
try {
|
|
2819
|
+
await rows.update(table, recordId, encodeJsonColumns(schemaForJson, encodeBooleanColumns(schemaForJson, storagePatch)));
|
|
2820
|
+
} catch (err) {
|
|
2821
|
+
if (err instanceof UniqueConstraintError) {
|
|
2822
|
+
return problemDetails(c, {
|
|
2823
|
+
code: "unique-constraint-violated",
|
|
2824
|
+
title: "Unique constraint violated",
|
|
2825
|
+
status: 409,
|
|
2826
|
+
detail: "A record with the same value(s) for the unique fields already exists."
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
throw err;
|
|
2830
|
+
}
|
|
2831
|
+
const rawUpdated = await rows.getById(table, recordId);
|
|
2832
|
+
const updated = rawUpdated == null ? null : decodeBooleanColumns(schemaForJson, decodeJsonColumns(schemaForJson, rawUpdated));
|
|
2833
|
+
if (updated == null) {
|
|
2834
|
+
return problemDetails(c, {
|
|
2835
|
+
code: "record-not-found",
|
|
2836
|
+
title: "Record vanished after update",
|
|
2837
|
+
status: 404,
|
|
2838
|
+
detail: `${slug}/${recordId}`
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
const afterHooks = resolveHooksFor("afterChange", {
|
|
2842
|
+
collection,
|
|
2843
|
+
plugins: config.plugins
|
|
2844
|
+
});
|
|
2845
|
+
if (afterHooks.length > 0) {
|
|
2846
|
+
const ctx = {
|
|
2847
|
+
op: "update",
|
|
2848
|
+
user,
|
|
2849
|
+
record: updated
|
|
2850
|
+
};
|
|
2851
|
+
const exit = await Effect8.runPromiseExit(runHooks("afterChange", ctx, afterHooks));
|
|
2852
|
+
if (Exit8.isFailure(exit)) {
|
|
2853
|
+
console.warn("[saacms/hooks] afterChange (update) failOnError hook failed");
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
const cache = resolveCache(c);
|
|
2857
|
+
for (const tag of [
|
|
2858
|
+
`saacms:record:${slug}:${recordId}`,
|
|
2859
|
+
`saacms:collection:${slug}`
|
|
2860
|
+
]) {
|
|
2861
|
+
try {
|
|
2862
|
+
await cache.purgeByTag(tag);
|
|
2863
|
+
} catch (err) {
|
|
2864
|
+
console.warn(`[saacms/cache] purge failed for tag ${tag}:`, err);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
const envelope = {
|
|
2868
|
+
data: updated,
|
|
2869
|
+
_links: {
|
|
2870
|
+
self: { href: `/api/saacms/v1/${slug}/${recordId}` },
|
|
2871
|
+
collection: { href: `/api/saacms/v1/${slug}` }
|
|
2872
|
+
},
|
|
2873
|
+
_meta: { mergeStyle: MERGE_STYLE }
|
|
2874
|
+
};
|
|
2875
|
+
const headers = {
|
|
2876
|
+
"Content-Type": "application/json",
|
|
2877
|
+
"Cache-Control": CACHE_CONTROL3
|
|
2878
|
+
};
|
|
2879
|
+
const newEtag = computeWeakEtag7(slug, recordId, updated);
|
|
2880
|
+
if (newEtag != null)
|
|
2881
|
+
headers["ETag"] = newEtag;
|
|
2882
|
+
return c.newResponse(JSON.stringify(envelope), 200, headers);
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
function readUser10(c) {
|
|
2886
|
+
const raw = c.get("user");
|
|
2887
|
+
return raw != null && typeof raw === "object" ? raw : null;
|
|
2888
|
+
}
|
|
2889
|
+
function allows6(result, record) {
|
|
2890
|
+
if (typeof result === "boolean")
|
|
2891
|
+
return result;
|
|
2892
|
+
for (const [key, value] of Object.entries(result.where)) {
|
|
2893
|
+
if (record[key] !== value)
|
|
2894
|
+
return false;
|
|
2895
|
+
}
|
|
2896
|
+
return true;
|
|
2897
|
+
}
|
|
2898
|
+
function isPlainObject2(v) {
|
|
2899
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
2900
|
+
}
|
|
2901
|
+
function isHookObject4(value) {
|
|
2902
|
+
return typeof value === "object" && value !== null;
|
|
2903
|
+
}
|
|
2904
|
+
function hookRejectionDetail5(cause) {
|
|
2905
|
+
const err = Cause5.squash(cause);
|
|
2906
|
+
if (err instanceof Error)
|
|
2907
|
+
return err.message;
|
|
2908
|
+
if (typeof err === "string")
|
|
2909
|
+
return err;
|
|
2910
|
+
try {
|
|
2911
|
+
return JSON.stringify(err);
|
|
2912
|
+
} catch {
|
|
2913
|
+
return String(err);
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
function computeWeakEtag7(slug, recordId, record) {
|
|
2917
|
+
const updatedAt = record["updatedAt"];
|
|
2918
|
+
if (typeof updatedAt !== "string" || updatedAt.length === 0)
|
|
2919
|
+
return null;
|
|
2920
|
+
return `W/"${slug}:${recordId}:${updatedAt}"`;
|
|
2921
|
+
}
|
|
2922
|
+
// src/runtime/scale-cost.ts
|
|
2923
|
+
var DEFAULT_THRESHOLDS = {
|
|
2924
|
+
serveDynamicReadsPerDay: 5000,
|
|
2925
|
+
serveMinCacheHitRate: 0.5,
|
|
2926
|
+
publishMonthlyMinutesFraction: 0.8
|
|
2927
|
+
};
|
|
2928
|
+
var DAYS_PER_MONTH = 30;
|
|
2929
|
+
function int(n) {
|
|
2930
|
+
return Number.isFinite(n) ? Math.round(n) : 0;
|
|
2931
|
+
}
|
|
2932
|
+
function oneDp(n) {
|
|
2933
|
+
if (!Number.isFinite(n))
|
|
2934
|
+
return 0;
|
|
2935
|
+
return Math.round(n * 10) / 10;
|
|
2936
|
+
}
|
|
2937
|
+
function safeDiv(num, den) {
|
|
2938
|
+
if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0)
|
|
2939
|
+
return 0;
|
|
2940
|
+
const r = num / den;
|
|
2941
|
+
return Number.isFinite(r) ? r : 0;
|
|
2942
|
+
}
|
|
2943
|
+
function serveAxisFor(coll, windowDays, th) {
|
|
2944
|
+
const readsPerDay = safeDiv(coll.dynamicReads, windowDays);
|
|
2945
|
+
const totalCacheTraffic = coll.cacheHits + coll.cacheMisses;
|
|
2946
|
+
const hitRate = safeDiv(coll.cacheHits, totalCacheTraffic);
|
|
2947
|
+
const projectedPerMonth = readsPerDay * DAYS_PER_MONTH;
|
|
2948
|
+
const crossed = readsPerDay >= th.serveDynamicReadsPerDay && hitRate < th.serveMinCacheHitRate;
|
|
2949
|
+
return {
|
|
2950
|
+
axis: "serve",
|
|
2951
|
+
crossed,
|
|
2952
|
+
subject: coll.slug,
|
|
2953
|
+
metricLines: [
|
|
2954
|
+
`reads/day: ${int(readsPerDay)}`,
|
|
2955
|
+
`cache hit-rate: ${oneDp(hitRate * 100)}%`,
|
|
2956
|
+
`projected row-reads/mo: ${int(projectedPerMonth)}`
|
|
2957
|
+
],
|
|
2958
|
+
recommendedAction: `wire @saacms/plugin-cache-kv on '${coll.slug}'`
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
function publishAxis(pub, windowDays, th) {
|
|
2962
|
+
const publishesPerMonth = safeDiv(pub.publishes, windowDays) * DAYS_PER_MONTH;
|
|
2963
|
+
const projectedMinutesPerMonth = publishesPerMonth * pub.avgCiMinutes;
|
|
2964
|
+
const allowance = pub.githubFreeMinutesPerMonth;
|
|
2965
|
+
const pctOfAllowance = safeDiv(projectedMinutesPerMonth, allowance) * 100;
|
|
2966
|
+
const crossed = projectedMinutesPerMonth >= th.publishMonthlyMinutesFraction * allowance;
|
|
2967
|
+
return {
|
|
2968
|
+
axis: "publish",
|
|
2969
|
+
crossed,
|
|
2970
|
+
metricLines: [
|
|
2971
|
+
`publishes/30d: ${int(publishesPerMonth)}`,
|
|
2972
|
+
`avg CI minutes/publish: ${oneDp(pub.avgCiMinutes)}`,
|
|
2973
|
+
`projected Actions-minutes/mo: ${int(projectedMinutesPerMonth)}`,
|
|
2974
|
+
`GitHub free allowance/mo: ${int(allowance)} minutes`,
|
|
2975
|
+
`% of free allowance: ${oneDp(pctOfAllowance)}%`
|
|
2976
|
+
],
|
|
2977
|
+
recommendedAction: "reduce publish frequency or fund incremental publish (v1.x); " + "the Draft-batches-Publish invariant already keeps this O(publishes)"
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
function ownerSummaryFor(serveCrossed, publishCrossed) {
|
|
2981
|
+
if (serveCrossed && publishCrossed) {
|
|
2982
|
+
return "Your site is growing fast and publishing very often — ask your developer about a performance and publishing upgrade.";
|
|
2983
|
+
}
|
|
2984
|
+
if (serveCrossed) {
|
|
2985
|
+
return "Your site is growing fast — ask your developer about a performance upgrade.";
|
|
2986
|
+
}
|
|
2987
|
+
if (publishCrossed) {
|
|
2988
|
+
return "Your site is publishing very often — ask your developer about publish costs.";
|
|
2989
|
+
}
|
|
2990
|
+
return "";
|
|
2991
|
+
}
|
|
2992
|
+
function computeScaleCostSignal(snapshot, thresholds) {
|
|
2993
|
+
const th = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
2994
|
+
const windowDays = snapshot.windowDays;
|
|
2995
|
+
const serveAxes = snapshot.collections.map((c) => serveAxisFor(c, windowDays, th)).filter((a) => a.crossed);
|
|
2996
|
+
const pub = publishAxis(snapshot.publish, windowDays, th);
|
|
2997
|
+
const serveCrossed = serveAxes.length > 0;
|
|
2998
|
+
const publishCrossed = pub.crossed;
|
|
2999
|
+
const anyCrossed = serveCrossed || publishCrossed;
|
|
3000
|
+
const axes = [...serveAxes];
|
|
3001
|
+
if (publishCrossed)
|
|
3002
|
+
axes.push(pub);
|
|
3003
|
+
return {
|
|
3004
|
+
window: { days: windowDays },
|
|
3005
|
+
axes,
|
|
3006
|
+
anyCrossed,
|
|
3007
|
+
ownerSummary: ownerSummaryFor(serveCrossed, publishCrossed)
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
// src/runtime/index.ts
|
|
3012
|
+
var NOT_IMPLEMENTED = { error: "NOT_IMPLEMENTED" };
|
|
3013
|
+
function createSaacmsRuntime(config) {
|
|
3014
|
+
const app = new Hono;
|
|
3015
|
+
mountAuthMiddleware(app, config);
|
|
3016
|
+
const services2 = collectServices(config);
|
|
3017
|
+
app.use("*", async (c, next) => {
|
|
3018
|
+
c.set("saacmsServices", services2);
|
|
3019
|
+
await next();
|
|
3020
|
+
});
|
|
3021
|
+
app.all("/admin/*", (c) => c.json(NOT_IMPLEMENTED, 501));
|
|
3022
|
+
mountHealthRoute(app, config);
|
|
3023
|
+
mountOpenApiRoute(app, config);
|
|
3024
|
+
mountListRoute(app, config);
|
|
3025
|
+
mountUploadRoute(app, config);
|
|
3026
|
+
mountCreateRoute(app, config);
|
|
3027
|
+
mountDraftsRoute(app, config);
|
|
3028
|
+
mountReadRoute(app, config);
|
|
3029
|
+
mountPutRoute(app, config);
|
|
3030
|
+
mountUpdateRoute(app, config);
|
|
3031
|
+
mountDeleteRoute(app, config);
|
|
3032
|
+
mountPatternRoute(app, config);
|
|
3033
|
+
mountSchemeRoute(app, config);
|
|
3034
|
+
return app;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
export { RowStorageError, UniqueConstraintError, collectServices, getServices, recordAuditEvent, DEFAULT_THRESHOLDS, computeScaleCostSignal, createSaacmsRuntime };
|