@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
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { default as asBucketStorage } from './packem_shared/asBucketStorage-Cnxd9y2q.mjs';
|
|
2
|
+
export { initLunora } from './packem_shared/initLunora-lxwHTEV3.mjs';
|
|
3
|
+
export { createSecrets } from './packem_shared/createSecrets-TsIP9lOa.mjs';
|
|
4
|
+
export { LunoraEnvError, defineEnv, redactSecrets } from './packem_shared/LunoraEnvError-DjFkpkSP.mjs';
|
|
5
|
+
export { LunoraError } from './packem_shared/LunoraError-DN7Zhhvu.mjs';
|
|
6
|
+
export { bindOrm, bindTableFacade } from './packem_shared/bindOrm-Ce57S3N9.mjs';
|
|
7
|
+
export { httpAction, httpRoute, httpRouter, serveStorageObject } from './packem_shared/httpAction-FLwfsePg.mjs';
|
|
8
|
+
export { onConnect, onDisconnect } from './packem_shared/onConnect-CIPXKPyw.mjs';
|
|
9
|
+
export { defineMigration } from './packem_shared/defineMigration-CAJLr6fx.mjs';
|
|
10
|
+
export { defineMutator } from './packem_shared/defineMutator-EIXAWhs9.mjs';
|
|
11
|
+
export { composePluginMiddleware, defineComponent, definePlugin, defineSchemaExtension, installPlugins, mergeSchemaExtension } from './packem_shared/composePluginMiddleware-Ck5_TUO8.mjs';
|
|
12
|
+
export { PRESENCE_DEFAULT_TTL_MS, PRESENCE_TABLE, definePresence, presenceExtension } from './packem_shared/PRESENCE_DEFAULT_TTL_MS-D8viLY1S.mjs';
|
|
13
|
+
export { protectPublic } from './packem_shared/protectPublic-BjFkQ_Or.mjs';
|
|
14
|
+
export { defineAggregateIndex, defineRankIndex, defineSchema, defineTable, defineVectorIndex } from './packem_shared/defineAggregateIndex-ZdyU78gh.mjs';
|
|
15
|
+
export { defineShape } from './packem_shared/defineShape-CJ27Wx7o.mjs';
|
|
16
|
+
export { anyApi } from './types.mjs';
|
|
17
|
+
export { cronJobs } from '@lunora/scheduler';
|
|
18
|
+
export { ValidationError, v } from '@lunora/values';
|
|
19
|
+
export { buildRlsReadRegistry, composeShapeReadWhere } from './packem_shared/buildRlsReadRegistry-1jexWrb3.mjs';
|
|
20
|
+
export { createPolicyDsl, definePermission, definePolicies, definePolicy, defineRole } from './packem_shared/createPolicyDsl-De67zPDS.mjs';
|
|
21
|
+
export { defineStorageRule, defineStorageRules } from './packem_shared/defineStorageRule-qu0mpilX.mjs';
|
|
22
|
+
export { mask } from './packem_shared/mask-BV_jNzsN.mjs';
|
|
23
|
+
export { rls } from './packem_shared/rls-2Jhd0uev.mjs';
|
|
24
|
+
export { storageRules } from './packem_shared/storageRules-Cje6Woea.mjs';
|
|
25
|
+
|
|
26
|
+
const VERSION = "0.0.0";
|
|
27
|
+
|
|
28
|
+
export { VERSION };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { optionalInner } from '@lunora/values';
|
|
2
|
+
|
|
3
|
+
const SECRET_VALUE_PREFIXES = [
|
|
4
|
+
/^sk_/u,
|
|
5
|
+
/^pk_/u,
|
|
6
|
+
/^rk_/u,
|
|
7
|
+
/^ghp_/u,
|
|
8
|
+
/^gho_/u,
|
|
9
|
+
/^ghs_/u,
|
|
10
|
+
/^ghr_/u,
|
|
11
|
+
/^github_pat_/u,
|
|
12
|
+
/^xox[baprs]-/u,
|
|
13
|
+
/^AKIA/u,
|
|
14
|
+
/^AIza/u,
|
|
15
|
+
/^Bearer\s/u
|
|
16
|
+
];
|
|
17
|
+
const HIGH_ENTROPY_TOKEN = /[\w./+-]{24,}/gu;
|
|
18
|
+
const STANDALONE_TOKEN = /^[\w./+-]+$/u;
|
|
19
|
+
const EMBEDDED_PREFIXED_TOKEN_SHORT = /\b(?:(?:sk|pk|rk|ghp|gho|ghs|ghr)_[\w./+-]*|github_pat_[\w./+-]*|xox[baprs]-[\w./+-]*)/gu;
|
|
20
|
+
const EMBEDDED_PREFIXED_TOKEN_LONG = /\b(?:AKIA|AIza)[\w./+-]+|Bearer\s+[\w./+-]+/gu;
|
|
21
|
+
const URL_CREDENTIAL = /\b([a-z][\w.+-]*:\/\/[\w.%+-]+):[\w.%+-]+@/gu;
|
|
22
|
+
const REDACTED = "[redacted]";
|
|
23
|
+
const SECRET_KEY = /(?:KEY|PASSWORD|SECRET|TOKEN)$/u;
|
|
24
|
+
const looksLikeSecretValue = (value) => {
|
|
25
|
+
if (SECRET_VALUE_PREFIXES.some((prefix) => prefix.test(value))) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
return trimmed.length >= 24 && STANDALONE_TOKEN.test(trimmed);
|
|
30
|
+
};
|
|
31
|
+
const QUOTED_VALUE = /(["'])(?<inner>(?:\\.|(?!\1).)*)\1/gu;
|
|
32
|
+
const KEYED_VALUE = /\b(?<key>[A-Za-z_]\w*)\s*[=:]\s*\S+/gu;
|
|
33
|
+
const redactSecrets = (message) => {
|
|
34
|
+
let out = message;
|
|
35
|
+
out = out.replaceAll(QUOTED_VALUE, (match, ...groups) => {
|
|
36
|
+
const named = groups.at(-1);
|
|
37
|
+
return named?.inner !== void 0 && looksLikeSecretValue(named.inner) ? REDACTED : match;
|
|
38
|
+
});
|
|
39
|
+
out = out.replaceAll(URL_CREDENTIAL, (_match, prefix) => `${prefix}:${REDACTED}@`);
|
|
40
|
+
out = out.replaceAll(EMBEDDED_PREFIXED_TOKEN_SHORT, REDACTED);
|
|
41
|
+
out = out.replaceAll(EMBEDDED_PREFIXED_TOKEN_LONG, REDACTED);
|
|
42
|
+
out = out.replaceAll(KEYED_VALUE, (match, ...groups) => {
|
|
43
|
+
const named = groups.at(-1);
|
|
44
|
+
return named?.key !== void 0 && SECRET_KEY.test(named.key) ? `${named.key}=${REDACTED}` : match;
|
|
45
|
+
});
|
|
46
|
+
out = out.replaceAll(HIGH_ENTROPY_TOKEN, REDACTED);
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
const redactValueForKey = (message, key, raw) => {
|
|
50
|
+
const masked = redactSecrets(message);
|
|
51
|
+
if (typeof raw === "string" && raw !== "" && SECRET_KEY.test(key)) {
|
|
52
|
+
return masked.replaceAll(raw, REDACTED);
|
|
53
|
+
}
|
|
54
|
+
return masked;
|
|
55
|
+
};
|
|
56
|
+
class LunoraEnvError extends Error {
|
|
57
|
+
name = "LunoraEnvError";
|
|
58
|
+
failures;
|
|
59
|
+
constructor(failures) {
|
|
60
|
+
const summary = failures.map((failure) => ` - ${failure.key}: ${failure.message}`).join("\n");
|
|
61
|
+
super(`Invalid environment (${String(failures.length)} key(s)):
|
|
62
|
+
${summary}`);
|
|
63
|
+
this.failures = failures;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const TRUE_VALUES = /* @__PURE__ */ new Set(["1", "on", "true", "yes"]);
|
|
67
|
+
const FALSE_VALUES = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
68
|
+
const INTEGER_LITERAL = /^-?\d+$/u;
|
|
69
|
+
const unwrapKind = (validator) => {
|
|
70
|
+
if (validator.kind !== "optional") {
|
|
71
|
+
return validator.kind;
|
|
72
|
+
}
|
|
73
|
+
const inner = optionalInner(validator);
|
|
74
|
+
return inner ? unwrapKind(inner) : validator.kind;
|
|
75
|
+
};
|
|
76
|
+
const coerce = (validator, raw) => {
|
|
77
|
+
if (typeof raw !== "string") {
|
|
78
|
+
return raw;
|
|
79
|
+
}
|
|
80
|
+
switch (unwrapKind(validator)) {
|
|
81
|
+
case "bigint": {
|
|
82
|
+
return INTEGER_LITERAL.test(raw.trim()) ? BigInt(raw.trim()) : raw;
|
|
83
|
+
}
|
|
84
|
+
case "boolean": {
|
|
85
|
+
const lowered = raw.trim().toLowerCase();
|
|
86
|
+
if (TRUE_VALUES.has(lowered)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (FALSE_VALUES.has(lowered)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return raw;
|
|
93
|
+
}
|
|
94
|
+
case "number": {
|
|
95
|
+
const trimmed = raw.trim();
|
|
96
|
+
if (trimmed === "") {
|
|
97
|
+
return raw;
|
|
98
|
+
}
|
|
99
|
+
const parsed = Number(trimmed);
|
|
100
|
+
return Number.isNaN(parsed) ? raw : parsed;
|
|
101
|
+
}
|
|
102
|
+
default: {
|
|
103
|
+
return raw;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const validateKey = (key, validator, env, failures) => {
|
|
108
|
+
const raw = env[key];
|
|
109
|
+
if (raw === void 0 && validator.kind === "optional") {
|
|
110
|
+
return { ok: true, value: void 0 };
|
|
111
|
+
}
|
|
112
|
+
const result = validator.safeParse(coerce(validator, raw));
|
|
113
|
+
if (result.ok) {
|
|
114
|
+
return { ok: true, value: result.value };
|
|
115
|
+
}
|
|
116
|
+
failures.push({ key, message: redactValueForKey(result.error.message, key, raw) });
|
|
117
|
+
return { ok: false };
|
|
118
|
+
};
|
|
119
|
+
const requireEnvObject = (env) => {
|
|
120
|
+
if (typeof env !== "object" || env === null) {
|
|
121
|
+
throw new LunoraEnvError([{ key: "<env>", message: `expected an object, received ${env === null ? "null" : typeof env}` }]);
|
|
122
|
+
}
|
|
123
|
+
return env;
|
|
124
|
+
};
|
|
125
|
+
const defineEnv = (shape) => {
|
|
126
|
+
const keys = Object.keys(shape);
|
|
127
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
128
|
+
const accessor = ((env) => {
|
|
129
|
+
const source = requireEnvObject(env);
|
|
130
|
+
let resolved = cache.get(source);
|
|
131
|
+
if (resolved === void 0) {
|
|
132
|
+
resolved = /* @__PURE__ */ new Map();
|
|
133
|
+
cache.set(source, resolved);
|
|
134
|
+
}
|
|
135
|
+
const cached = resolved;
|
|
136
|
+
const read = (property) => {
|
|
137
|
+
if (cached.has(property)) {
|
|
138
|
+
return cached.get(property);
|
|
139
|
+
}
|
|
140
|
+
const failures = [];
|
|
141
|
+
const outcome = validateKey(property, shape[property], source, failures);
|
|
142
|
+
if (!outcome.ok) {
|
|
143
|
+
throw new LunoraEnvError(failures);
|
|
144
|
+
}
|
|
145
|
+
cached.set(property, outcome.value);
|
|
146
|
+
return outcome.value;
|
|
147
|
+
};
|
|
148
|
+
return /* @__PURE__ */ new Proxy({}, {
|
|
149
|
+
get(_target, property) {
|
|
150
|
+
if (typeof property !== "string" || !(property in shape)) {
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
return read(property);
|
|
154
|
+
},
|
|
155
|
+
getOwnPropertyDescriptor(_target, property) {
|
|
156
|
+
if (typeof property === "string" && property in shape) {
|
|
157
|
+
return { configurable: true, enumerable: true, value: read(property), writable: false };
|
|
158
|
+
}
|
|
159
|
+
return void 0;
|
|
160
|
+
},
|
|
161
|
+
has(_target, property) {
|
|
162
|
+
return typeof property === "string" && property in shape;
|
|
163
|
+
},
|
|
164
|
+
ownKeys() {
|
|
165
|
+
return keys;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
accessor.parse = (env) => {
|
|
170
|
+
const source = requireEnvObject(env);
|
|
171
|
+
const failures = [];
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const key of keys) {
|
|
174
|
+
const outcome = validateKey(key, shape[key], source, failures);
|
|
175
|
+
if (outcome.ok && outcome.value !== void 0) {
|
|
176
|
+
out[key] = outcome.value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (failures.length > 0) {
|
|
180
|
+
throw new LunoraEnvError(failures);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
};
|
|
184
|
+
return accessor;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export { LunoraEnvError, defineEnv, redactSecrets };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const CODE_STATUS = {
|
|
2
|
+
BAD_REQUEST: 400,
|
|
3
|
+
CONFLICT: 409,
|
|
4
|
+
/**
|
|
5
|
+
* `count()` invoked against a table whose context carries an active RLS
|
|
6
|
+
* policy. The operation itself is unsupported in an RLS-restricted reader
|
|
7
|
+
* (kitcn's documented constraint) — the request is well-formed and the
|
|
8
|
+
* caller is authorized, so this is a 422 (semantic conflict) rather than a
|
|
9
|
+
* 403 (policy denial).
|
|
10
|
+
*/
|
|
11
|
+
COUNT_RLS_UNSUPPORTED: 422,
|
|
12
|
+
FORBIDDEN: 403,
|
|
13
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
14
|
+
/**
|
|
15
|
+
* An analytical reduction (`aggregate` / `groupBy`) was invoked over a
|
|
16
|
+
* column that the procedure's `mask()` middleware redacts. A masked column
|
|
17
|
+
* can't be summed, averaged, or grouped without leaking the very values the
|
|
18
|
+
* mask hides (a group key *is* the raw value; an aggregate is computed from
|
|
19
|
+
* it), so the operation fails closed. The request is well-formed and the
|
|
20
|
+
* caller is authorized — this is a 422 (semantic conflict), mirroring
|
|
21
|
+
* `COUNT_RLS_UNSUPPORTED`.
|
|
22
|
+
*/
|
|
23
|
+
MASK_UNSUPPORTED: 422,
|
|
24
|
+
NOT_FOUND: 404,
|
|
25
|
+
NOT_IMPLEMENTED: 501,
|
|
26
|
+
/**
|
|
27
|
+
* A write policy's `when` returned a relation-crossing predicate
|
|
28
|
+
* (`some`/`none`/`every`/`is`/`isNot`). The in-memory write-policy evaluator
|
|
29
|
+
* has no child fetcher and cannot resolve a relation node, so the policy is
|
|
30
|
+
* unsupported as written. Relation predicates are valid in *read* policies
|
|
31
|
+
* and query `where` clauses (the pre-resolver handles them there). The
|
|
32
|
+
* request is well-formed; this is a 422 (semantic conflict), mirroring the
|
|
33
|
+
* sibling `*_UNSUPPORTED` codes.
|
|
34
|
+
*/
|
|
35
|
+
RELATION_PREDICATE_UNSUPPORTED: 422,
|
|
36
|
+
TOO_MANY_REQUESTS: 429,
|
|
37
|
+
UNAUTHORIZED: 401,
|
|
38
|
+
UNPROCESSABLE: 422
|
|
39
|
+
};
|
|
40
|
+
class LunoraError extends Error {
|
|
41
|
+
name = "LunoraError";
|
|
42
|
+
code;
|
|
43
|
+
status;
|
|
44
|
+
/** Structured, JSON+wire-encodable payload surfaced to the client alongside `code`. */
|
|
45
|
+
data;
|
|
46
|
+
constructor(code, message, data) {
|
|
47
|
+
super(message ?? code);
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.status = CODE_STATUS[code];
|
|
50
|
+
this.data = data;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { LunoraError };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { v } from '@lunora/values';
|
|
2
|
+
import { initLunora } from './initLunora-lxwHTEV3.mjs';
|
|
3
|
+
import { LunoraError } from './LunoraError-DN7Zhhvu.mjs';
|
|
4
|
+
import { onDisconnect } from './onConnect-CIPXKPyw.mjs';
|
|
5
|
+
import { defineSchemaExtension, defineComponent } from './composePluginMiddleware-Ck5_TUO8.mjs';
|
|
6
|
+
import { defineTable } from './defineAggregateIndex-ZdyU78gh.mjs';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_MS = 3e4;
|
|
9
|
+
const MAX_DATA_BYTES = 4096;
|
|
10
|
+
const PRESENCE_KEY = "presence";
|
|
11
|
+
const PRESENCE_BARE_TABLE = "present";
|
|
12
|
+
const PRESENCE_TABLE = `${PRESENCE_KEY}_${PRESENCE_BARE_TABLE}`;
|
|
13
|
+
const presenceExtension = defineSchemaExtension(PRESENCE_KEY, {
|
|
14
|
+
tables: {
|
|
15
|
+
[PRESENCE_BARE_TABLE]: defineTable({
|
|
16
|
+
data: v.optional(v.record(v.string(), v.any())),
|
|
17
|
+
lastSeen: v.number(),
|
|
18
|
+
roomId: v.string(),
|
|
19
|
+
sessionId: v.string(),
|
|
20
|
+
userId: v.optional(v.string())
|
|
21
|
+
}).index("byRoomSession", ["roomId", "sessionId"]).index("byRoom", ["roomId"])
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const { mutation, query } = initLunora.dataModel().create();
|
|
25
|
+
const definePresence = (options = {}) => {
|
|
26
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
27
|
+
const disconnectGraceMs = Math.max(0, Math.min(options.disconnectGraceMs ?? 0, ttlMs));
|
|
28
|
+
const heartbeat = mutation.input({
|
|
29
|
+
data: v.optional(v.record(v.string(), v.any())),
|
|
30
|
+
roomId: v.string(),
|
|
31
|
+
sessionId: v.string()
|
|
32
|
+
}).mutation(async ({ args, ctx: context }) => {
|
|
33
|
+
const lastSeen = Date.now();
|
|
34
|
+
const userId = context.auth.userId ?? void 0;
|
|
35
|
+
if (args.data !== void 0 && JSON.stringify(args.data).length > MAX_DATA_BYTES) {
|
|
36
|
+
throw new LunoraError("BAD_REQUEST", `presence data exceeds the ${String(MAX_DATA_BYTES)}-byte limit`);
|
|
37
|
+
}
|
|
38
|
+
const existing = await context.db.query(PRESENCE_TABLE).withIndex("byRoomSession", (q) => q.eq("roomId", args.roomId).eq("sessionId", args.sessionId)).first();
|
|
39
|
+
if (existing && (existing["userId"] ?? void 0) !== userId) {
|
|
40
|
+
throw new LunoraError("FORBIDDEN", "presence heartbeat denied: this (roomId, sessionId) is held by another identity");
|
|
41
|
+
}
|
|
42
|
+
const row = {
|
|
43
|
+
lastSeen,
|
|
44
|
+
roomId: args.roomId,
|
|
45
|
+
sessionId: args.sessionId,
|
|
46
|
+
...args.data === void 0 ? {} : { data: args.data },
|
|
47
|
+
...userId === void 0 ? {} : { userId }
|
|
48
|
+
};
|
|
49
|
+
await (existing ? context.db.patch(existing["_id"], row) : context.db.insert(PRESENCE_TABLE, row));
|
|
50
|
+
return { lastSeen };
|
|
51
|
+
});
|
|
52
|
+
const listPresent = query.input({ roomId: v.string() }).query(async ({ args, ctx: context }) => {
|
|
53
|
+
const cutoff = Date.now() - ttlMs;
|
|
54
|
+
const rows = await context.db.query(PRESENCE_TABLE).withIndex("byRoom", (q) => q.eq("roomId", args.roomId)).collect();
|
|
55
|
+
const live = rows.filter((row) => row["lastSeen"] > cutoff).toSorted((a, b) => b["lastSeen"] - a["lastSeen"]);
|
|
56
|
+
const seenUsers = /* @__PURE__ */ new Set();
|
|
57
|
+
const members = [];
|
|
58
|
+
for (const row of live) {
|
|
59
|
+
const userId = row["userId"];
|
|
60
|
+
if (userId !== void 0) {
|
|
61
|
+
if (seenUsers.has(userId)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
seenUsers.add(userId);
|
|
65
|
+
}
|
|
66
|
+
const member = {
|
|
67
|
+
lastSeen: row["lastSeen"],
|
|
68
|
+
roomId: row["roomId"]
|
|
69
|
+
};
|
|
70
|
+
if (userId !== void 0) {
|
|
71
|
+
member.userId = userId;
|
|
72
|
+
}
|
|
73
|
+
if (row["data"] !== void 0) {
|
|
74
|
+
member.data = row["data"];
|
|
75
|
+
}
|
|
76
|
+
members.push(member);
|
|
77
|
+
}
|
|
78
|
+
return members;
|
|
79
|
+
});
|
|
80
|
+
const sweep = mutation.input({ roomId: v.string() }).mutation(async ({ args, ctx: context }) => {
|
|
81
|
+
const cutoff = Date.now() - ttlMs;
|
|
82
|
+
const stale = await context.db.query(PRESENCE_TABLE).withIndex("byRoom", (q) => q.eq("roomId", args.roomId)).filter((row) => row["lastSeen"] <= cutoff).collect();
|
|
83
|
+
await Promise.all(stale.map((row) => context.db.delete(row["_id"])));
|
|
84
|
+
return { deleted: stale.length };
|
|
85
|
+
});
|
|
86
|
+
const internalSweep = { ...sweep, visibility: "internal" };
|
|
87
|
+
const disconnect = onDisconnect(async (context, event) => {
|
|
88
|
+
const roomId = event.context?.["roomId"];
|
|
89
|
+
const sessionId = event.context?.["sessionId"];
|
|
90
|
+
if (typeof roomId !== "string" || typeof sessionId !== "string") {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const existing = await context.db.query(PRESENCE_TABLE).withIndex("byRoomSession", (q) => q.eq("roomId", roomId).eq("sessionId", sessionId)).first();
|
|
94
|
+
if (!existing) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const verifiedUserId = event.userId ?? void 0;
|
|
98
|
+
if ((existing["userId"] ?? void 0) !== verifiedUserId) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (disconnectGraceMs === 0) {
|
|
102
|
+
await context.db.delete(existing["_id"]);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const agedLastSeen = Math.min(existing["lastSeen"], Date.now() + disconnectGraceMs - ttlMs);
|
|
106
|
+
await context.db.patch(existing["_id"], { lastSeen: agedLastSeen });
|
|
107
|
+
});
|
|
108
|
+
return defineComponent(PRESENCE_KEY, {
|
|
109
|
+
extension: presenceExtension,
|
|
110
|
+
functions: { disconnect, heartbeat, listPresent, sweep: internalSweep }
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export { DEFAULT_TTL_MS as PRESENCE_DEFAULT_TTL_MS, PRESENCE_TABLE, definePresence, presenceExtension };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const asBucketStorage = (raw) => {
|
|
2
|
+
const candidate = raw ?? {};
|
|
3
|
+
if (typeof candidate.bucket === "function") {
|
|
4
|
+
return candidate;
|
|
5
|
+
}
|
|
6
|
+
const self = { ...candidate, bucketName: "default" };
|
|
7
|
+
self.bucket = () => self;
|
|
8
|
+
return self;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export { asBucketStorage as default };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const isUniqueConflict = (error) => typeof error === "object" && error !== null && error.code === "CONFLICT" && error.kind === "unique";
|
|
2
|
+
const buildUpsertWhere = (tableName, target, create) => {
|
|
3
|
+
const fields = typeof target === "string" ? [target] : target;
|
|
4
|
+
if (fields.length === 0) {
|
|
5
|
+
throw new Error(`ctx.db.${tableName}.upsert: "target" must name at least one field`);
|
|
6
|
+
}
|
|
7
|
+
const where = {};
|
|
8
|
+
for (const field of fields) {
|
|
9
|
+
if (!(field in create)) {
|
|
10
|
+
throw new Error(`ctx.db.${tableName}.upsert: target field "${field}" is missing from the create document`);
|
|
11
|
+
}
|
|
12
|
+
where[field] = create[field];
|
|
13
|
+
}
|
|
14
|
+
return where;
|
|
15
|
+
};
|
|
16
|
+
const bindTableFacade = (writer, tableName) => {
|
|
17
|
+
const insert = async (document, options) => {
|
|
18
|
+
if (options?.skipDuplicates !== true) {
|
|
19
|
+
return writer.insert(tableName, document);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return await writer.insert(tableName, document);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (isUniqueConflict(error)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const upsert = async ({ create, target, update }) => {
|
|
31
|
+
const where = buildUpsertWhere(tableName, target, create);
|
|
32
|
+
const existing = await writer.findFirst(tableName, { where });
|
|
33
|
+
if (existing && typeof existing["_id"] === "string") {
|
|
34
|
+
await writer.patch(existing["_id"], update ?? create, tableName);
|
|
35
|
+
return { created: false, id: existing["_id"] };
|
|
36
|
+
}
|
|
37
|
+
const id = await writer.insert(tableName, create);
|
|
38
|
+
return { created: true, id };
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
aggregate: (options) => writer.aggregate(tableName, options),
|
|
42
|
+
count: (where) => writer.count(tableName, where),
|
|
43
|
+
delete: (id) => writer.delete(id, tableName),
|
|
44
|
+
// `deleteMany`/`patchMany` forward the bound `tableName` as `expectedTable`
|
|
45
|
+
// (threaded through the writer + the RLS middleware's per-id gate) so every
|
|
46
|
+
// batched id is scoped to this table — the same IDOR guard the single-row
|
|
47
|
+
// `delete`/`patch` apply. `patchMany` maps the facade's `values` payload to
|
|
48
|
+
// the writer's `{ id, patch }` shape.
|
|
49
|
+
deleteMany: (ids, options) => {
|
|
50
|
+
if (writer.deleteMany === void 0) {
|
|
51
|
+
throw new Error(`ctx.db.${tableName}.deleteMany is unavailable: this writer has no batch delete`);
|
|
52
|
+
}
|
|
53
|
+
return writer.deleteMany(ids, options, tableName);
|
|
54
|
+
},
|
|
55
|
+
// `exists` reuses `findFirst` (RLS-filtered, indexed when a `.withIndex`-able
|
|
56
|
+
// `where` is supplied) and only asks whether a row came back — no count scan.
|
|
57
|
+
exists: async (where) => await writer.findFirst(tableName, where === void 0 ? void 0 : { where }) !== null,
|
|
58
|
+
findFirst: (args) => writer.findFirst(tableName, args),
|
|
59
|
+
findFirstOrThrow: (args) => writer.findFirstOrThrow(tableName, args),
|
|
60
|
+
findMany: (args) => writer.findMany(tableName, args),
|
|
61
|
+
get: (id) => writer.get(id, tableName),
|
|
62
|
+
groupBy: (options) => writer.groupBy(tableName, options),
|
|
63
|
+
// Physical removal — bypasses `.softDelete()`. RLS gates it as a delete.
|
|
64
|
+
hardDelete: (id) => writer.delete(id, tableName, { hard: true }),
|
|
65
|
+
insert,
|
|
66
|
+
insertMany: (documents, options) => {
|
|
67
|
+
if (writer.insertMany === void 0) {
|
|
68
|
+
throw new Error(`ctx.db.${tableName}.insertMany is unavailable: this writer has no batch insert`);
|
|
69
|
+
}
|
|
70
|
+
return writer.insertMany(tableName, documents, options);
|
|
71
|
+
},
|
|
72
|
+
patch: (id, patch) => writer.patch(id, patch, tableName),
|
|
73
|
+
patchMany: (patches, options) => {
|
|
74
|
+
if (writer.patchMany === void 0) {
|
|
75
|
+
throw new Error(`ctx.db.${tableName}.patchMany is unavailable: this writer has no batch patch`);
|
|
76
|
+
}
|
|
77
|
+
return writer.patchMany(
|
|
78
|
+
patches.map((entry) => {
|
|
79
|
+
return { id: entry.id, patch: entry.values };
|
|
80
|
+
}),
|
|
81
|
+
options,
|
|
82
|
+
tableName
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
rank: (indexName, options) => writer.rank(tableName, indexName, options),
|
|
86
|
+
rankPage: (indexName, options) => writer.rankPage(tableName, indexName, options),
|
|
87
|
+
replace: (id, document) => writer.replace(id, document, tableName),
|
|
88
|
+
restore: async (id) => {
|
|
89
|
+
if (!writer.restore) {
|
|
90
|
+
throw new Error(`ctx.db.${tableName}.restore is unavailable: this writer has no restore (is the table .softDelete()?)`);
|
|
91
|
+
}
|
|
92
|
+
await writer.restore(id, tableName);
|
|
93
|
+
},
|
|
94
|
+
upsert,
|
|
95
|
+
upsertMany: async ({ rows, target }) => {
|
|
96
|
+
const results = [];
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
results.push(await upsert({ create: row.create, target, update: row.update }));
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
},
|
|
102
|
+
withSearchIndex: (indexName, search) => writer.query(tableName).withSearchIndex(indexName, search)
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
const bindOrm = (facade) => {
|
|
106
|
+
const resolve = (table) => {
|
|
107
|
+
const bound = facade[table];
|
|
108
|
+
if (!bound) {
|
|
109
|
+
throw new Error(`unknown table: ${table}`);
|
|
110
|
+
}
|
|
111
|
+
return bound;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
delete: (table, id) => resolve(table).delete(id),
|
|
115
|
+
insert: (table) => {
|
|
116
|
+
return { values: (document) => resolve(table).insert(document) };
|
|
117
|
+
},
|
|
118
|
+
query: facade,
|
|
119
|
+
replace: (table, id) => {
|
|
120
|
+
return { with: (document) => resolve(table).replace(id, document) };
|
|
121
|
+
},
|
|
122
|
+
update: (table, id) => {
|
|
123
|
+
return { set: (values) => resolve(table).patch(id, values) };
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export { bindOrm, bindTableFacade };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { indexRolePermissions, computeReadBaseWhere, permissionName } from './rls-2Jhd0uev.mjs';
|
|
2
|
+
import { r as readRlsTag } from './policy-tag-DvpVH2tv.mjs';
|
|
3
|
+
|
|
4
|
+
const FALSE_PREDICATE = { OR: [] };
|
|
5
|
+
const readEntryTags = (entry) => {
|
|
6
|
+
const hoisted = entry?.rls?.tags;
|
|
7
|
+
if (hoisted) {
|
|
8
|
+
return hoisted;
|
|
9
|
+
}
|
|
10
|
+
const tag = readRlsTag(entry);
|
|
11
|
+
return tag ? [tag] : [];
|
|
12
|
+
};
|
|
13
|
+
const addTagToRegistry = (byTable, tag) => {
|
|
14
|
+
const rolePermissions = indexRolePermissions(tag.roles);
|
|
15
|
+
const seenWhenByTable = /* @__PURE__ */ new Map();
|
|
16
|
+
const policiesByTable = /* @__PURE__ */ new Map();
|
|
17
|
+
for (const policy of tag.policies) {
|
|
18
|
+
if (policy.on !== "read") {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const seen = seenWhenByTable.get(policy.table) ?? /* @__PURE__ */ new Set();
|
|
22
|
+
if (seen.has(policy.when)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
seen.add(policy.when);
|
|
26
|
+
seenWhenByTable.set(policy.table, seen);
|
|
27
|
+
const list = policiesByTable.get(policy.table) ?? [];
|
|
28
|
+
list.push(policy);
|
|
29
|
+
policiesByTable.set(policy.table, list);
|
|
30
|
+
}
|
|
31
|
+
for (const [table, policies] of policiesByTable) {
|
|
32
|
+
const groups = byTable.get(table) ?? [];
|
|
33
|
+
groups.push({ policies, rolePermissions });
|
|
34
|
+
byTable.set(table, groups);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const isFalsePredicate = (where) => {
|
|
38
|
+
const or = where.OR;
|
|
39
|
+
return Array.isArray(or) && or.length === 0 && Object.keys(where).length === 1;
|
|
40
|
+
};
|
|
41
|
+
const andMerge = (injected, caller) => {
|
|
42
|
+
if (!injected || Object.keys(injected).length === 0) {
|
|
43
|
+
return caller;
|
|
44
|
+
}
|
|
45
|
+
if (isFalsePredicate(injected)) {
|
|
46
|
+
return injected;
|
|
47
|
+
}
|
|
48
|
+
if (Object.keys(caller).length === 0) {
|
|
49
|
+
return injected;
|
|
50
|
+
}
|
|
51
|
+
return { AND: [injected, caller] };
|
|
52
|
+
};
|
|
53
|
+
const evaluateGroupBaseWhere = (group, request) => {
|
|
54
|
+
const granted = /* @__PURE__ */ new Set();
|
|
55
|
+
for (const roleName of request.roles) {
|
|
56
|
+
for (const name of group.rolePermissions.get(roleName) ?? []) {
|
|
57
|
+
granted.add(name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return computeReadBaseWhere(group.policies, {
|
|
61
|
+
auth: {
|
|
62
|
+
can: (permission) => granted.has(permissionName(permission)),
|
|
63
|
+
identity: request.identity,
|
|
64
|
+
roles: request.roles,
|
|
65
|
+
userId: request.userId
|
|
66
|
+
},
|
|
67
|
+
ctx: request.ctx
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const resolveReadBaseWhere = (registry, request) => {
|
|
71
|
+
const groups = registry.byTable.get(request.table);
|
|
72
|
+
if (!groups || groups.length === 0) {
|
|
73
|
+
return request.rlsRequired && !request.tablePublic ? FALSE_PREDICATE : void 0;
|
|
74
|
+
}
|
|
75
|
+
const predicates = [];
|
|
76
|
+
for (const group of groups) {
|
|
77
|
+
const base = evaluateGroupBaseWhere(group, request);
|
|
78
|
+
if (base === void 0) {
|
|
79
|
+
return void 0;
|
|
80
|
+
}
|
|
81
|
+
if (isFalsePredicate(base)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
predicates.push(base);
|
|
85
|
+
}
|
|
86
|
+
if (predicates.length === 0) {
|
|
87
|
+
return FALSE_PREDICATE;
|
|
88
|
+
}
|
|
89
|
+
return predicates.length === 1 ? predicates[0] : { OR: predicates };
|
|
90
|
+
};
|
|
91
|
+
const buildRlsReadRegistry = (functions) => {
|
|
92
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
93
|
+
const seenTags = /* @__PURE__ */ new Set();
|
|
94
|
+
for (const entry of functions) {
|
|
95
|
+
for (const tag of readEntryTags(entry)) {
|
|
96
|
+
if (seenTags.has(tag)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
seenTags.add(tag);
|
|
100
|
+
addTagToRegistry(byTable, tag);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { byTable };
|
|
104
|
+
};
|
|
105
|
+
const composeShapeReadWhere = (registry, request) => andMerge(resolveReadBaseWhere(registry, request), request.shapeWhere);
|
|
106
|
+
|
|
107
|
+
export { buildRlsReadRegistry, composeShapeReadWhere };
|