@lunora/server 0.0.0 → 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +105 -0
- package/README.md +134 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/data-model.d.mts +416 -0
- package/dist/data-model.d.ts +416 -0
- package/dist/data-model.mjs +1 -0
- package/dist/drizzle.d.mts +1 -0
- package/dist/drizzle.d.ts +1 -0
- package/dist/drizzle.mjs +1 -0
- package/dist/index.d.mts +1985 -0
- package/dist/index.d.ts +1985 -0
- package/dist/index.mjs +28 -0
- package/dist/packem_shared/LunoraEnvError-DjFkpkSP.mjs +187 -0
- package/dist/packem_shared/LunoraError-DN7Zhhvu.mjs +54 -0
- package/dist/packem_shared/PRESENCE_DEFAULT_TTL_MS-D8viLY1S.mjs +114 -0
- package/dist/packem_shared/asBucketStorage-Cnxd9y2q.mjs +11 -0
- package/dist/packem_shared/bindOrm-Ce57S3N9.mjs +128 -0
- package/dist/packem_shared/buildRlsReadRegistry-1jexWrb3.mjs +107 -0
- package/dist/packem_shared/composePluginMiddleware-Ck5_TUO8.mjs +100 -0
- package/dist/packem_shared/createPolicyDsl-De67zPDS.mjs +29 -0
- package/dist/packem_shared/createSecrets-TsIP9lOa.mjs +55 -0
- package/dist/packem_shared/defineAggregateIndex-ZdyU78gh.mjs +291 -0
- package/dist/packem_shared/defineMigration-CAJLr6fx.mjs +8 -0
- package/dist/packem_shared/defineMutator-EIXAWhs9.mjs +11 -0
- package/dist/packem_shared/defineShape-CJ27Wx7o.mjs +17 -0
- package/dist/packem_shared/defineStorageRule-qu0mpilX.mjs +20 -0
- package/dist/packem_shared/functions-Di9FUNkf.mjs +5 -0
- package/dist/packem_shared/httpAction-FLwfsePg.mjs +340 -0
- package/dist/packem_shared/initLunora-lxwHTEV3.mjs +100 -0
- package/dist/packem_shared/mask-BV_jNzsN.mjs +211 -0
- package/dist/packem_shared/onConnect-CIPXKPyw.mjs +13 -0
- package/dist/packem_shared/policy-tag-DvpVH2tv.mjs +13 -0
- package/dist/packem_shared/protectPublic-BjFkQ_Or.mjs +15 -0
- package/dist/packem_shared/rls-2Jhd0uev.mjs +569 -0
- package/dist/packem_shared/run-middleware-CYQOuoV6.mjs +18 -0
- package/dist/packem_shared/storageRules-Cje6Woea.mjs +88 -0
- package/dist/packem_shared/types.d-BDY0FYHK.d.ts +135 -0
- package/dist/packem_shared/types.d-DmvyEMD6.d.mts +135 -0
- package/dist/rls/testing.d.mts +63 -0
- package/dist/rls/testing.d.ts +63 -0
- package/dist/rls/testing.mjs +49 -0
- package/dist/types.d.mts +1157 -0
- package/dist/types.d.ts +1157 -0
- package/dist/types.mjs +31 -0
- package/package.json +59 -17
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { parseValidatorMap, ValidationError } from '@lunora/values';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { LunoraError } from './LunoraError-DN7Zhhvu.mjs';
|
|
4
|
+
|
|
5
|
+
const httpAction = (handler) => async (c) => handler(c.get("lunora"), c.req.raw);
|
|
6
|
+
const httpRouter = () => {
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
app.use("*", async (c, next) => {
|
|
9
|
+
const injected = c.env.__lunoraCtx;
|
|
10
|
+
if (!injected) {
|
|
11
|
+
throw new LunoraError(
|
|
12
|
+
"INTERNAL_SERVER_ERROR",
|
|
13
|
+
"HttpActionCtx was not injected — mount httpRouter() on createWorker(), which supplies it per request."
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
c.set("lunora", injected);
|
|
17
|
+
await next();
|
|
18
|
+
});
|
|
19
|
+
return app;
|
|
20
|
+
};
|
|
21
|
+
const unwrapOptional = (validator) => validator.kind === "optional" ? validator._meta?.inner ?? validator : validator;
|
|
22
|
+
const coerceScalar = (kind, raw) => {
|
|
23
|
+
switch (kind) {
|
|
24
|
+
case "bigint": {
|
|
25
|
+
try {
|
|
26
|
+
return BigInt(raw);
|
|
27
|
+
} catch {
|
|
28
|
+
return raw;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
case "boolean": {
|
|
32
|
+
if (raw === "true" || raw === "1") {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (raw === "false" || raw === "0") {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
case "number": {
|
|
41
|
+
return raw === "" ? Number.NaN : Number(raw);
|
|
42
|
+
}
|
|
43
|
+
default: {
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const coerceSearchParameter = (validator, c, key) => {
|
|
49
|
+
const effective = unwrapOptional(validator);
|
|
50
|
+
if (effective.kind === "array") {
|
|
51
|
+
const values = c.req.queries(key);
|
|
52
|
+
if (values === void 0) {
|
|
53
|
+
return void 0;
|
|
54
|
+
}
|
|
55
|
+
const element = effective._meta?.inner;
|
|
56
|
+
return values.map((raw2) => coerceScalar(element?.kind ?? "string", raw2));
|
|
57
|
+
}
|
|
58
|
+
const raw = c.req.query(key);
|
|
59
|
+
return raw === void 0 ? void 0 : coerceScalar(effective.kind, raw);
|
|
60
|
+
};
|
|
61
|
+
const parseSearchParams = (validators, c) => {
|
|
62
|
+
const raw = {};
|
|
63
|
+
for (const key of Object.keys(validators)) {
|
|
64
|
+
const validator = validators[key];
|
|
65
|
+
if (!validator) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
raw[key] = coerceSearchParameter(validator, c, key);
|
|
69
|
+
}
|
|
70
|
+
return parseValidatorMap(validators, raw, "searchParams");
|
|
71
|
+
};
|
|
72
|
+
const parseParams = (validators, c) => {
|
|
73
|
+
const provided = c.req.param();
|
|
74
|
+
const raw = {};
|
|
75
|
+
for (const key of Object.keys(validators)) {
|
|
76
|
+
const validator = validators[key];
|
|
77
|
+
if (!validator) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const value = provided[key];
|
|
81
|
+
raw[key] = value === void 0 ? void 0 : coerceScalar(unwrapOptional(validator).kind, value);
|
|
82
|
+
}
|
|
83
|
+
return parseValidatorMap(validators, raw, "params");
|
|
84
|
+
};
|
|
85
|
+
const parseBody = async (validators, c) => {
|
|
86
|
+
let json;
|
|
87
|
+
try {
|
|
88
|
+
json = await c.req.json();
|
|
89
|
+
} catch {
|
|
90
|
+
throw new LunoraError("BAD_REQUEST", "Invalid JSON body");
|
|
91
|
+
}
|
|
92
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
93
|
+
throw new LunoraError("BAD_REQUEST", "Expected a JSON object body");
|
|
94
|
+
}
|
|
95
|
+
return parseValidatorMap(validators, json, "body");
|
|
96
|
+
};
|
|
97
|
+
const applyOutput = (output, result) => {
|
|
98
|
+
try {
|
|
99
|
+
return output.parse(result);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof ValidationError) {
|
|
102
|
+
throw new LunoraError("INTERNAL_SERVER_ERROR", `Response did not match the declared output schema: ${error.message}`);
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const errorResponse = (error) => {
|
|
108
|
+
if (error instanceof ValidationError) {
|
|
109
|
+
return Response.json({ code: "BAD_REQUEST", error: error.message }, { status: 400 });
|
|
110
|
+
}
|
|
111
|
+
if (error instanceof LunoraError) {
|
|
112
|
+
return Response.json({ code: error.code, error: error.message }, { status: error.status });
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
};
|
|
116
|
+
const buildRouteHandler = (state, userHandler) => async (c) => {
|
|
117
|
+
try {
|
|
118
|
+
const context = c.get("lunora");
|
|
119
|
+
const searchParams = Object.keys(state.searchParams).length > 0 ? parseSearchParams(state.searchParams, c) : {};
|
|
120
|
+
const params = Object.keys(state.params).length > 0 ? parseParams(state.params, c) : {};
|
|
121
|
+
const body = Object.keys(state.body).length > 0 ? await parseBody(state.body, c) : {};
|
|
122
|
+
const result = await userHandler({ body, ctx: context, params, searchParams });
|
|
123
|
+
const payload = state.output ? applyOutput(state.output, result) : result;
|
|
124
|
+
return payload === void 0 ? new Response(null, { status: 204 }) : Response.json(payload);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return errorResponse(error);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const isLunoraErrorLike = (error) => {
|
|
130
|
+
if (!error || typeof error !== "object") {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const candidate = error;
|
|
134
|
+
return candidate.name === "LunoraError" && typeof candidate.code === "string" && typeof candidate.message === "string";
|
|
135
|
+
};
|
|
136
|
+
const sseFrame = (chunk, event) => {
|
|
137
|
+
const data = JSON.stringify(chunk);
|
|
138
|
+
const prefix = event ? `event: ${event}
|
|
139
|
+
` : "";
|
|
140
|
+
return `${prefix}data: ${data}
|
|
141
|
+
|
|
142
|
+
`;
|
|
143
|
+
};
|
|
144
|
+
const buildStreamHandler = (state, userHandler) => (
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- LunoraRouteHandler is contractually `(c) => Promise<Response>`; this handler returns synchronously (all awaits live inside the ReadableStream pump), so `async` is required by the type, not the body.
|
|
146
|
+
async (c) => {
|
|
147
|
+
let searchParams;
|
|
148
|
+
let params;
|
|
149
|
+
try {
|
|
150
|
+
searchParams = Object.keys(state.searchParams).length > 0 ? parseSearchParams(state.searchParams, c) : {};
|
|
151
|
+
params = Object.keys(state.params).length > 0 ? parseParams(state.params, c) : {};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return errorResponse(error);
|
|
154
|
+
}
|
|
155
|
+
const context = c.get("lunora");
|
|
156
|
+
const request = c.req.raw;
|
|
157
|
+
const encoder = new TextEncoder();
|
|
158
|
+
const ac = new AbortController();
|
|
159
|
+
if (request.signal.aborted) {
|
|
160
|
+
ac.abort();
|
|
161
|
+
return new Response(
|
|
162
|
+
new ReadableStream({
|
|
163
|
+
start: (controller) => {
|
|
164
|
+
controller.close();
|
|
165
|
+
}
|
|
166
|
+
}),
|
|
167
|
+
{
|
|
168
|
+
headers: {
|
|
169
|
+
"cache-control": "no-cache, no-transform",
|
|
170
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
171
|
+
"x-accel-buffering": "no"
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const onAbort = () => {
|
|
177
|
+
ac.abort();
|
|
178
|
+
};
|
|
179
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
180
|
+
const stream = new ReadableStream({
|
|
181
|
+
cancel() {
|
|
182
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
183
|
+
ac.abort();
|
|
184
|
+
},
|
|
185
|
+
async start(controller) {
|
|
186
|
+
try {
|
|
187
|
+
const iterator = userHandler({ ctx: context, params, request, searchParams, signal: ac.signal });
|
|
188
|
+
for await (const chunk of iterator) {
|
|
189
|
+
if (ac.signal.aborted) {
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
controller.enqueue(encoder.encode(sseFrame(chunk)));
|
|
193
|
+
}
|
|
194
|
+
controller.enqueue(encoder.encode(sseFrame({}, "complete")));
|
|
195
|
+
} catch (error) {
|
|
196
|
+
let payload;
|
|
197
|
+
if (isLunoraErrorLike(error)) {
|
|
198
|
+
payload = { code: error.code, message: error.message };
|
|
199
|
+
} else {
|
|
200
|
+
console.error("[lunora] unhandled stream handler error:", error);
|
|
201
|
+
payload = { code: "INTERNAL_SERVER_ERROR", message: "Internal error" };
|
|
202
|
+
}
|
|
203
|
+
controller.enqueue(encoder.encode(sseFrame(payload, "error")));
|
|
204
|
+
} finally {
|
|
205
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
206
|
+
controller.close();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return new Response(stream, {
|
|
211
|
+
headers: {
|
|
212
|
+
"cache-control": "no-cache, no-transform",
|
|
213
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
214
|
+
// Hint to proxies (including Cloudflare's own buffering layer)
|
|
215
|
+
// that this response must not be coalesced.
|
|
216
|
+
"x-accel-buffering": "no"
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
const makeRouteBuilder = (state) => {
|
|
222
|
+
return {
|
|
223
|
+
body: (validators) => makeRouteBuilder({ ...state, body: { ...state.body, ...validators } }),
|
|
224
|
+
handler: (userHandler) => buildRouteHandler(state, userHandler),
|
|
225
|
+
output: (validator) => makeRouteBuilder({ ...state, output: validator }),
|
|
226
|
+
params: (validators) => makeRouteBuilder({ ...state, params: { ...state.params, ...validators } }),
|
|
227
|
+
searchParams: (validators) => makeRouteBuilder({ ...state, searchParams: { ...state.searchParams, ...validators } }),
|
|
228
|
+
stream: (userHandler) => buildStreamHandler(state, userHandler)
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
const makeRouteFactory = (method) => (path) => makeRouteBuilder({ body: {}, method, params: {}, path, searchParams: {} });
|
|
232
|
+
const httpRoute = {
|
|
233
|
+
delete: makeRouteFactory("DELETE"),
|
|
234
|
+
get: makeRouteFactory("GET"),
|
|
235
|
+
head: makeRouteFactory("HEAD"),
|
|
236
|
+
options: makeRouteFactory("OPTIONS"),
|
|
237
|
+
patch: makeRouteFactory("PATCH"),
|
|
238
|
+
post: makeRouteFactory("POST"),
|
|
239
|
+
put: makeRouteFactory("PUT")
|
|
240
|
+
};
|
|
241
|
+
const SINGLE_BYTE_RANGE_RE = /^bytes=(\d*)-(\d*)$/;
|
|
242
|
+
const toHttpEtag = (etag) => {
|
|
243
|
+
if (etag.startsWith('"') || etag.startsWith('W/"')) {
|
|
244
|
+
return etag;
|
|
245
|
+
}
|
|
246
|
+
return `"${etag}"`;
|
|
247
|
+
};
|
|
248
|
+
const isSafeHeaderValue = (value) => {
|
|
249
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
250
|
+
const code = value.codePointAt(index);
|
|
251
|
+
if (code === 13 || code === 10 || code === 0) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
};
|
|
257
|
+
const parseRange = (header, size) => {
|
|
258
|
+
if (header === null) {
|
|
259
|
+
return { kind: "full" };
|
|
260
|
+
}
|
|
261
|
+
const match = SINGLE_BYTE_RANGE_RE.exec(header.trim());
|
|
262
|
+
if (!match) {
|
|
263
|
+
return { kind: "full" };
|
|
264
|
+
}
|
|
265
|
+
const startRaw = match[1] ?? "";
|
|
266
|
+
const endRaw = match[2] ?? "";
|
|
267
|
+
if (startRaw === "" && endRaw === "") {
|
|
268
|
+
return { kind: "full" };
|
|
269
|
+
}
|
|
270
|
+
let start;
|
|
271
|
+
let end;
|
|
272
|
+
if (startRaw === "") {
|
|
273
|
+
const suffix = Number(endRaw);
|
|
274
|
+
if (suffix === 0) {
|
|
275
|
+
return { kind: "unsatisfiable" };
|
|
276
|
+
}
|
|
277
|
+
start = Math.max(0, size - suffix);
|
|
278
|
+
end = size - 1;
|
|
279
|
+
} else {
|
|
280
|
+
start = Number(startRaw);
|
|
281
|
+
end = endRaw === "" ? size - 1 : Math.min(Number(endRaw), size - 1);
|
|
282
|
+
}
|
|
283
|
+
if (start > end || start >= size) {
|
|
284
|
+
return { kind: "unsatisfiable" };
|
|
285
|
+
}
|
|
286
|
+
return { end, kind: "partial", start };
|
|
287
|
+
};
|
|
288
|
+
const serveStorageObject = async (context, key, request) => {
|
|
289
|
+
const object = await context.storage.download(key);
|
|
290
|
+
if (!object) {
|
|
291
|
+
return new Response("Not Found", { status: 404 });
|
|
292
|
+
}
|
|
293
|
+
const rawContentType = object.httpMetadata?.contentType;
|
|
294
|
+
const contentType = rawContentType !== void 0 && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream";
|
|
295
|
+
const baseHeaders = {
|
|
296
|
+
"accept-ranges": "bytes",
|
|
297
|
+
"content-type": contentType,
|
|
298
|
+
etag: toHttpEtag(object.etag)
|
|
299
|
+
};
|
|
300
|
+
if (object.sha256Base64 !== void 0) {
|
|
301
|
+
baseHeaders["repr-digest"] = `sha-256=:${object.sha256Base64}:`;
|
|
302
|
+
}
|
|
303
|
+
const range = parseRange(request.headers.get("range"), object.size);
|
|
304
|
+
if (range.kind === "unsatisfiable") {
|
|
305
|
+
object.body?.cancel().catch(() => {
|
|
306
|
+
});
|
|
307
|
+
return new Response("Range Not Satisfiable", {
|
|
308
|
+
headers: {
|
|
309
|
+
"accept-ranges": "bytes",
|
|
310
|
+
"content-range": `bytes */${String(object.size)}`,
|
|
311
|
+
"content-type": "text/plain; charset=utf-8",
|
|
312
|
+
etag: toHttpEtag(object.etag)
|
|
313
|
+
},
|
|
314
|
+
status: 416
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (range.kind === "full") {
|
|
318
|
+
return new Response(object.body, {
|
|
319
|
+
headers: { ...baseHeaders, "content-length": String(object.size) },
|
|
320
|
+
status: 200
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
object.body?.cancel().catch(() => {
|
|
324
|
+
});
|
|
325
|
+
const length = range.end - range.start + 1;
|
|
326
|
+
const slice = await context.storage.download(key, { range: { length, offset: range.start } });
|
|
327
|
+
if (!slice) {
|
|
328
|
+
return new Response("Not Found", { status: 404 });
|
|
329
|
+
}
|
|
330
|
+
return new Response(slice.body, {
|
|
331
|
+
headers: {
|
|
332
|
+
...baseHeaders,
|
|
333
|
+
"content-length": String(length),
|
|
334
|
+
"content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(object.size)}`
|
|
335
|
+
},
|
|
336
|
+
status: 206
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export { httpAction, httpRoute, httpRouter, serveStorageObject };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { v as validateArgs } from './functions-Di9FUNkf.mjs';
|
|
2
|
+
import { r as readRlsTag } from './policy-tag-DvpVH2tv.mjs';
|
|
3
|
+
import { r as runMiddlewareChain } from './run-middleware-CYQOuoV6.mjs';
|
|
4
|
+
|
|
5
|
+
const runMiddleware = (middlewares, baseContext) => runMiddlewareChain(middlewares, baseContext, (context) => context);
|
|
6
|
+
const makeHandler = (args, middlewares, userHandler, output) => async (context, rawArgs) => {
|
|
7
|
+
const parsed = validateArgs(args, rawArgs);
|
|
8
|
+
const resolvedContext = await runMiddleware(middlewares, context);
|
|
9
|
+
const result = await userHandler({ args: parsed, ctx: resolvedContext });
|
|
10
|
+
return output ? output.parse(result) : result;
|
|
11
|
+
};
|
|
12
|
+
const makeStreamHandler = (args, middlewares, userHandler) => (context, rawArgs, signal) => {
|
|
13
|
+
const parsed = validateArgs(args, rawArgs);
|
|
14
|
+
return (async function* drive() {
|
|
15
|
+
const resolvedContext = await runMiddleware(middlewares, context);
|
|
16
|
+
const source = userHandler({ args: parsed, ctx: resolvedContext, signal });
|
|
17
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
18
|
+
try {
|
|
19
|
+
while (true) {
|
|
20
|
+
if (signal.aborted) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const next = await iterator.next();
|
|
24
|
+
if (next.done) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (signal.aborted) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
yield next.value;
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
await iterator.return?.();
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
};
|
|
37
|
+
const collectRls = (middlewares) => {
|
|
38
|
+
const tags = [];
|
|
39
|
+
for (const middleware of middlewares) {
|
|
40
|
+
const tag = readRlsTag(middleware);
|
|
41
|
+
if (!tag) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
tags.push(tag);
|
|
45
|
+
}
|
|
46
|
+
return tags.length > 0 ? { tags } : void 0;
|
|
47
|
+
};
|
|
48
|
+
const makeBuilder = (kind, state, visibility) => {
|
|
49
|
+
return {
|
|
50
|
+
__lunoraProcedure: kind,
|
|
51
|
+
...visibility ? { __lunoraVisibility: visibility } : {},
|
|
52
|
+
input: (validators) => makeBuilder(kind, { ...state, args: { ...state.args, ...validators } }, visibility),
|
|
53
|
+
[kind]: (userHandler) => {
|
|
54
|
+
const rls = collectRls(state.middlewares);
|
|
55
|
+
return {
|
|
56
|
+
args: state.args,
|
|
57
|
+
handler: makeHandler(state.args, state.middlewares, userHandler, state.output),
|
|
58
|
+
kind,
|
|
59
|
+
...rls ? { rls } : {},
|
|
60
|
+
...visibility ? { visibility } : {}
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
output: (validator) => makeBuilder(kind, { ...state, output: validator }, visibility),
|
|
64
|
+
// `.stream()` is meaningful only on query builders. It's harmless to expose
|
|
65
|
+
// on every builder shape (callers can't hit it from action/mutation builders
|
|
66
|
+
// anyway since the type system narrows it away), but emitting it
|
|
67
|
+
// unconditionally keeps the runtime free of per-kind branching.
|
|
68
|
+
...kind === "query" ? {
|
|
69
|
+
stream: (userHandler) => {
|
|
70
|
+
const rls = collectRls(state.middlewares);
|
|
71
|
+
return {
|
|
72
|
+
args: state.args,
|
|
73
|
+
handler: makeStreamHandler(state.args, state.middlewares, userHandler),
|
|
74
|
+
kind: "stream",
|
|
75
|
+
...rls ? { rls } : {},
|
|
76
|
+
...visibility ? { visibility } : {}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
} : {},
|
|
80
|
+
use: (middleware) => makeBuilder(kind, { ...state, middlewares: [...state.middlewares, middleware] }, visibility)
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const initLunora = {
|
|
84
|
+
dataModel: () => {
|
|
85
|
+
return {
|
|
86
|
+
create: (_options) => {
|
|
87
|
+
return {
|
|
88
|
+
action: makeBuilder("action", { args: {}, middlewares: [] }),
|
|
89
|
+
internalAction: makeBuilder("action", { args: {}, middlewares: [] }, "internal"),
|
|
90
|
+
internalMutation: makeBuilder("mutation", { args: {}, middlewares: [] }, "internal"),
|
|
91
|
+
internalQuery: makeBuilder("query", { args: {}, middlewares: [] }, "internal"),
|
|
92
|
+
mutation: makeBuilder("mutation", { args: {}, middlewares: [] }),
|
|
93
|
+
query: makeBuilder("query", { args: {}, middlewares: [] })
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export { initLunora };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { LunoraError } from './LunoraError-DN7Zhhvu.mjs';
|
|
2
|
+
import { bindTableFacade, bindOrm } from './bindOrm-Ce57S3N9.mjs';
|
|
3
|
+
|
|
4
|
+
const permissionName = (permission) => typeof permission === "string" ? permission : permission.name;
|
|
5
|
+
const indexRolePermissions = (roles) => {
|
|
6
|
+
const map = /* @__PURE__ */ new Map();
|
|
7
|
+
for (const role of roles ?? []) {
|
|
8
|
+
map.set(role.name, new Set((role.permissions ?? []).map((permission) => permissionName(permission))));
|
|
9
|
+
}
|
|
10
|
+
return map;
|
|
11
|
+
};
|
|
12
|
+
const fnv1aHex = (input) => {
|
|
13
|
+
let hash = 2166136261;
|
|
14
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
15
|
+
hash ^= input.codePointAt(index) ?? 0;
|
|
16
|
+
hash = Math.imul(hash, 16777619);
|
|
17
|
+
}
|
|
18
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
19
|
+
};
|
|
20
|
+
const applyStrategy = (strategy, value, context) => {
|
|
21
|
+
try {
|
|
22
|
+
if (strategy === "redact") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (strategy === "hash") {
|
|
26
|
+
if (value === null || value === void 0) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return fnv1aHex(typeof value === "string" ? value : JSON.stringify(value));
|
|
30
|
+
}
|
|
31
|
+
return strategy(value, context);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const maskRow = (row, columns, base) => {
|
|
37
|
+
const out = { ...row };
|
|
38
|
+
for (const [column, strategy] of Object.entries(columns)) {
|
|
39
|
+
if (!(column in out)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
out[column] = applyStrategy(strategy, row[column], { ...base, column, row });
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
};
|
|
46
|
+
const maskPage = (page, columns, base) => {
|
|
47
|
+
return { ...page, page: page.page.map((row) => maskRow(row, columns, base)) };
|
|
48
|
+
};
|
|
49
|
+
const isFacadeEntry = (value) => {
|
|
50
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const candidate = value;
|
|
54
|
+
return typeof candidate["findMany"] === "function" && typeof candidate["withSearchIndex"] === "function";
|
|
55
|
+
};
|
|
56
|
+
const wrapDatabase = (base, perTable, context) => {
|
|
57
|
+
const wrapReader = (reader, columns) => {
|
|
58
|
+
return {
|
|
59
|
+
collect: async () => {
|
|
60
|
+
const rows = await reader.collect();
|
|
61
|
+
return rows.map((row) => maskRow(row, columns, context));
|
|
62
|
+
},
|
|
63
|
+
filter: (predicate) => wrapReader(
|
|
64
|
+
reader.filter((document) => predicate(document)),
|
|
65
|
+
columns
|
|
66
|
+
),
|
|
67
|
+
first: async () => {
|
|
68
|
+
const row = await reader.first();
|
|
69
|
+
return row ? maskRow(row, columns, context) : null;
|
|
70
|
+
},
|
|
71
|
+
order: (direction) => wrapReader(reader.order(direction), columns),
|
|
72
|
+
paginate: async (options) => maskPage(await reader.paginate(options), columns, context),
|
|
73
|
+
take: async (limit) => {
|
|
74
|
+
const rows = await reader.take(limit);
|
|
75
|
+
return rows.map((row) => maskRow(row, columns, context));
|
|
76
|
+
},
|
|
77
|
+
unique: async () => {
|
|
78
|
+
const row = await reader.unique();
|
|
79
|
+
return row ? maskRow(row, columns, context) : null;
|
|
80
|
+
},
|
|
81
|
+
withIndex: (indexName, range) => wrapReader(reader.withIndex(indexName, range), columns),
|
|
82
|
+
withSearchIndex: (indexName, search) => wrapReader(reader.withSearchIndex(indexName, search), columns)
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const locate = async (id, expectedTable) => {
|
|
86
|
+
if (base.lookupById) {
|
|
87
|
+
const located = await base.lookupById(id, expectedTable);
|
|
88
|
+
if (!located) {
|
|
89
|
+
return { row: null, tableName: void 0 };
|
|
90
|
+
}
|
|
91
|
+
return { row: located.row, tableName: perTable.has(located.tableName) ? located.tableName : void 0 };
|
|
92
|
+
}
|
|
93
|
+
const row = await base.get(id, expectedTable);
|
|
94
|
+
if (!row) {
|
|
95
|
+
return { row: null, tableName: void 0 };
|
|
96
|
+
}
|
|
97
|
+
let probeTables;
|
|
98
|
+
if (expectedTable === void 0) {
|
|
99
|
+
probeTables = [...perTable.keys()];
|
|
100
|
+
} else if (perTable.has(expectedTable)) {
|
|
101
|
+
probeTables = [expectedTable];
|
|
102
|
+
} else {
|
|
103
|
+
probeTables = [];
|
|
104
|
+
}
|
|
105
|
+
const probes = await Promise.all(
|
|
106
|
+
probeTables.map(async (tableName) => {
|
|
107
|
+
const probe = await base.findFirst(tableName, { limit: 1, where: { _id: id } });
|
|
108
|
+
return probe?.["_id"] === id ? tableName : void 0;
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
return { row, tableName: probes.find((entry) => entry !== void 0) };
|
|
112
|
+
};
|
|
113
|
+
const assertReductionAllowed = (tableName, fields, method) => {
|
|
114
|
+
const columns = perTable.get(tableName);
|
|
115
|
+
if (!columns) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const offending = fields.find((field) => typeof field === "string" && field in columns);
|
|
119
|
+
if (offending !== void 0) {
|
|
120
|
+
throw new LunoraError("MASK_UNSUPPORTED", `${method}() over masked column "${offending}" on "${tableName}" is not supported`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const wrapped = {
|
|
124
|
+
...base,
|
|
125
|
+
aggregate(tableName, options) {
|
|
126
|
+
assertReductionAllowed(tableName, [options.field], "aggregate");
|
|
127
|
+
return base.aggregate(tableName, options);
|
|
128
|
+
},
|
|
129
|
+
async findFirst(tableName, args) {
|
|
130
|
+
const row = await base.findFirst(tableName, args);
|
|
131
|
+
const columns = perTable.get(tableName);
|
|
132
|
+
return row && columns ? maskRow(row, columns, context) : row;
|
|
133
|
+
},
|
|
134
|
+
async findFirstOrThrow(tableName, args) {
|
|
135
|
+
const row = await base.findFirstOrThrow(tableName, args);
|
|
136
|
+
const columns = perTable.get(tableName);
|
|
137
|
+
return columns ? maskRow(row, columns, context) : row;
|
|
138
|
+
},
|
|
139
|
+
async findMany(tableName, args) {
|
|
140
|
+
const page = await base.findMany(tableName, args);
|
|
141
|
+
const columns = perTable.get(tableName);
|
|
142
|
+
return columns ? maskPage(page, columns, context) : page;
|
|
143
|
+
},
|
|
144
|
+
async get(id, expectedTable) {
|
|
145
|
+
const { row, tableName } = await locate(id, expectedTable);
|
|
146
|
+
const columns = tableName === void 0 ? void 0 : perTable.get(tableName);
|
|
147
|
+
if (!row || !columns) {
|
|
148
|
+
return row;
|
|
149
|
+
}
|
|
150
|
+
return maskRow(row, columns, context);
|
|
151
|
+
},
|
|
152
|
+
groupBy(tableName, options) {
|
|
153
|
+
assertReductionAllowed(tableName, [...options.by, options.agg?.field], "groupBy");
|
|
154
|
+
return base.groupBy(tableName, options);
|
|
155
|
+
},
|
|
156
|
+
query(tableName) {
|
|
157
|
+
const reader = base.query(tableName);
|
|
158
|
+
const columns = perTable.get(tableName);
|
|
159
|
+
return columns ? wrapReader(reader, columns) : reader;
|
|
160
|
+
},
|
|
161
|
+
async rankPage(tableName, indexName, options) {
|
|
162
|
+
const page = await base.rankPage(tableName, indexName, options);
|
|
163
|
+
const columns = perTable.get(tableName);
|
|
164
|
+
return columns ? maskPage(page, columns, context) : page;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const writableFacade = wrapped;
|
|
168
|
+
for (const tableName of perTable.keys()) {
|
|
169
|
+
if (isFacadeEntry(base[tableName])) {
|
|
170
|
+
writableFacade[tableName] = bindTableFacade(wrapped, tableName);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return wrapped;
|
|
174
|
+
};
|
|
175
|
+
const mask = (policies, options = {}) => {
|
|
176
|
+
const perTable = new Map(Object.entries(policies));
|
|
177
|
+
const rolePermissions = indexRolePermissions(options.roles);
|
|
178
|
+
return async ({ ctx, next }) => {
|
|
179
|
+
const auth = ctx.auth ?? {};
|
|
180
|
+
const identity = await auth.getIdentity?.() ?? null;
|
|
181
|
+
const roles = auth.roles ?? [];
|
|
182
|
+
const granted = /* @__PURE__ */ new Set();
|
|
183
|
+
for (const roleName of roles) {
|
|
184
|
+
for (const name of rolePermissions.get(roleName) ?? []) {
|
|
185
|
+
granted.add(name);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const maskContext = {
|
|
189
|
+
auth: {
|
|
190
|
+
can: (permission) => granted.has(permissionName(permission)),
|
|
191
|
+
identity,
|
|
192
|
+
roles,
|
|
193
|
+
// eslint-disable-next-line unicorn/no-null -- MaskContext.auth.userId is a public `null | string` type
|
|
194
|
+
userId: auth.userId ?? null
|
|
195
|
+
},
|
|
196
|
+
ctx
|
|
197
|
+
};
|
|
198
|
+
if (options.bypass?.(maskContext)) {
|
|
199
|
+
return next();
|
|
200
|
+
}
|
|
201
|
+
const wrapped = wrapDatabase(ctx.db, perTable, maskContext);
|
|
202
|
+
const extension = { db: wrapped };
|
|
203
|
+
const { orm } = ctx;
|
|
204
|
+
if (orm !== null && typeof orm === "object") {
|
|
205
|
+
extension.orm = bindOrm(wrapped);
|
|
206
|
+
}
|
|
207
|
+
return next({ ctx: extension });
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export { mask };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const wrapLifecycle = (lifecycle, handler) => {
|
|
2
|
+
return {
|
|
3
|
+
args: {},
|
|
4
|
+
handler: (context, event) => handler(context, event),
|
|
5
|
+
kind: "mutation",
|
|
6
|
+
lifecycle,
|
|
7
|
+
visibility: "internal"
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
const onConnect = (handler) => wrapLifecycle("connect", handler);
|
|
11
|
+
const onDisconnect = (handler) => wrapLifecycle("disconnect", handler);
|
|
12
|
+
|
|
13
|
+
export { onConnect, onDisconnect };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const RLS_TAG = /* @__PURE__ */ Symbol.for("lunora.rls.middleware-policies");
|
|
2
|
+
const tagRlsMiddleware = (middleware, tag) => {
|
|
3
|
+
Object.defineProperty(middleware, RLS_TAG, { configurable: true, enumerable: false, value: tag });
|
|
4
|
+
return middleware;
|
|
5
|
+
};
|
|
6
|
+
const readRlsTag = (middleware) => {
|
|
7
|
+
if (middleware === null || typeof middleware !== "function" && typeof middleware !== "object") {
|
|
8
|
+
return void 0;
|
|
9
|
+
}
|
|
10
|
+
return middleware[RLS_TAG];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { readRlsTag as r, tagRlsMiddleware as t };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { r as runMiddlewareChain } from './run-middleware-CYQOuoV6.mjs';
|
|
2
|
+
|
|
3
|
+
const protectPublic = (options) => {
|
|
4
|
+
const chain = [options.rateLimit, options.captcha, ...options.use ?? []].filter(
|
|
5
|
+
(middleware) => middleware !== void 0
|
|
6
|
+
);
|
|
7
|
+
const composed = async ({ ctx, next }) => runMiddlewareChain(
|
|
8
|
+
chain,
|
|
9
|
+
ctx,
|
|
10
|
+
(context) => next({ ctx: context })
|
|
11
|
+
);
|
|
12
|
+
return composed;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export { protectPublic };
|