@lunora/server 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +6 -0
- package/README.md +51 -1
- package/__assets__/package-og.svg +1 -1
- package/dist/data-model.d.mts +158 -32
- package/dist/data-model.d.ts +158 -32
- package/dist/index.d.mts +459 -117
- package/dist/index.d.ts +459 -117
- package/dist/index.mjs +20 -15
- package/dist/packem_shared/{LunoraEnvError-DjFkpkSP.mjs → LunoraEnvError-BGmd1Qs0.mjs} +4 -4
- package/dist/packem_shared/LunoraError-WbxmrpxR.mjs +9 -0
- package/dist/packem_shared/{PRESENCE_DEFAULT_TTL_MS-BZYd5-uo.mjs → PRESENCE_DEFAULT_TTL_MS-BtPCiNLW.mjs} +4 -4
- package/dist/packem_shared/bindOrm-Bfgl7f-Q.mjs +146 -0
- package/dist/packem_shared/buildRlsReadRegistry-D54vUQe4.mjs +107 -0
- package/dist/packem_shared/{composePluginMiddleware-Ck5_TUO8.mjs → composePluginMiddleware-CcTj1_v2.mjs} +9 -6
- package/dist/packem_shared/{createPolicyDsl-De67zPDS.mjs → createPolicyDsl-By3QB4he.mjs} +4 -1
- package/dist/packem_shared/createSecrets-DwaR2rNG.mjs +58 -0
- package/dist/packem_shared/{defineAggregateIndex-DxSso0rH.mjs → defineAggregateIndex-znq4ygKQ.mjs} +69 -10
- package/dist/packem_shared/defineIdentity-DiX4zM9x.mjs +35 -0
- package/dist/packem_shared/{defineMigration-CAJLr6fx.mjs → defineMigration-Hx01yIht.mjs} +3 -1
- package/dist/packem_shared/defineMutator-EIXAWhs9.mjs +11 -0
- package/dist/packem_shared/defineShape-C5scNOrf.mjs +18 -0
- package/dist/packem_shared/{defineStorageRule-qu0mpilX.mjs → defineStorageRule-B5nL4Z1P.mjs} +4 -1
- package/dist/packem_shared/functions-Di9FUNkf.mjs +5 -0
- package/dist/packem_shared/{httpAction-B7FYUEgr.mjs → httpAction-DCXoYPIk.mjs} +42 -27
- package/dist/packem_shared/{initLunora-CATvPsVt.mjs → initLunora-sQUqaejx.mjs} +18 -4
- package/dist/packem_shared/{mask-Jc84C_hK.mjs → mask-BT1YgRbF.mjs} +89 -9
- package/dist/packem_shared/policy-tag-DvpVH2tv.mjs +13 -0
- package/dist/packem_shared/{protectPublic-BjFkQ_Or.mjs → protectPublic-BlcGpiRc.mjs} +1 -1
- package/dist/packem_shared/{rls-BDKRbMCA.mjs → rls-B_ZWgslr.mjs} +51 -7
- package/dist/packem_shared/{run-middleware-CYQOuoV6.mjs → run-middleware-I6EiQfxL.mjs} +3 -1
- package/dist/packem_shared/{storageRules-4a30FSpI.mjs → storageRules-6QxzDOcx.mjs} +1 -1
- package/dist/packem_shared/{types.d-DmvyEMD6.d.mts → types.d-BB3pjV0m.d.mts} +10 -4
- package/dist/packem_shared/{types.d-BDY0FYHK.d.ts → types.d-Cxl6ndhm.d.ts} +10 -4
- package/dist/rls/testing.d.mts +1 -1
- package/dist/rls/testing.d.ts +1 -1
- package/dist/rls/testing.mjs +1 -1
- package/dist/types.d.mts +206 -5
- package/dist/types.d.ts +206 -5
- package/package.json +6 -5
- package/dist/packem_shared/LunoraError-DhggBJZF.mjs +0 -51
- package/dist/packem_shared/bindOrm-DCuyr46L.mjs +0 -71
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { LunoraError as LunoraError$1 } from '@lunora/errors';
|
|
2
|
+
|
|
1
3
|
const defineMigration = (definition) => {
|
|
2
4
|
if (definition.id.trim() === "") {
|
|
3
|
-
throw new
|
|
5
|
+
throw new LunoraError$1("INTERNAL", "defineMigration: `id` must be a non-empty string");
|
|
4
6
|
}
|
|
5
7
|
return { __lunoraMigration: true, ...definition };
|
|
6
8
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { v as validateArgs } from './functions-Di9FUNkf.mjs';
|
|
2
|
+
|
|
3
|
+
const defineMutator = (definition) => {
|
|
4
|
+
const handler = async (context, rawArgs) => {
|
|
5
|
+
const parsed = validateArgs(definition.args ?? {}, rawArgs);
|
|
6
|
+
return definition.server(context, parsed);
|
|
7
|
+
};
|
|
8
|
+
return { __lunoraMutator: true, ...definition, handler, kind: "mutation" };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export { defineMutator };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { LunoraError as LunoraError$1 } from '@lunora/errors';
|
|
2
|
+
import { v as validateArgs } from './functions-Di9FUNkf.mjs';
|
|
3
|
+
|
|
4
|
+
const defineShape = (definition) => {
|
|
5
|
+
if (definition.table.trim() === "") {
|
|
6
|
+
throw new LunoraError$1("INTERNAL", "defineShape: `table` must be a non-empty string");
|
|
7
|
+
}
|
|
8
|
+
if (definition.columns?.length === 0) {
|
|
9
|
+
throw new LunoraError$1("INTERNAL", "defineShape: `columns` must list at least one column when provided");
|
|
10
|
+
}
|
|
11
|
+
const compileWhere = (context, rawArgs) => {
|
|
12
|
+
const parsed = validateArgs(definition.args ?? {}, rawArgs);
|
|
13
|
+
return definition.where(context, parsed);
|
|
14
|
+
};
|
|
15
|
+
return { __lunoraShape: true, ...definition, compileWhere };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { defineShape };
|
package/dist/packem_shared/{defineStorageRule-qu0mpilX.mjs → defineStorageRule-B5nL4Z1P.mjs}
RENAMED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LunoraError as LunoraError$1 } from '@lunora/errors';
|
|
2
|
+
|
|
1
3
|
const defineStorageRule = (input) => {
|
|
2
4
|
return { bucket: input.bucket, on: input.on, prefix: input.prefix, when: input.when };
|
|
3
5
|
};
|
|
@@ -7,7 +9,8 @@ const defineStorageRules = (rules) => {
|
|
|
7
9
|
const key = JSON.stringify([rule.bucket, rule.on, rule.prefix]);
|
|
8
10
|
const whens = seenWhenByKey.get(key) ?? /* @__PURE__ */ new Set();
|
|
9
11
|
if (whens.has(rule.when)) {
|
|
10
|
-
throw new
|
|
12
|
+
throw new LunoraError$1(
|
|
13
|
+
"INTERNAL",
|
|
11
14
|
`defineStorageRules: duplicate rule for (bucket "${rule.bucket}", on "${rule.on}"${rule.prefix === void 0 ? "" : `, prefix "${rule.prefix}"`}) — the same decision function is registered more than once. Multiple distinct rules per (bucket, on) are allowed (they OR); remove the duplicate.`
|
|
12
15
|
);
|
|
13
16
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { toErrorBody } from '@lunora/errors';
|
|
1
2
|
import { parseValidatorMap, ValidationError } from '@lunora/values';
|
|
2
3
|
import { Hono } from 'hono';
|
|
3
|
-
import { LunoraError } from './LunoraError-
|
|
4
|
+
import { LunoraError } from './LunoraError-WbxmrpxR.mjs';
|
|
4
5
|
|
|
5
6
|
const httpAction = (handler) => async (c) => handler(c.get("lunora"), c.req.raw);
|
|
6
7
|
const httpRouter = () => {
|
|
@@ -109,7 +110,11 @@ const errorResponse = (error) => {
|
|
|
109
110
|
return Response.json({ code: "BAD_REQUEST", error: error.message }, { status: 400 });
|
|
110
111
|
}
|
|
111
112
|
if (error instanceof LunoraError) {
|
|
112
|
-
|
|
113
|
+
const { body, redacted, status } = toErrorBody(error, { fallbackCode: "INTERNAL_SERVER_ERROR", redactedMessage: "Internal error" });
|
|
114
|
+
if (redacted) {
|
|
115
|
+
console.error("[lunora] http action error (redacted on the wire):", error);
|
|
116
|
+
}
|
|
117
|
+
return Response.json({ code: body.code, error: body.message }, { status });
|
|
113
118
|
}
|
|
114
119
|
throw error;
|
|
115
120
|
};
|
|
@@ -121,18 +126,25 @@ const buildRouteHandler = (state, userHandler) => async (c) => {
|
|
|
121
126
|
const body = Object.keys(state.body).length > 0 ? await parseBody(state.body, c) : {};
|
|
122
127
|
const result = await userHandler({ body, ctx: context, params, searchParams });
|
|
123
128
|
const payload = state.output ? applyOutput(state.output, result) : result;
|
|
124
|
-
|
|
129
|
+
const headers = {};
|
|
130
|
+
if (state.cacheControl) {
|
|
131
|
+
headers["cache-control"] = state.cacheControl;
|
|
132
|
+
}
|
|
133
|
+
if (state.cacheTag) {
|
|
134
|
+
headers["cache-tag"] = state.cacheTag;
|
|
135
|
+
}
|
|
136
|
+
if (state.vary) {
|
|
137
|
+
headers.vary = state.vary;
|
|
138
|
+
}
|
|
139
|
+
const hasCacheHeaders = Object.keys(headers).length > 0;
|
|
140
|
+
if (payload === void 0) {
|
|
141
|
+
return new Response(null, { headers: hasCacheHeaders ? headers : void 0, status: 204 });
|
|
142
|
+
}
|
|
143
|
+
return Response.json(payload, { headers: hasCacheHeaders ? headers : void 0 });
|
|
125
144
|
} catch (error) {
|
|
126
145
|
return errorResponse(error);
|
|
127
146
|
}
|
|
128
147
|
};
|
|
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
148
|
const sseFrame = (chunk, event) => {
|
|
137
149
|
const data = JSON.stringify(chunk);
|
|
138
150
|
const prefix = event ? `event: ${event}
|
|
@@ -193,39 +205,42 @@ const buildStreamHandler = (state, userHandler) => (
|
|
|
193
205
|
}
|
|
194
206
|
controller.enqueue(encoder.encode(sseFrame({}, "complete")));
|
|
195
207
|
} catch (error) {
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
payload = { code: error.code, message: error.message };
|
|
199
|
-
} else {
|
|
208
|
+
const { body, redacted } = toErrorBody(error, { fallbackCode: "INTERNAL_SERVER_ERROR", redactedMessage: "Internal error" });
|
|
209
|
+
if (redacted) {
|
|
200
210
|
console.error("[lunora] unhandled stream handler error:", error);
|
|
201
|
-
payload = { code: "INTERNAL_SERVER_ERROR", message: "Internal error" };
|
|
202
211
|
}
|
|
203
|
-
controller.enqueue(encoder.encode(sseFrame(
|
|
212
|
+
controller.enqueue(encoder.encode(sseFrame({ code: body.code, message: body.message }, "error")));
|
|
204
213
|
} finally {
|
|
205
214
|
request.signal.removeEventListener("abort", onAbort);
|
|
206
215
|
controller.close();
|
|
207
216
|
}
|
|
208
217
|
}
|
|
209
218
|
});
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
+
const headers = {
|
|
220
|
+
// SSE responses must stay uncacheable so proxies don't buffer or
|
|
221
|
+
// coalesce live frames. `cacheControl()` is intentionally ignored
|
|
222
|
+
// for stream() routes; `cacheTag`/`vary` are also omitted because
|
|
223
|
+
// they only make sense alongside a cacheable response.
|
|
224
|
+
"cache-control": "no-cache, no-transform",
|
|
225
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
226
|
+
// Hint to proxies (including Cloudflare's own buffering layer)
|
|
227
|
+
// that this response must not be coalesced.
|
|
228
|
+
"x-accel-buffering": "no"
|
|
229
|
+
};
|
|
230
|
+
return new Response(stream, { headers });
|
|
219
231
|
}
|
|
220
232
|
);
|
|
221
233
|
const makeRouteBuilder = (state) => {
|
|
222
234
|
return {
|
|
223
235
|
body: (validators) => makeRouteBuilder({ ...state, body: { ...state.body, ...validators } }),
|
|
236
|
+
cacheControl: (value) => makeRouteBuilder({ ...state, cacheControl: value }),
|
|
237
|
+
cacheTag: (value) => makeRouteBuilder({ ...state, cacheTag: value }),
|
|
224
238
|
handler: (userHandler) => buildRouteHandler(state, userHandler),
|
|
225
239
|
output: (validator) => makeRouteBuilder({ ...state, output: validator }),
|
|
226
240
|
params: (validators) => makeRouteBuilder({ ...state, params: { ...state.params, ...validators } }),
|
|
227
241
|
searchParams: (validators) => makeRouteBuilder({ ...state, searchParams: { ...state.searchParams, ...validators } }),
|
|
228
|
-
stream: (userHandler) => buildStreamHandler(state, userHandler)
|
|
242
|
+
stream: (userHandler) => buildStreamHandler(state, userHandler),
|
|
243
|
+
vary: (value) => makeRouteBuilder({ ...state, vary: value })
|
|
229
244
|
};
|
|
230
245
|
};
|
|
231
246
|
const makeRouteFactory = (method) => (path) => makeRouteBuilder({ body: {}, method, params: {}, path, searchParams: {} });
|
|
@@ -337,4 +352,4 @@ const serveStorageObject = async (context, key, request) => {
|
|
|
337
352
|
});
|
|
338
353
|
};
|
|
339
354
|
|
|
340
|
-
export { httpAction, httpRoute, httpRouter, serveStorageObject };
|
|
355
|
+
export { httpAction, httpRoute, httpRouter, isSafeHeaderValue, serveStorageObject };
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { r as
|
|
3
|
-
|
|
4
|
-
const validateArgs = (validators, args) => parseValidatorMap(validators, args, "args");
|
|
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-I6EiQfxL.mjs';
|
|
5
4
|
|
|
6
5
|
const runMiddleware = (middlewares, baseContext) => runMiddlewareChain(middlewares, baseContext, (context) => context);
|
|
7
6
|
const makeHandler = (args, middlewares, userHandler, output) => async (context, rawArgs) => {
|
|
@@ -35,16 +34,29 @@ const makeStreamHandler = (args, middlewares, userHandler) => (context, rawArgs,
|
|
|
35
34
|
}
|
|
36
35
|
})();
|
|
37
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
|
+
};
|
|
38
48
|
const makeBuilder = (kind, state, visibility) => {
|
|
39
49
|
return {
|
|
40
50
|
__lunoraProcedure: kind,
|
|
41
51
|
...visibility ? { __lunoraVisibility: visibility } : {},
|
|
42
52
|
input: (validators) => makeBuilder(kind, { ...state, args: { ...state.args, ...validators } }, visibility),
|
|
43
53
|
[kind]: (userHandler) => {
|
|
54
|
+
const rls = collectRls(state.middlewares);
|
|
44
55
|
return {
|
|
45
56
|
args: state.args,
|
|
46
57
|
handler: makeHandler(state.args, state.middlewares, userHandler, state.output),
|
|
47
58
|
kind,
|
|
59
|
+
...rls ? { rls } : {},
|
|
48
60
|
...visibility ? { visibility } : {}
|
|
49
61
|
};
|
|
50
62
|
},
|
|
@@ -55,10 +67,12 @@ const makeBuilder = (kind, state, visibility) => {
|
|
|
55
67
|
// unconditionally keeps the runtime free of per-kind branching.
|
|
56
68
|
...kind === "query" ? {
|
|
57
69
|
stream: (userHandler) => {
|
|
70
|
+
const rls = collectRls(state.middlewares);
|
|
58
71
|
return {
|
|
59
72
|
args: state.args,
|
|
60
73
|
handler: makeStreamHandler(state.args, state.middlewares, userHandler),
|
|
61
74
|
kind: "stream",
|
|
75
|
+
...rls ? { rls } : {},
|
|
62
76
|
...visibility ? { visibility } : {}
|
|
63
77
|
};
|
|
64
78
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { LunoraError } from './LunoraError-
|
|
2
|
-
import { bindTableFacade, bindOrm } from './bindOrm-
|
|
1
|
+
import { LunoraError } from './LunoraError-WbxmrpxR.mjs';
|
|
2
|
+
import { bindTableFacade, bindOrm } from './bindOrm-Bfgl7f-Q.mjs';
|
|
3
3
|
|
|
4
4
|
const permissionName = (permission) => typeof permission === "string" ? permission : permission.name;
|
|
5
5
|
const indexRolePermissions = (roles) => {
|
|
@@ -46,6 +46,29 @@ const maskRow = (row, columns, base) => {
|
|
|
46
46
|
const maskPage = (page, columns, base) => {
|
|
47
47
|
return { ...page, page: page.page.map((row) => maskRow(row, columns, base)) };
|
|
48
48
|
};
|
|
49
|
+
const assertIndexFieldsAllowed = (builderCallback, columns, tableName, method) => {
|
|
50
|
+
if (typeof builderCallback !== "function") {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
54
|
+
const makeRecorder = () => /* @__PURE__ */ new Proxy(
|
|
55
|
+
{},
|
|
56
|
+
{
|
|
57
|
+
get: () => (field) => {
|
|
58
|
+
if (typeof field === "string") {
|
|
59
|
+
referenced.add(field);
|
|
60
|
+
}
|
|
61
|
+
return makeRecorder();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
builderCallback(makeRecorder());
|
|
66
|
+
for (const field of referenced) {
|
|
67
|
+
if (field in columns) {
|
|
68
|
+
throw new LunoraError("MASK_UNSUPPORTED", `${method}() filtering "${tableName}" by masked column "${field}" is not supported`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
49
72
|
const isFacadeEntry = (value) => {
|
|
50
73
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
51
74
|
return false;
|
|
@@ -54,21 +77,27 @@ const isFacadeEntry = (value) => {
|
|
|
54
77
|
return typeof candidate["findMany"] === "function" && typeof candidate["withSearchIndex"] === "function";
|
|
55
78
|
};
|
|
56
79
|
const wrapDatabase = (base, perTable, context) => {
|
|
57
|
-
const wrapReader = (reader, columns) => {
|
|
80
|
+
const wrapReader = (reader, columns, tableName) => {
|
|
58
81
|
return {
|
|
59
82
|
collect: async () => {
|
|
60
83
|
const rows = await reader.collect();
|
|
61
84
|
return rows.map((row) => maskRow(row, columns, context));
|
|
62
85
|
},
|
|
86
|
+
// SECURITY (value oracle): the predicate must see the MASKED row, not
|
|
87
|
+
// the raw stored row — otherwise a caller can `.filter(d => d.ssn ===
|
|
88
|
+
// guess)` to read the value the mask hides. Masking before the
|
|
89
|
+
// predicate keeps filtering on non-masked columns working while
|
|
90
|
+
// redacting masked cells the predicate can observe.
|
|
63
91
|
filter: (predicate) => wrapReader(
|
|
64
|
-
reader.filter((document) => predicate(document)),
|
|
65
|
-
columns
|
|
92
|
+
reader.filter((document) => predicate(maskRow(document, columns, context))),
|
|
93
|
+
columns,
|
|
94
|
+
tableName
|
|
66
95
|
),
|
|
67
96
|
first: async () => {
|
|
68
97
|
const row = await reader.first();
|
|
69
98
|
return row ? maskRow(row, columns, context) : null;
|
|
70
99
|
},
|
|
71
|
-
order: (direction) => wrapReader(reader.order(direction), columns),
|
|
100
|
+
order: (direction) => wrapReader(reader.order(direction), columns, tableName),
|
|
72
101
|
paginate: async (options) => maskPage(await reader.paginate(options), columns, context),
|
|
73
102
|
take: async (limit) => {
|
|
74
103
|
const rows = await reader.take(limit);
|
|
@@ -78,8 +107,19 @@ const wrapDatabase = (base, perTable, context) => {
|
|
|
78
107
|
const row = await reader.unique();
|
|
79
108
|
return row ? maskRow(row, columns, context) : null;
|
|
80
109
|
},
|
|
81
|
-
|
|
82
|
-
|
|
110
|
+
// SECURITY (value oracle): reject before delegating when the range /
|
|
111
|
+
// search references a masked column — an index range or search term
|
|
112
|
+
// over a masked column is the same value oracle as a masked-column
|
|
113
|
+
// `where`, so it must fail closed (see `assertIndexFieldsAllowed`).
|
|
114
|
+
// Reads over NON-masked columns pass through and still mask output.
|
|
115
|
+
withIndex: (indexName, range) => {
|
|
116
|
+
assertIndexFieldsAllowed(range, columns, tableName, "withIndex");
|
|
117
|
+
return wrapReader(reader.withIndex(indexName, range), columns, tableName);
|
|
118
|
+
},
|
|
119
|
+
withSearchIndex: (indexName, search) => {
|
|
120
|
+
assertIndexFieldsAllowed(search, columns, tableName, "withSearchIndex");
|
|
121
|
+
return wrapReader(reader.withSearchIndex(indexName, search), columns, tableName);
|
|
122
|
+
}
|
|
83
123
|
};
|
|
84
124
|
};
|
|
85
125
|
const locate = async (id, expectedTable) => {
|
|
@@ -120,23 +160,63 @@ const wrapDatabase = (base, perTable, context) => {
|
|
|
120
160
|
throw new LunoraError("MASK_UNSUPPORTED", `${method}() over masked column "${offending}" on "${tableName}" is not supported`);
|
|
121
161
|
}
|
|
122
162
|
};
|
|
163
|
+
const collectWhereFields = (where, into) => {
|
|
164
|
+
if (!where || typeof where !== "object" || Array.isArray(where)) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const [key, value] of Object.entries(where)) {
|
|
168
|
+
if (key === "AND" || key === "OR") {
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
for (const clause of value) {
|
|
171
|
+
collectWhereFields(clause, into);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} else if (key === "NOT") {
|
|
175
|
+
collectWhereFields(value, into);
|
|
176
|
+
} else if (!key.startsWith("__")) {
|
|
177
|
+
into.add(key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const assertWhereAllowed = (tableName, where, method) => {
|
|
182
|
+
const columns = perTable.get(tableName);
|
|
183
|
+
if (!columns || where === void 0) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
187
|
+
collectWhereFields(where, referenced);
|
|
188
|
+
for (const field of referenced) {
|
|
189
|
+
if (field in columns) {
|
|
190
|
+
throw new LunoraError("MASK_UNSUPPORTED", `${method}() filtering "${tableName}" by masked column "${field}" is not supported`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
123
194
|
const wrapped = {
|
|
124
195
|
...base,
|
|
125
196
|
aggregate(tableName, options) {
|
|
126
197
|
assertReductionAllowed(tableName, [options.field], "aggregate");
|
|
127
198
|
return base.aggregate(tableName, options);
|
|
128
199
|
},
|
|
200
|
+
count(tableName, whereOrArgs) {
|
|
201
|
+
const wrapper = whereOrArgs && typeof whereOrArgs === "object" && !Array.isArray(whereOrArgs) ? whereOrArgs : void 0;
|
|
202
|
+
const where = wrapper && ("where" in wrapper || "baseWhere" in wrapper || "restrictsCounts" in wrapper) ? wrapper.where : whereOrArgs;
|
|
203
|
+
assertWhereAllowed(tableName, where, "count");
|
|
204
|
+
return base.count(tableName, whereOrArgs);
|
|
205
|
+
},
|
|
129
206
|
async findFirst(tableName, args) {
|
|
207
|
+
assertWhereAllowed(tableName, args?.where, "findFirst");
|
|
130
208
|
const row = await base.findFirst(tableName, args);
|
|
131
209
|
const columns = perTable.get(tableName);
|
|
132
210
|
return row && columns ? maskRow(row, columns, context) : row;
|
|
133
211
|
},
|
|
134
212
|
async findFirstOrThrow(tableName, args) {
|
|
213
|
+
assertWhereAllowed(tableName, args?.where, "findFirstOrThrow");
|
|
135
214
|
const row = await base.findFirstOrThrow(tableName, args);
|
|
136
215
|
const columns = perTable.get(tableName);
|
|
137
216
|
return columns ? maskRow(row, columns, context) : row;
|
|
138
217
|
},
|
|
139
218
|
async findMany(tableName, args) {
|
|
219
|
+
assertWhereAllowed(tableName, args?.where, "findMany");
|
|
140
220
|
const page = await base.findMany(tableName, args);
|
|
141
221
|
const columns = perTable.get(tableName);
|
|
142
222
|
return columns ? maskPage(page, columns, context) : page;
|
|
@@ -156,7 +236,7 @@ const wrapDatabase = (base, perTable, context) => {
|
|
|
156
236
|
query(tableName) {
|
|
157
237
|
const reader = base.query(tableName);
|
|
158
238
|
const columns = perTable.get(tableName);
|
|
159
|
-
return columns ? wrapReader(reader, columns) : reader;
|
|
239
|
+
return columns ? wrapReader(reader, columns, tableName) : reader;
|
|
160
240
|
},
|
|
161
241
|
async rankPage(tableName, indexName, options) {
|
|
162
242
|
const page = await base.rankPage(tableName, indexName, options);
|
|
@@ -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 };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { LunoraError } from './LunoraError-
|
|
2
|
-
import { bindTableFacade, bindOrm } from './bindOrm-
|
|
1
|
+
import { LunoraError } from './LunoraError-WbxmrpxR.mjs';
|
|
2
|
+
import { bindTableFacade, bindOrm } from './bindOrm-Bfgl7f-Q.mjs';
|
|
3
|
+
import { t as tagRlsMiddleware } from './policy-tag-DvpVH2tv.mjs';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_BATCH_LIMIT = 500;
|
|
5
6
|
const assertBatchLimit = (count, limit, op) => {
|
|
@@ -244,7 +245,6 @@ const isFacadeEntry = (value) => {
|
|
|
244
245
|
return typeof candidate["findMany"] === "function" && typeof candidate["withSearchIndex"] === "function";
|
|
245
246
|
};
|
|
246
247
|
const wrapDatabase = (base, raw, perTable, context) => {
|
|
247
|
-
const route = (tableName) => perTable.has(tableName) ? raw : base;
|
|
248
248
|
const readBaseCache = /* @__PURE__ */ new Map();
|
|
249
249
|
const readBase = (tableName) => {
|
|
250
250
|
const cached = readBaseCache.get(tableName);
|
|
@@ -262,6 +262,7 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
262
262
|
readBaseCache.set(tableName, result);
|
|
263
263
|
return result;
|
|
264
264
|
};
|
|
265
|
+
const route = (tableName) => readBase(tableName).restricts ? raw : base;
|
|
265
266
|
const relationReadFilter = (table) => readBase(table).baseWhere;
|
|
266
267
|
const locateRow = async (id, expectedTable) => {
|
|
267
268
|
if (raw.lookupById) {
|
|
@@ -299,7 +300,7 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
299
300
|
const nextRow = computeNextRow ? computeNextRow(located.row) : void 0;
|
|
300
301
|
const writeOk = evaluateWrite(policies, op, { ...context, row: located.row }, nextRow);
|
|
301
302
|
if (!writeOk) {
|
|
302
|
-
throw new LunoraError("FORBIDDEN", `${op}
|
|
303
|
+
throw new LunoraError("FORBIDDEN", `${op} denied by policy`);
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
306
|
return perform(raw);
|
|
@@ -324,7 +325,7 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
324
325
|
restrictsCounts: (args.restrictsCounts ?? false) || restricts
|
|
325
326
|
});
|
|
326
327
|
},
|
|
327
|
-
delete: (id, expectedTable) => gateById(id, "delete", (writer) => writer.delete(id, expectedTable), void 0, expectedTable),
|
|
328
|
+
delete: (id, expectedTable, options) => gateById(id, "delete", (writer) => writer.delete(id, expectedTable, options), void 0, expectedTable),
|
|
328
329
|
async deleteMany(ids, options, expectedTable) {
|
|
329
330
|
assertBatchLimit(ids.length, options?.limit, "deleteMany");
|
|
330
331
|
for (const id of ids) {
|
|
@@ -332,6 +333,16 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
332
333
|
}
|
|
333
334
|
return { deleted: ids.length };
|
|
334
335
|
},
|
|
336
|
+
async deleteWhere(tableName, where, options) {
|
|
337
|
+
const { baseWhere } = readBase(tableName);
|
|
338
|
+
const resolved = await route(tableName).findMany(tableName, {
|
|
339
|
+
baseWhere: mergeBaseWhere(where, baseWhere),
|
|
340
|
+
relationBaseWhere: relationReadFilter
|
|
341
|
+
});
|
|
342
|
+
const ids = resolved.page.map((row) => String(row["_id"]));
|
|
343
|
+
assertBatchLimit(ids.length, options?.limit, "deleteWhere");
|
|
344
|
+
return wrapped.deleteMany(ids, options);
|
|
345
|
+
},
|
|
335
346
|
async findFirst(tableName, args) {
|
|
336
347
|
const { baseWhere } = readBase(tableName);
|
|
337
348
|
return route(tableName).findFirst(tableName, {
|
|
@@ -365,7 +376,10 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
365
376
|
return base.get(id, expectedTable);
|
|
366
377
|
}
|
|
367
378
|
const { baseWhere, restricts } = readBase(located.tableName);
|
|
368
|
-
if (!restricts
|
|
379
|
+
if (!restricts) {
|
|
380
|
+
return base.get(id, expectedTable);
|
|
381
|
+
}
|
|
382
|
+
if (!baseWhere) {
|
|
369
383
|
return located.row;
|
|
370
384
|
}
|
|
371
385
|
const allowed = await raw.findFirst(located.tableName, { baseWhere, limit: 1, where: { _id: id } });
|
|
@@ -432,6 +446,19 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
432
446
|
expectedTable
|
|
433
447
|
);
|
|
434
448
|
}
|
|
449
|
+
return { patched: patches.length };
|
|
450
|
+
},
|
|
451
|
+
async patchWhere(tableName, args, options) {
|
|
452
|
+
const { baseWhere } = readBase(tableName);
|
|
453
|
+
const resolved = await route(tableName).findMany(tableName, {
|
|
454
|
+
baseWhere: mergeBaseWhere(args.where, baseWhere),
|
|
455
|
+
relationBaseWhere: relationReadFilter
|
|
456
|
+
});
|
|
457
|
+
const patches = resolved.page.map((row) => {
|
|
458
|
+
return { id: String(row["_id"]), patch: args.patch };
|
|
459
|
+
});
|
|
460
|
+
assertBatchLimit(patches.length, options?.limit, "patchWhere");
|
|
461
|
+
return wrapped.patchMany(patches, options);
|
|
435
462
|
},
|
|
436
463
|
query(tableName) {
|
|
437
464
|
const { baseWhere } = readBase(tableName);
|
|
@@ -441,6 +468,22 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
441
468
|
}
|
|
442
469
|
return reader.filter((document) => matchesWhere(document, baseWhere));
|
|
443
470
|
},
|
|
471
|
+
// Restore clears the soft-delete marker — a by-id un-delete, gated as an
|
|
472
|
+
// "update" (the policy that governs patch). No post-image check: the
|
|
473
|
+
// writer owns the marker field, so we only enforce the pre-image USING.
|
|
474
|
+
restore: (id, expectedTable) => gateById(
|
|
475
|
+
id,
|
|
476
|
+
"update",
|
|
477
|
+
async (writer) => {
|
|
478
|
+
const perform = writer.restore ?? raw.restore;
|
|
479
|
+
if (!perform) {
|
|
480
|
+
throw new LunoraError("BAD_REQUEST", "restore is not supported by this writer");
|
|
481
|
+
}
|
|
482
|
+
await perform(id, expectedTable);
|
|
483
|
+
},
|
|
484
|
+
void 0,
|
|
485
|
+
expectedTable
|
|
486
|
+
),
|
|
444
487
|
replace: (id, document, expectedTable) => gateById(
|
|
445
488
|
id,
|
|
446
489
|
"update",
|
|
@@ -516,7 +559,7 @@ const indexRolePermissions = (roles) => {
|
|
|
516
559
|
const rls = (policies, options = {}) => {
|
|
517
560
|
const perTable = indexByTable(policies);
|
|
518
561
|
const rolePermissions = indexRolePermissions(options.roles);
|
|
519
|
-
|
|
562
|
+
const middleware = async ({ ctx, next }) => {
|
|
520
563
|
const auth = ctx.auth ?? {};
|
|
521
564
|
const identity = await auth.getIdentity?.() ?? null;
|
|
522
565
|
const roles = auth.roles ?? [];
|
|
@@ -546,6 +589,7 @@ const rls = (policies, options = {}) => {
|
|
|
546
589
|
}
|
|
547
590
|
return next({ ctx: extension });
|
|
548
591
|
};
|
|
592
|
+
return tagRlsMiddleware(middleware, { policies, roles: options.roles ?? [] });
|
|
549
593
|
};
|
|
550
594
|
|
|
551
595
|
export { computeReadBaseWhere, evaluateWrite, indexRolePermissions, matchesWhere, permissionName, rls };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { LunoraError as LunoraError$1 } from '@lunora/errors';
|
|
2
|
+
|
|
1
3
|
const runMiddlewareChain = async (middlewares, baseContext, terminal) => {
|
|
2
4
|
let lastIndex = -1;
|
|
3
5
|
const dispatch = async (index, context) => {
|
|
4
6
|
if (index <= lastIndex) {
|
|
5
|
-
throw new
|
|
7
|
+
throw new LunoraError$1("INTERNAL", "middleware next() called multiple times");
|
|
6
8
|
}
|
|
7
9
|
lastIndex = index;
|
|
8
10
|
const middleware = middlewares[index];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LunoraError } from './LunoraError-
|
|
1
|
+
import { LunoraError } from './LunoraError-WbxmrpxR.mjs';
|
|
2
2
|
|
|
3
3
|
const permissionName = (permission) => typeof permission === "string" ? permission : permission.name;
|
|
4
4
|
const indexRolePermissions = (roles = []) => {
|
|
@@ -45,10 +45,10 @@ type PolicyDecisionOf<DM, REL extends Record<keyof DM, object>, T extends keyof
|
|
|
45
45
|
* unknown table, a stray column, or a relation predicate naming a relation the
|
|
46
46
|
* table does not declare is a compile error rather than a silent runtime deny.
|
|
47
47
|
*/
|
|
48
|
-
interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T extends keyof DM, Context = unknown
|
|
48
|
+
interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T extends keyof DM, Context = unknown, Identity = Record<string, unknown>> {
|
|
49
49
|
on: PolicyOperation;
|
|
50
50
|
table: T;
|
|
51
|
-
when: (context: PolicyContext<Context>) => PolicyDecisionOf<DM, REL, T>;
|
|
51
|
+
when: (context: PolicyContext<Context, Identity>) => PolicyDecisionOf<DM, REL, T>;
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
54
|
* Context handed to a policy. `auth.roles` is the per-request role list,
|
|
@@ -59,7 +59,7 @@ interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T ext
|
|
|
59
59
|
* pre-write row; for `insert` it is the candidate document. `ctx` is the full
|
|
60
60
|
* procedure context the middleware closed over.
|
|
61
61
|
*/
|
|
62
|
-
interface PolicyContext<Context = unknown
|
|
62
|
+
interface PolicyContext<Context = unknown, Identity = Record<string, unknown>> {
|
|
63
63
|
readonly auth: {
|
|
64
64
|
/**
|
|
65
65
|
* `true` when any of the request's `roles` grants `permission` (passed
|
|
@@ -68,7 +68,13 @@ interface PolicyContext<Context = unknown> {
|
|
|
68
68
|
* when none of the request's roles lists the permission.
|
|
69
69
|
*/
|
|
70
70
|
readonly can: (permission: Permission | string) => boolean;
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The resolved identity, typed to the `Identity` type parameter bound on
|
|
73
|
+
* `createPolicyDsl` — e.g. the app's `defineIdentity(...)` claim type via
|
|
74
|
+
* the `InferIdentity` helper; otherwise the untyped claim bag.
|
|
75
|
+
* `undefined`/`null` when the request is anonymous.
|
|
76
|
+
*/
|
|
77
|
+
readonly identity?: Identity | null;
|
|
72
78
|
readonly roles: ReadonlyArray<string>;
|
|
73
79
|
readonly userId: null | string;
|
|
74
80
|
};
|
|
@@ -45,10 +45,10 @@ type PolicyDecisionOf<DM, REL extends Record<keyof DM, object>, T extends keyof
|
|
|
45
45
|
* unknown table, a stray column, or a relation predicate naming a relation the
|
|
46
46
|
* table does not declare is a compile error rather than a silent runtime deny.
|
|
47
47
|
*/
|
|
48
|
-
interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T extends keyof DM, Context = unknown
|
|
48
|
+
interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T extends keyof DM, Context = unknown, Identity = Record<string, unknown>> {
|
|
49
49
|
on: PolicyOperation;
|
|
50
50
|
table: T;
|
|
51
|
-
when: (context: PolicyContext<Context>) => PolicyDecisionOf<DM, REL, T>;
|
|
51
|
+
when: (context: PolicyContext<Context, Identity>) => PolicyDecisionOf<DM, REL, T>;
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
54
|
* Context handed to a policy. `auth.roles` is the per-request role list,
|
|
@@ -59,7 +59,7 @@ interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T ext
|
|
|
59
59
|
* pre-write row; for `insert` it is the candidate document. `ctx` is the full
|
|
60
60
|
* procedure context the middleware closed over.
|
|
61
61
|
*/
|
|
62
|
-
interface PolicyContext<Context = unknown
|
|
62
|
+
interface PolicyContext<Context = unknown, Identity = Record<string, unknown>> {
|
|
63
63
|
readonly auth: {
|
|
64
64
|
/**
|
|
65
65
|
* `true` when any of the request's `roles` grants `permission` (passed
|
|
@@ -68,7 +68,13 @@ interface PolicyContext<Context = unknown> {
|
|
|
68
68
|
* when none of the request's roles lists the permission.
|
|
69
69
|
*/
|
|
70
70
|
readonly can: (permission: Permission | string) => boolean;
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The resolved identity, typed to the `Identity` type parameter bound on
|
|
73
|
+
* `createPolicyDsl` — e.g. the app's `defineIdentity(...)` claim type via
|
|
74
|
+
* the `InferIdentity` helper; otherwise the untyped claim bag.
|
|
75
|
+
* `undefined`/`null` when the request is anonymous.
|
|
76
|
+
*/
|
|
77
|
+
readonly identity?: Identity | null;
|
|
72
78
|
readonly roles: ReadonlyArray<string>;
|
|
73
79
|
readonly userId: null | string;
|
|
74
80
|
};
|
package/dist/rls/testing.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { P as PolicyOperation, R as Role, a as Policy } from "../packem_shared/types.d-
|
|
1
|
+
import { P as PolicyOperation, R as Role, a as Policy } from "../packem_shared/types.d-BB3pjV0m.mjs";
|
|
2
2
|
import "../data-model.mjs";
|
|
3
3
|
/**
|
|
4
4
|
* The slice of a request identity a policy reads. Mirrors the
|