@lunora/runtime 1.0.0-alpha.3 → 1.0.0-alpha.30
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 +17 -0
- package/dist/index.d.mts +1855 -1100
- package/dist/index.d.ts +1855 -1100
- package/dist/index.mjs +10 -8
- package/dist/packem_shared/{DEFAULT_REGISTRY_CACHE_TTL_MS-BpCwo_mo.mjs → DEFAULT_REGISTRY_CACHE_TTL_MS-B3pA7aXp.mjs} +9 -5
- package/dist/packem_shared/LunoraError-Bpb9EFJ3.mjs +22 -0
- package/dist/packem_shared/NOOP_EXECUTION_CONTEXT-CCTu0Bf1.mjs +8 -0
- package/dist/packem_shared/analyticsEngineSink-DWUhiYHC.mjs +366 -0
- package/dist/packem_shared/applyJurisdiction-BkZtTkct.mjs +20 -0
- package/dist/packem_shared/composeIdentityResolvers-XGjO7V1J.mjs +55 -0
- package/dist/packem_shared/{composeWorker-CjF1xUIO.mjs → composeWorker-CqUlK17X.mjs} +979 -133
- package/dist/packem_shared/{createCrossShardRelationCapabilities-C0KOf7er.mjs → createCrossShardRelationCapabilities-CbcWjkAn.mjs} +4 -2
- package/dist/packem_shared/{createQueryCoordinator-DbxC7iUz.mjs → createQueryCoordinator-DNCJzOZE.mjs} +12 -3
- package/dist/packem_shared/{decorateResponse-DbISh_Wi.mjs → decorateResponse-DRWQFNhF.mjs} +50 -10
- package/dist/packem_shared/otlp-DOLuy1Aj.mjs +95 -0
- package/package.json +5 -2
- package/dist/packem_shared/LunoraError-CL0aOtpo.mjs +0 -46
- package/dist/packem_shared/analyticsEngineSink-DqEvrQs0.mjs +0 -141
- package/dist/packem_shared/resolveShard-DDkzWtrU.mjs +0 -9
|
@@ -1,10 +1,100 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isLunoraError, toErrorBody } from '@lunora/errors';
|
|
2
|
+
import { NOOP_EXECUTION_CONTEXT } from './NOOP_EXECUTION_CONTEXT-CCTu0Bf1.mjs';
|
|
3
|
+
import { o as otlpRandomHex, b as buildTraceparent } from './otlp-DOLuy1Aj.mjs';
|
|
4
|
+
import { LunoraError, toErrorResponse } from './LunoraError-Bpb9EFJ3.mjs';
|
|
5
|
+
import { wrapResolverWithContract } from './composeIdentityResolvers-XGjO7V1J.mjs';
|
|
6
|
+
export { composeIdentityResolvers, routeIdentityResolvers } from './composeIdentityResolvers-XGjO7V1J.mjs';
|
|
2
7
|
import { emitRpcEvent } from './emitLogEvent-pEdtqAK8.mjs';
|
|
3
|
-
import { resolveShard } from './
|
|
4
|
-
import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse } from './decorateResponse-
|
|
8
|
+
import { resolveShard, applyJurisdiction } from './applyJurisdiction-BkZtTkct.mjs';
|
|
9
|
+
import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse, enforceWebSocketOrigin } from './decorateResponse-DRWQFNhF.mjs';
|
|
10
|
+
|
|
11
|
+
const evictOldestEntry = (map, capacity) => {
|
|
12
|
+
if (map.size < capacity) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const oldest = map.keys().next().value;
|
|
16
|
+
if (oldest !== void 0) {
|
|
17
|
+
map.delete(oldest);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const RELAY_NAME_INFIX = "::relay::";
|
|
22
|
+
const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
|
|
23
|
+
|
|
24
|
+
const textEncoder = new TextEncoder();
|
|
25
|
+
const toBase64Url = (bytes) => {
|
|
26
|
+
const binary = String.fromCodePoint(...bytes);
|
|
27
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
28
|
+
};
|
|
29
|
+
const fromBase64Url = (input) => {
|
|
30
|
+
const padded = input.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((input.length + 3) % 4);
|
|
31
|
+
const binary = atob(padded);
|
|
32
|
+
const bytes = new Uint8Array(binary.length);
|
|
33
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
34
|
+
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
35
|
+
}
|
|
36
|
+
return bytes;
|
|
37
|
+
};
|
|
38
|
+
const KEY_CACHE_MAX = 64;
|
|
39
|
+
const keyCache = /* @__PURE__ */ new Map();
|
|
40
|
+
const importHmacKey = async (secret) => {
|
|
41
|
+
const cached = keyCache.get(secret);
|
|
42
|
+
if (cached) {
|
|
43
|
+
return cached;
|
|
44
|
+
}
|
|
45
|
+
evictOldestEntry(keyCache, KEY_CACHE_MAX);
|
|
46
|
+
const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
|
|
47
|
+
keyCache.set(secret, keyPromise);
|
|
48
|
+
return keyPromise;
|
|
49
|
+
};
|
|
50
|
+
const signCanonical = async (secret, canonical) => {
|
|
51
|
+
const cryptoKey = await importHmacKey(secret);
|
|
52
|
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(canonical));
|
|
53
|
+
return toBase64Url(new Uint8Array(signature));
|
|
54
|
+
};
|
|
55
|
+
const verifyCanonical = async (secret, canonical, sigBytes) => {
|
|
56
|
+
const cryptoKey = await importHmacKey(secret);
|
|
57
|
+
return crypto.subtle.verify("HMAC", cryptoKey, sigBytes, textEncoder.encode(canonical));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const WS_ADMIN_TOKEN_VERSION = "v1";
|
|
61
|
+
const WS_ADMIN_TOKEN_TTL_MS = 6e4;
|
|
62
|
+
const mintWsAdminToken = async (secret, options = {}) => {
|
|
63
|
+
const expiresAtMs = (options.now ?? Date.now()) + (options.ttlMs ?? WS_ADMIN_TOKEN_TTL_MS);
|
|
64
|
+
const canonical = `${WS_ADMIN_TOKEN_VERSION}.${String(expiresAtMs)}`;
|
|
65
|
+
const signature = await signCanonical(secret, canonical);
|
|
66
|
+
return { expiresAtMs, token: `${canonical}.${signature}` };
|
|
67
|
+
};
|
|
68
|
+
const verifyWsAdminToken = async (secret, token, now = Date.now()) => {
|
|
69
|
+
if (secret.length === 0 || token.length === 0) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const parts = token.split(".");
|
|
73
|
+
if (parts.length !== 3) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const [version, expString, signature] = parts;
|
|
77
|
+
if (version !== WS_ADMIN_TOKEN_VERSION || signature.length === 0) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const expiresAtMs = Number(expString);
|
|
81
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
let signatureBytes;
|
|
85
|
+
try {
|
|
86
|
+
signatureBytes = fromBase64Url(signature);
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return verifyCanonical(secret, `${version}.${expString}`, signatureBytes);
|
|
91
|
+
};
|
|
5
92
|
|
|
6
93
|
const AUTH_BASE = "/_lunora/admin/auth";
|
|
7
94
|
const AUTH_ADMIN_ERROR_STATUS = {
|
|
95
|
+
INVITER_REQUIRED: 400,
|
|
96
|
+
ORG_SLUG_INVALID: 400,
|
|
97
|
+
ORG_SLUG_TAKEN: 409,
|
|
8
98
|
PASSWORD_TOO_LONG: 400,
|
|
9
99
|
PASSWORD_TOO_SHORT: 400,
|
|
10
100
|
USER_ALREADY_EXISTS: 409,
|
|
@@ -34,6 +124,23 @@ const parseRoleInput = (value) => {
|
|
|
34
124
|
return void 0;
|
|
35
125
|
};
|
|
36
126
|
const optionalBodyString = (body, field) => typeof body[field] === "string" ? body[field] : void 0;
|
|
127
|
+
const optionalBodyObject = (body, field) => {
|
|
128
|
+
const value = body[field];
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
130
|
+
};
|
|
131
|
+
const requirePermission = (body) => {
|
|
132
|
+
const value = body["permission"];
|
|
133
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
134
|
+
throw new LunoraError("`permission` object is required", { code: "BAD_REQUEST", status: 400 });
|
|
135
|
+
}
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const [resource, actions] of Object.entries(value)) {
|
|
138
|
+
if (Array.isArray(actions) && actions.every((action) => typeof action === "string")) {
|
|
139
|
+
out[resource] = actions;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
37
144
|
const AUTH_ROUTES = {
|
|
38
145
|
[`${AUTH_BASE}/capabilities`]: {
|
|
39
146
|
build: () => {
|
|
@@ -100,6 +207,34 @@ const AUTH_ROUTES = {
|
|
|
100
207
|
http: "GET",
|
|
101
208
|
method: "listInvitations"
|
|
102
209
|
},
|
|
210
|
+
[`${AUTH_BASE}/config`]: {
|
|
211
|
+
build: () => {
|
|
212
|
+
return {};
|
|
213
|
+
},
|
|
214
|
+
http: "GET",
|
|
215
|
+
method: "config"
|
|
216
|
+
},
|
|
217
|
+
[`${AUTH_BASE}/organizations/teams`]: {
|
|
218
|
+
build: ({ paging, query }) => {
|
|
219
|
+
return { ...paging, organizationId: requireQuery$1(query, "organizationId") };
|
|
220
|
+
},
|
|
221
|
+
http: "GET",
|
|
222
|
+
method: "listTeams"
|
|
223
|
+
},
|
|
224
|
+
[`${AUTH_BASE}/organizations/teams/members`]: {
|
|
225
|
+
build: ({ paging, query }) => {
|
|
226
|
+
return { ...paging, teamId: requireQuery$1(query, "teamId") };
|
|
227
|
+
},
|
|
228
|
+
http: "GET",
|
|
229
|
+
method: "listTeamMembers"
|
|
230
|
+
},
|
|
231
|
+
[`${AUTH_BASE}/organizations/roles`]: {
|
|
232
|
+
build: ({ paging, query }) => {
|
|
233
|
+
return { ...paging, organizationId: requireQuery$1(query, "organizationId") };
|
|
234
|
+
},
|
|
235
|
+
http: "GET",
|
|
236
|
+
method: "listOrgRoles"
|
|
237
|
+
},
|
|
103
238
|
// --- mutations (POST) -------------------------------------------------------
|
|
104
239
|
[`${AUTH_BASE}/users/create`]: {
|
|
105
240
|
build: ({ body }) => {
|
|
@@ -232,6 +367,137 @@ const AUTH_ROUTES = {
|
|
|
232
367
|
http: "POST",
|
|
233
368
|
method: "cancelInvitation",
|
|
234
369
|
returns: "void"
|
|
370
|
+
},
|
|
371
|
+
[`${AUTH_BASE}/organizations/create`]: {
|
|
372
|
+
build: ({ body }) => {
|
|
373
|
+
return {
|
|
374
|
+
logo: optionalBodyString(body, "logo"),
|
|
375
|
+
metadata: optionalBodyObject(body, "metadata"),
|
|
376
|
+
name: requireBodyString(body, "name"),
|
|
377
|
+
ownerId: optionalBodyString(body, "ownerId"),
|
|
378
|
+
slug: optionalBodyString(body, "slug")
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
http: "POST",
|
|
382
|
+
method: "createOrganization"
|
|
383
|
+
},
|
|
384
|
+
[`${AUTH_BASE}/organizations/update`]: {
|
|
385
|
+
build: ({ body }) => {
|
|
386
|
+
return {
|
|
387
|
+
logo: optionalBodyString(body, "logo"),
|
|
388
|
+
metadata: optionalBodyObject(body, "metadata"),
|
|
389
|
+
name: optionalBodyString(body, "name"),
|
|
390
|
+
organizationId: requireBodyString(body, "organizationId"),
|
|
391
|
+
slug: optionalBodyString(body, "slug")
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
http: "POST",
|
|
395
|
+
method: "updateOrganization"
|
|
396
|
+
},
|
|
397
|
+
[`${AUTH_BASE}/organizations/remove`]: {
|
|
398
|
+
build: ({ body }) => {
|
|
399
|
+
return { organizationId: requireBodyString(body, "organizationId") };
|
|
400
|
+
},
|
|
401
|
+
http: "POST",
|
|
402
|
+
method: "deleteOrganization",
|
|
403
|
+
returns: "void"
|
|
404
|
+
},
|
|
405
|
+
[`${AUTH_BASE}/organizations/members/add`]: {
|
|
406
|
+
build: ({ body }) => {
|
|
407
|
+
return {
|
|
408
|
+
organizationId: requireBodyString(body, "organizationId"),
|
|
409
|
+
role: optionalBodyString(body, "role"),
|
|
410
|
+
userId: requireBodyString(body, "userId")
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
http: "POST",
|
|
414
|
+
method: "addMember"
|
|
415
|
+
},
|
|
416
|
+
[`${AUTH_BASE}/organizations/members/invite`]: {
|
|
417
|
+
build: ({ body }) => {
|
|
418
|
+
return {
|
|
419
|
+
email: requireBodyString(body, "email"),
|
|
420
|
+
inviterId: optionalBodyString(body, "inviterId"),
|
|
421
|
+
organizationId: requireBodyString(body, "organizationId"),
|
|
422
|
+
role: optionalBodyString(body, "role")
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
http: "POST",
|
|
426
|
+
method: "inviteMember"
|
|
427
|
+
},
|
|
428
|
+
[`${AUTH_BASE}/organizations/members/role`]: {
|
|
429
|
+
build: ({ body }) => {
|
|
430
|
+
const role = parseRoleInput(body["role"]);
|
|
431
|
+
if (role === void 0 || typeof role === "string" && role.trim() === "") {
|
|
432
|
+
throw new LunoraError("`role` is required", { code: "BAD_REQUEST", status: 400 });
|
|
433
|
+
}
|
|
434
|
+
return { memberId: requireBodyString(body, "memberId"), role };
|
|
435
|
+
},
|
|
436
|
+
http: "POST",
|
|
437
|
+
method: "updateMemberRole"
|
|
438
|
+
},
|
|
439
|
+
[`${AUTH_BASE}/organizations/teams/create`]: {
|
|
440
|
+
build: ({ body }) => {
|
|
441
|
+
return { name: requireBodyString(body, "name"), organizationId: requireBodyString(body, "organizationId") };
|
|
442
|
+
},
|
|
443
|
+
http: "POST",
|
|
444
|
+
method: "createTeam"
|
|
445
|
+
},
|
|
446
|
+
[`${AUTH_BASE}/organizations/teams/update`]: {
|
|
447
|
+
build: ({ body }) => {
|
|
448
|
+
return { name: requireBodyString(body, "name"), teamId: requireBodyString(body, "teamId") };
|
|
449
|
+
},
|
|
450
|
+
http: "POST",
|
|
451
|
+
method: "updateTeam"
|
|
452
|
+
},
|
|
453
|
+
[`${AUTH_BASE}/organizations/teams/remove`]: {
|
|
454
|
+
build: ({ body }) => {
|
|
455
|
+
return { teamId: requireBodyString(body, "teamId") };
|
|
456
|
+
},
|
|
457
|
+
http: "POST",
|
|
458
|
+
method: "removeTeam",
|
|
459
|
+
returns: "void"
|
|
460
|
+
},
|
|
461
|
+
[`${AUTH_BASE}/organizations/teams/members/add`]: {
|
|
462
|
+
build: ({ body }) => {
|
|
463
|
+
return { teamId: requireBodyString(body, "teamId"), userId: requireBodyString(body, "userId") };
|
|
464
|
+
},
|
|
465
|
+
http: "POST",
|
|
466
|
+
method: "addTeamMember"
|
|
467
|
+
},
|
|
468
|
+
[`${AUTH_BASE}/organizations/teams/members/remove`]: {
|
|
469
|
+
build: ({ body }) => {
|
|
470
|
+
return { teamMemberId: requireBodyString(body, "teamMemberId") };
|
|
471
|
+
},
|
|
472
|
+
http: "POST",
|
|
473
|
+
method: "removeTeamMember",
|
|
474
|
+
returns: "void"
|
|
475
|
+
},
|
|
476
|
+
[`${AUTH_BASE}/organizations/roles/create`]: {
|
|
477
|
+
build: ({ body }) => {
|
|
478
|
+
return {
|
|
479
|
+
organizationId: requireBodyString(body, "organizationId"),
|
|
480
|
+
permission: requirePermission(body),
|
|
481
|
+
role: requireBodyString(body, "role")
|
|
482
|
+
};
|
|
483
|
+
},
|
|
484
|
+
http: "POST",
|
|
485
|
+
method: "createOrgRole"
|
|
486
|
+
},
|
|
487
|
+
[`${AUTH_BASE}/organizations/roles/update`]: {
|
|
488
|
+
build: ({ body }) => {
|
|
489
|
+
return { permission: requirePermission(body), roleId: requireBodyString(body, "roleId") };
|
|
490
|
+
},
|
|
491
|
+
http: "POST",
|
|
492
|
+
method: "updateOrgRole"
|
|
493
|
+
},
|
|
494
|
+
[`${AUTH_BASE}/organizations/roles/remove`]: {
|
|
495
|
+
build: ({ body }) => {
|
|
496
|
+
return { roleId: requireBodyString(body, "roleId") };
|
|
497
|
+
},
|
|
498
|
+
http: "POST",
|
|
499
|
+
method: "deleteOrgRole",
|
|
500
|
+
returns: "void"
|
|
235
501
|
}
|
|
236
502
|
};
|
|
237
503
|
const buildAuthAdminRoutes = (deps) => {
|
|
@@ -244,15 +510,15 @@ const buildAuthAdminRoutes = (deps) => {
|
|
|
244
510
|
}
|
|
245
511
|
const candidate = error;
|
|
246
512
|
const code = typeof candidate.code === "string" ? candidate.code : "AUTH_ADMIN_ERROR";
|
|
247
|
-
|
|
248
|
-
throw new LunoraError(
|
|
513
|
+
console.error("[lunora] auth admin operation failed:", error);
|
|
514
|
+
throw new LunoraError("auth admin operation failed", { code, status: AUTH_ADMIN_ERROR_STATUS[code] ?? 500 });
|
|
249
515
|
}
|
|
250
516
|
};
|
|
251
517
|
const handle = async (request, descriptor) => {
|
|
518
|
+
deps.assertAdmin(request);
|
|
252
519
|
if (request.method !== descriptor.http) {
|
|
253
520
|
throw new LunoraError(`Auth admin endpoint requires ${descriptor.http}`, { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
254
521
|
}
|
|
255
|
-
deps.assertAdmin(request);
|
|
256
522
|
const admin = deps.getAuthAdmin();
|
|
257
523
|
if (admin === void 0) {
|
|
258
524
|
throw new LunoraError("auth endpoints require an `authAdmin` on the worker", { code: "AUTH_NOT_CONFIGURED", status: 400 });
|
|
@@ -278,6 +544,48 @@ const buildAuthAdminRoutes = (deps) => {
|
|
|
278
544
|
return routes;
|
|
279
545
|
};
|
|
280
546
|
|
|
547
|
+
const MAX_BATCH_ENTRIES = 500;
|
|
548
|
+
|
|
549
|
+
const normalizeBatchCall = (raw, index, defaultShard) => {
|
|
550
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
551
|
+
throw new LunoraError("each batch call must be an object", { code: "BAD_REQUEST", status: 400 });
|
|
552
|
+
}
|
|
553
|
+
const call = raw;
|
|
554
|
+
if (typeof call.functionPath !== "string") {
|
|
555
|
+
throw new LunoraError("each batch call needs a string `functionPath`", { code: "BAD_REQUEST", status: 400 });
|
|
556
|
+
}
|
|
557
|
+
if (call.functionPath.startsWith("__lunora_relation__:") || call.functionPath.startsWith("__lunora_admin__")) {
|
|
558
|
+
throw new LunoraError("reserved function path cannot be batched", { code: "FORBIDDEN", status: 403 });
|
|
559
|
+
}
|
|
560
|
+
if (call.args !== void 0 && (typeof call.args !== "object" || call.args === null || Array.isArray(call.args))) {
|
|
561
|
+
throw new LunoraError("each batch call `args` must be an object", { code: "BAD_REQUEST", status: 400 });
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
entry: {
|
|
565
|
+
args: call.args === void 0 ? {} : call.args,
|
|
566
|
+
clientId: typeof call.clientId === "string" ? call.clientId : void 0,
|
|
567
|
+
clientSeq: typeof call.clientSeq === "number" ? call.clientSeq : void 0,
|
|
568
|
+
functionPath: call.functionPath,
|
|
569
|
+
id: typeof call.id === "number" ? call.id : index,
|
|
570
|
+
mutationId: typeof call.mutationId === "string" ? call.mutationId : void 0
|
|
571
|
+
},
|
|
572
|
+
shardKey: typeof call.shardKey === "string" ? call.shardKey : defaultShard
|
|
573
|
+
};
|
|
574
|
+
};
|
|
575
|
+
const groupBatchCallsByShard = (calls, defaultShard) => {
|
|
576
|
+
if (calls.length > MAX_BATCH_ENTRIES) {
|
|
577
|
+
throw new LunoraError(`RPC batch exceeds the ${String(MAX_BATCH_ENTRIES)}-call limit`, { code: "BAD_REQUEST", status: 400 });
|
|
578
|
+
}
|
|
579
|
+
const groups = /* @__PURE__ */ new Map();
|
|
580
|
+
for (const [index, raw] of calls.entries()) {
|
|
581
|
+
const { entry, shardKey } = normalizeBatchCall(raw, index, defaultShard);
|
|
582
|
+
const group = groups.get(shardKey) ?? [];
|
|
583
|
+
group.push(entry);
|
|
584
|
+
groups.set(shardKey, group);
|
|
585
|
+
}
|
|
586
|
+
return groups;
|
|
587
|
+
};
|
|
588
|
+
|
|
281
589
|
const MAX_BODY_BYTES = 1048576;
|
|
282
590
|
const readBodyTextWithLimit = async (request, limit = MAX_BODY_BYTES) => {
|
|
283
591
|
if (!request.body) {
|
|
@@ -335,9 +643,9 @@ const readBodyBytesWithLimit = async (request, limit = MAX_BODY_BYTES) => {
|
|
|
335
643
|
}
|
|
336
644
|
return out.buffer;
|
|
337
645
|
};
|
|
338
|
-
const readJsonBodyWithLimit = async (request) => {
|
|
646
|
+
const readJsonBodyWithLimit = async (request, limit = MAX_BODY_BYTES) => {
|
|
339
647
|
try {
|
|
340
|
-
const text = await readBodyTextWithLimit(request);
|
|
648
|
+
const text = await readBodyTextWithLimit(request, limit);
|
|
341
649
|
return text === "" ? {} : JSON.parse(text);
|
|
342
650
|
} catch (error) {
|
|
343
651
|
if (error instanceof LunoraError) {
|
|
@@ -597,14 +905,14 @@ const partitionExportTables = (options, tables) => {
|
|
|
597
905
|
}
|
|
598
906
|
return { globalTables, shardLocalTables };
|
|
599
907
|
};
|
|
600
|
-
const exportShardLocalRows = async (options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow) => {
|
|
908
|
+
const exportShardLocalRows = async (options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow, namespace) => {
|
|
601
909
|
if (tables !== void 0 && shardLocalTables.length === 0) {
|
|
602
910
|
return;
|
|
603
911
|
}
|
|
604
912
|
const exportTables = tables === void 0 ? [] : shardLocalTables;
|
|
605
913
|
const probeFallback = tables === void 0 ? collectKnownTables() : [];
|
|
606
914
|
const probeTables = exportTables.length > 0 ? exportTables : probeFallback;
|
|
607
|
-
const result = await coordinator.orchestrateExport(
|
|
915
|
+
const result = await coordinator.orchestrateExport(namespace, {
|
|
608
916
|
args: { tables: exportTables },
|
|
609
917
|
headers: forwardedHeaders,
|
|
610
918
|
tables: probeTables
|
|
@@ -618,9 +926,9 @@ const exportShardLocalRows = async (options, coordinator, forwardedHeaders, tabl
|
|
|
618
926
|
}
|
|
619
927
|
}
|
|
620
928
|
};
|
|
621
|
-
const streamExportRows = async (options, coordinator, forwardedHeaders, tables, writeRow) => {
|
|
929
|
+
const streamExportRows = async (options, coordinator, forwardedHeaders, tables, writeRow, namespace) => {
|
|
622
930
|
const { globalTables, shardLocalTables } = partitionExportTables(options, tables);
|
|
623
|
-
await exportShardLocalRows(options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow);
|
|
931
|
+
await exportShardLocalRows(options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow, namespace);
|
|
624
932
|
const exportGlobalsFunction = options.exportGlobals;
|
|
625
933
|
const wantGlobals = tables === void 0 || globalTables.length > 0;
|
|
626
934
|
if (wantGlobals && exportGlobalsFunction) {
|
|
@@ -740,7 +1048,7 @@ const mergeImportResult = (totals, result) => {
|
|
|
740
1048
|
}
|
|
741
1049
|
totals.conflicts += result.conflicts;
|
|
742
1050
|
};
|
|
743
|
-
const streamingImport = async (request, options, forwardedHeaders) => {
|
|
1051
|
+
const streamingImport = async (request, options, forwardedHeaders, namespace) => {
|
|
744
1052
|
const defaultShard = options.defaultShardKey ?? "__root__";
|
|
745
1053
|
const { errors, globalRows, perShard } = await bucketImportStream(request, options, defaultShard);
|
|
746
1054
|
const totals = { conflicts: 0, errors, inserted: {} };
|
|
@@ -749,7 +1057,7 @@ const streamingImport = async (request, options, forwardedHeaders) => {
|
|
|
749
1057
|
if (!coordinator) {
|
|
750
1058
|
throw new LunoraError("Import endpoint requires a `queryCoordinator` on the worker", { code: "BAD_REQUEST", status: 400 });
|
|
751
1059
|
}
|
|
752
|
-
const result = await coordinator.orchestrateImport(
|
|
1060
|
+
const result = await coordinator.orchestrateImport(namespace, {
|
|
753
1061
|
batches: [...perShard.values()],
|
|
754
1062
|
headers: forwardedHeaders
|
|
755
1063
|
});
|
|
@@ -966,7 +1274,129 @@ const buildIntrospectionAdminRoutes = (deps) => {
|
|
|
966
1274
|
};
|
|
967
1275
|
};
|
|
968
1276
|
|
|
969
|
-
const
|
|
1277
|
+
const KV_NAMESPACES_PATH = "/_lunora/admin/kv/namespaces";
|
|
1278
|
+
const KV_KEYS_PATH = "/_lunora/admin/kv/keys";
|
|
1279
|
+
const KV_VALUE_PATH = "/_lunora/admin/kv/value";
|
|
1280
|
+
const KV_VALUE_MAX_BODY_BYTES = 32 * 1048576;
|
|
1281
|
+
const KV_MIN_EXPIRATION_SECONDS = 60;
|
|
1282
|
+
const buildKvAdminRoutes = (deps) => {
|
|
1283
|
+
const { readJsonBody, requireAdminOption } = deps;
|
|
1284
|
+
const gate = (request) => requireAdminOption(request, deps.kvIntrospector, {
|
|
1285
|
+
code: "KV_NOT_CONFIGURED",
|
|
1286
|
+
message: "KV endpoints require a `kvIntrospector` on the worker"
|
|
1287
|
+
});
|
|
1288
|
+
const ok = (payload) => Response.json(payload, { headers: { "content-type": "application/json" }, status: 200 });
|
|
1289
|
+
const requireNamespaceAndKey = (request, verb) => {
|
|
1290
|
+
const url = new URL(request.url);
|
|
1291
|
+
const namespace = url.searchParams.get("namespace") ?? "";
|
|
1292
|
+
const key = url.searchParams.get("key") ?? "";
|
|
1293
|
+
if (namespace === "") {
|
|
1294
|
+
throw new LunoraError(`KV-value ${verb} request requires a \`namespace\` query parameter`, { code: "BAD_REQUEST", status: 400 });
|
|
1295
|
+
}
|
|
1296
|
+
if (key === "") {
|
|
1297
|
+
throw new LunoraError(`KV-value ${verb} request requires a \`key\` query parameter`, { code: "BAD_REQUEST", status: 400 });
|
|
1298
|
+
}
|
|
1299
|
+
return { key, namespace };
|
|
1300
|
+
};
|
|
1301
|
+
const requireKnownNamespace = async (introspector, namespace) => {
|
|
1302
|
+
const namespaces = await introspector.listNamespaces();
|
|
1303
|
+
if (!namespaces.some((entry) => entry.binding === namespace)) {
|
|
1304
|
+
throw new LunoraError(`Unknown KV namespace binding \`${namespace}\``, { code: "NOT_FOUND", status: 404 });
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
const handleKvNamespaces = async (request) => {
|
|
1308
|
+
if (request.method !== "GET") {
|
|
1309
|
+
throw new LunoraError("KV-namespaces endpoint requires GET", { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
1310
|
+
}
|
|
1311
|
+
return ok({ namespaces: await gate(request).listNamespaces() });
|
|
1312
|
+
};
|
|
1313
|
+
const handleKvKeys = async (request) => {
|
|
1314
|
+
if (request.method !== "GET") {
|
|
1315
|
+
throw new LunoraError("KV-keys endpoint requires GET", { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
1316
|
+
}
|
|
1317
|
+
const introspector = gate(request);
|
|
1318
|
+
const url = new URL(request.url);
|
|
1319
|
+
const namespace = url.searchParams.get("namespace") ?? "";
|
|
1320
|
+
if (namespace === "") {
|
|
1321
|
+
throw new LunoraError("KV-keys request requires a `namespace` query parameter", { code: "BAD_REQUEST", status: 400 });
|
|
1322
|
+
}
|
|
1323
|
+
const prefix = url.searchParams.get("prefix") ?? void 0;
|
|
1324
|
+
const cursor = url.searchParams.get("cursor") ?? void 0;
|
|
1325
|
+
const limitRaw = url.searchParams.get("limit");
|
|
1326
|
+
const parsedLimit = limitRaw === null ? void 0 : Number.parseInt(limitRaw, 10);
|
|
1327
|
+
if (parsedLimit !== void 0 && (!Number.isInteger(parsedLimit) || parsedLimit < 1)) {
|
|
1328
|
+
throw new LunoraError("KV-keys `limit` must be a positive integer", { code: "BAD_REQUEST", status: 400 });
|
|
1329
|
+
}
|
|
1330
|
+
const limit = parsedLimit === void 0 ? void 0 : Math.min(parsedLimit, 1e3);
|
|
1331
|
+
await requireKnownNamespace(introspector, namespace);
|
|
1332
|
+
return ok(await introspector.listKeys({ cursor, limit, namespace, prefix }));
|
|
1333
|
+
};
|
|
1334
|
+
const handleKvValueGet = async (request) => {
|
|
1335
|
+
const introspector = gate(request);
|
|
1336
|
+
const params = requireNamespaceAndKey(request, "GET");
|
|
1337
|
+
await requireKnownNamespace(introspector, params.namespace);
|
|
1338
|
+
return ok(await introspector.getValue(params));
|
|
1339
|
+
};
|
|
1340
|
+
const handleKvValuePut = async (request) => {
|
|
1341
|
+
const introspector = gate(request);
|
|
1342
|
+
const candidate = await readJsonBody(request, KV_VALUE_MAX_BODY_BYTES);
|
|
1343
|
+
if (typeof candidate.namespace !== "string" || candidate.namespace === "") {
|
|
1344
|
+
throw new LunoraError("KV-value PUT request requires a `namespace` string", { code: "BAD_REQUEST", status: 400 });
|
|
1345
|
+
}
|
|
1346
|
+
if (typeof candidate.key !== "string" || candidate.key === "") {
|
|
1347
|
+
throw new LunoraError("KV-value PUT request requires a `key` string", { code: "BAD_REQUEST", status: 400 });
|
|
1348
|
+
}
|
|
1349
|
+
if (typeof candidate.value !== "string") {
|
|
1350
|
+
throw new LunoraError("KV-value PUT request requires a `value` string", { code: "BAD_REQUEST", status: 400 });
|
|
1351
|
+
}
|
|
1352
|
+
if (candidate.expirationTtl !== void 0 && (typeof candidate.expirationTtl !== "number" || !Number.isInteger(candidate.expirationTtl) || candidate.expirationTtl < KV_MIN_EXPIRATION_SECONDS)) {
|
|
1353
|
+
throw new LunoraError("KV-value PUT `expirationTtl` must be an integer ≥ 60", { code: "BAD_REQUEST", status: 400 });
|
|
1354
|
+
}
|
|
1355
|
+
const minExpiration = Math.floor(Date.now() / 1e3) + KV_MIN_EXPIRATION_SECONDS;
|
|
1356
|
+
if (candidate.expiration !== void 0 && (typeof candidate.expiration !== "number" || !Number.isInteger(candidate.expiration) || candidate.expiration < minExpiration)) {
|
|
1357
|
+
throw new LunoraError("KV-value PUT `expiration` must be a Unix-seconds timestamp at least 60 seconds in the future", {
|
|
1358
|
+
code: "BAD_REQUEST",
|
|
1359
|
+
status: 400
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
await requireKnownNamespace(introspector, candidate.namespace);
|
|
1363
|
+
await introspector.putValue({
|
|
1364
|
+
expiration: candidate.expiration,
|
|
1365
|
+
expirationTtl: candidate.expirationTtl,
|
|
1366
|
+
key: candidate.key,
|
|
1367
|
+
metadata: candidate.metadata,
|
|
1368
|
+
namespace: candidate.namespace,
|
|
1369
|
+
value: candidate.value
|
|
1370
|
+
});
|
|
1371
|
+
return ok({ ok: true });
|
|
1372
|
+
};
|
|
1373
|
+
const handleKvValueDelete = async (request) => {
|
|
1374
|
+
const introspector = gate(request);
|
|
1375
|
+
const params = requireNamespaceAndKey(request, "DELETE");
|
|
1376
|
+
await requireKnownNamespace(introspector, params.namespace);
|
|
1377
|
+
await introspector.deleteKey(params);
|
|
1378
|
+
return ok({ deleted: true });
|
|
1379
|
+
};
|
|
1380
|
+
const kvValueHandlers = {
|
|
1381
|
+
DELETE: handleKvValueDelete,
|
|
1382
|
+
GET: handleKvValueGet,
|
|
1383
|
+
PUT: handleKvValuePut
|
|
1384
|
+
};
|
|
1385
|
+
const handleKvValue = (request) => {
|
|
1386
|
+
const handler = kvValueHandlers[request.method];
|
|
1387
|
+
if (!handler) {
|
|
1388
|
+
throw new LunoraError("KV-value endpoint requires GET, PUT, or DELETE", { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
1389
|
+
}
|
|
1390
|
+
return handler(request);
|
|
1391
|
+
};
|
|
1392
|
+
return {
|
|
1393
|
+
[KV_NAMESPACES_PATH]: handleKvNamespaces,
|
|
1394
|
+
[KV_KEYS_PATH]: handleKvKeys,
|
|
1395
|
+
[KV_VALUE_PATH]: handleKvValue
|
|
1396
|
+
};
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
const MIGRATE_PATH$1 = "/_lunora/migrate";
|
|
970
1400
|
const PITR_PATH = "/_lunora/admin/pitr";
|
|
971
1401
|
const RANK_PATH = "/_lunora/admin/rank";
|
|
972
1402
|
const RANKPAGE_PATH = "/_lunora/admin/rankpage";
|
|
@@ -1223,7 +1653,7 @@ const buildOrchestrationAdminRoutes = (deps) => {
|
|
|
1223
1653
|
return forwardToShard(shardDO, pitr.shardKey ?? defaultShard, forwarded);
|
|
1224
1654
|
};
|
|
1225
1655
|
return {
|
|
1226
|
-
[MIGRATE_PATH]: handleMigrate,
|
|
1656
|
+
[MIGRATE_PATH$1]: handleMigrate,
|
|
1227
1657
|
[PITR_PATH]: handlePitr,
|
|
1228
1658
|
[RANK_PATH]: handleRank,
|
|
1229
1659
|
[RANKPAGE_PATH]: handleRankPage,
|
|
@@ -1254,11 +1684,11 @@ const buildScheduledAdminRoutes = (deps) => {
|
|
|
1254
1684
|
const stub = resolveSchedulerStub(request);
|
|
1255
1685
|
return stub.fetch(new Request("https://scheduler.internal/status", { method: "GET" }));
|
|
1256
1686
|
};
|
|
1257
|
-
const handleScheduledWebSocket = (request) => {
|
|
1687
|
+
const handleScheduledWebSocket = async (request) => {
|
|
1258
1688
|
if (request.headers.get("Upgrade") !== "websocket") {
|
|
1259
1689
|
throw new LunoraError("WebSocket upgrade header missing", { code: "BAD_REQUEST", status: 426 });
|
|
1260
1690
|
}
|
|
1261
|
-
if (!checkWsAdmin(request)) {
|
|
1691
|
+
if (!await checkWsAdmin(request)) {
|
|
1262
1692
|
throw new LunoraError("admin authorization required", { code: "ADMIN_FORBIDDEN", status: 403 });
|
|
1263
1693
|
}
|
|
1264
1694
|
const namespace = requireSchedulerNamespace();
|
|
@@ -1502,7 +1932,7 @@ const buildWorkflowsAdminRoutes = (deps) => {
|
|
|
1502
1932
|
assertAdmin(request);
|
|
1503
1933
|
const client = resolveWorkflowsClient(env);
|
|
1504
1934
|
if (!client) {
|
|
1505
|
-
return
|
|
1935
|
+
return Response.json({ configured: false, instances: [], page: 1, perPage: 0, totalCount: 0 });
|
|
1506
1936
|
}
|
|
1507
1937
|
const workflowName = requireQuery(url, "name");
|
|
1508
1938
|
const status = toInstanceStatus(url.searchParams.get("status"));
|
|
@@ -1554,9 +1984,28 @@ const buildWorkflowsAdminRoutes = (deps) => {
|
|
|
1554
1984
|
|
|
1555
1985
|
const NDJSON_ENCODER = new TextEncoder();
|
|
1556
1986
|
const RPC_PATH = "/_lunora/rpc";
|
|
1987
|
+
const RPC_BATCH_PATH = "/_lunora/rpc-batch";
|
|
1557
1988
|
const WS_PATH = "/_lunora/ws";
|
|
1989
|
+
const VOICE_PATH_PREFIX = "/_lunora/voice/";
|
|
1558
1990
|
const SCHEDULER_DISPATCH_PATH = "/_lunora/scheduler/dispatch";
|
|
1559
1991
|
const CRON_JOBS_RUN_PATH = "/_lunora/admin/cron-jobs/run";
|
|
1992
|
+
const ADMIN_WS_TOKEN_PATH = "/_lunora/admin/ws-token";
|
|
1993
|
+
const ADMIN_PATH_PREFIX = "/_lunora/admin/";
|
|
1994
|
+
const MIGRATE_PATH = "/_lunora/migrate";
|
|
1995
|
+
const STATUS_PATH = "/_lunora/status";
|
|
1996
|
+
const isAdminPath = (pathname) => pathname.startsWith(ADMIN_PATH_PREFIX) || pathname === MIGRATE_PATH;
|
|
1997
|
+
const REQUIRE_EPHEMERAL_ENV_VALUES = /* @__PURE__ */ new Set(["1", "enabled", "on", "true", "yes"]);
|
|
1998
|
+
const readForwardedIdentity = (request) => {
|
|
1999
|
+
const forwardedUserId = request.headers.get("x-lunora-userid");
|
|
2000
|
+
const forwardedIdentity = request.headers.get("x-lunora-identity");
|
|
2001
|
+
if (forwardedUserId === null && forwardedIdentity === null) {
|
|
2002
|
+
return void 0;
|
|
2003
|
+
}
|
|
2004
|
+
return {
|
|
2005
|
+
...forwardedIdentity === null ? {} : { identity: forwardedIdentity },
|
|
2006
|
+
...forwardedUserId === null ? {} : { userId: forwardedUserId }
|
|
2007
|
+
};
|
|
2008
|
+
};
|
|
1560
2009
|
const DEFAULT_AUTH_BASE_PATH = "/api/auth";
|
|
1561
2010
|
const RECORD_AUTH_EVENT_OP = "__lunora_admin__:recordAuthEvent";
|
|
1562
2011
|
const AUTH_ATTEMPT_SEGMENTS = ["/sign-in", "/sign-up", "/callback"];
|
|
@@ -1569,7 +2018,7 @@ const isAuthAttemptPath = (pathname, basePath) => {
|
|
|
1569
2018
|
return AUTH_ATTEMPT_SEGMENTS.some((segment) => suffix === segment || suffix.startsWith(`${segment}/`));
|
|
1570
2019
|
};
|
|
1571
2020
|
const buildErrorEvent = (functionPath, durationMs, error, extra) => {
|
|
1572
|
-
const mappable =
|
|
2021
|
+
const mappable = isLunoraError(error);
|
|
1573
2022
|
const code = mappable ? error.code : "INTERNAL_SERVER_ERROR";
|
|
1574
2023
|
const status = mappable ? error.status : 500;
|
|
1575
2024
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1598,6 +2047,8 @@ const resolveForwardContext = async (request, env, resolveIdentity) => {
|
|
|
1598
2047
|
const cookie = request.headers.get("cookie");
|
|
1599
2048
|
const bookmark = request.headers.get("x-d1-bookmark");
|
|
1600
2049
|
const mutationId = request.headers.get("x-lunora-mutation-id");
|
|
2050
|
+
const clientId = request.headers.get("x-lunora-client-id");
|
|
2051
|
+
const clientSeq = request.headers.get("x-lunora-client-seq");
|
|
1601
2052
|
if (authorization) {
|
|
1602
2053
|
headers["authorization"] = authorization;
|
|
1603
2054
|
}
|
|
@@ -1610,6 +2061,12 @@ const resolveForwardContext = async (request, env, resolveIdentity) => {
|
|
|
1610
2061
|
if (mutationId) {
|
|
1611
2062
|
headers["x-lunora-mutation-id"] = mutationId;
|
|
1612
2063
|
}
|
|
2064
|
+
if (clientId) {
|
|
2065
|
+
headers["x-lunora-client-id"] = clientId;
|
|
2066
|
+
}
|
|
2067
|
+
if (clientSeq) {
|
|
2068
|
+
headers["x-lunora-client-seq"] = clientSeq;
|
|
2069
|
+
}
|
|
1613
2070
|
const clientIp = request.headers.get("cf-connecting-ip");
|
|
1614
2071
|
if (clientIp) {
|
|
1615
2072
|
headers["x-lunora-client-ip"] = clientIp;
|
|
@@ -1662,6 +2119,28 @@ const validateFanOut = (fanOut) => {
|
|
|
1662
2119
|
}
|
|
1663
2120
|
return spec;
|
|
1664
2121
|
};
|
|
2122
|
+
const logRpcDebug = (env, envelope) => {
|
|
2123
|
+
if (!env?.LUNORA_DEBUG_RPC) {
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
console.warn(`[lunora:rpc] ${envelope.fanOut ? "fan-out" : `shard=${envelope.shardKey ?? "(root)"}`} ${envelope.functionPath}`);
|
|
2127
|
+
};
|
|
2128
|
+
const resolveX402Charge = (envelope, options) => {
|
|
2129
|
+
const x402Tag = options.functions?.[envelope.functionPath]?.x402;
|
|
2130
|
+
if (!x402Tag) {
|
|
2131
|
+
return void 0;
|
|
2132
|
+
}
|
|
2133
|
+
if (envelope.fanOut) {
|
|
2134
|
+
throw new LunoraError("a paid (`.x402`) function cannot be fanned out", { code: "BAD_REQUEST", status: 400 });
|
|
2135
|
+
}
|
|
2136
|
+
if (!options.x402Charge) {
|
|
2137
|
+
throw new LunoraError(`function "${envelope.functionPath}" is marked paid (.x402) but no x402Charge gate is configured on the worker`, {
|
|
2138
|
+
code: "MISCONFIGURED",
|
|
2139
|
+
status: 500
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
return x402Tag;
|
|
2143
|
+
};
|
|
1665
2144
|
const parseEnvelope = async (request) => {
|
|
1666
2145
|
const text = await readBodyTextWithLimit(request);
|
|
1667
2146
|
let body;
|
|
@@ -1681,9 +2160,18 @@ const parseEnvelope = async (request) => {
|
|
|
1681
2160
|
throw new LunoraError("RPC `shardKey` must be a string", { code: "BAD_REQUEST", status: 400 });
|
|
1682
2161
|
}
|
|
1683
2162
|
const envelope = body;
|
|
2163
|
+
const fanOut = validateFanOut(envelope.fanOut);
|
|
2164
|
+
const args = envelope.args ?? {};
|
|
2165
|
+
if (fanOut && envelope.functionPath.startsWith("__lunora_relation__:")) {
|
|
2166
|
+
const requestedTable = args.table;
|
|
2167
|
+
if (typeof requestedTable === "string" && requestedTable !== fanOut.table) {
|
|
2168
|
+
throw new LunoraError("RPC `args.table` must match the authorized `fanOut.table` for a relation fan-out", { code: "BAD_REQUEST", status: 400 });
|
|
2169
|
+
}
|
|
2170
|
+
args.table = fanOut.table;
|
|
2171
|
+
}
|
|
1684
2172
|
return {
|
|
1685
|
-
args
|
|
1686
|
-
fanOut
|
|
2173
|
+
args,
|
|
2174
|
+
fanOut,
|
|
1687
2175
|
functionPath: envelope.functionPath,
|
|
1688
2176
|
shardKey: envelope.shardKey
|
|
1689
2177
|
};
|
|
@@ -1692,6 +2180,46 @@ const forwardToShard = async (namespace, shardKey, request) => {
|
|
|
1692
2180
|
const stub = resolveShard(namespace, shardKey);
|
|
1693
2181
|
return stub.fetch(request);
|
|
1694
2182
|
};
|
|
2183
|
+
const relayProbeCache = /* @__PURE__ */ new Map();
|
|
2184
|
+
const RELAY_PROBE_TTL_MS = 5e3;
|
|
2185
|
+
const RELAY_PROBE_MAX_ENTRIES = 4096;
|
|
2186
|
+
const probeRelayCount = async (namespace, shardKey) => {
|
|
2187
|
+
const now = Date.now();
|
|
2188
|
+
const cached = relayProbeCache.get(shardKey);
|
|
2189
|
+
if (cached !== void 0 && cached.expiresMs > now) {
|
|
2190
|
+
return cached.relayCount;
|
|
2191
|
+
}
|
|
2192
|
+
if (cached !== void 0) {
|
|
2193
|
+
relayProbeCache.delete(shardKey);
|
|
2194
|
+
}
|
|
2195
|
+
let relayCount = 0;
|
|
2196
|
+
try {
|
|
2197
|
+
const response = await resolveShard(namespace, shardKey).fetch(new Request("https://shard.internal/_lunora/route"));
|
|
2198
|
+
if (response.ok) {
|
|
2199
|
+
const body = await response.json();
|
|
2200
|
+
const reported = body.relayCount;
|
|
2201
|
+
if (typeof reported === "number" && reported > 0) {
|
|
2202
|
+
relayCount = Math.floor(reported);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
} catch {
|
|
2206
|
+
relayCount = 0;
|
|
2207
|
+
}
|
|
2208
|
+
evictOldestEntry(relayProbeCache, RELAY_PROBE_MAX_ENTRIES);
|
|
2209
|
+
relayProbeCache.set(shardKey, { expiresMs: now + RELAY_PROBE_TTL_MS, relayCount });
|
|
2210
|
+
return relayCount;
|
|
2211
|
+
};
|
|
2212
|
+
const resolveShardBindingName = (env, namespace) => {
|
|
2213
|
+
if (env === null || typeof env !== "object") {
|
|
2214
|
+
return void 0;
|
|
2215
|
+
}
|
|
2216
|
+
for (const [key, value] of Object.entries(env)) {
|
|
2217
|
+
if (value === namespace) {
|
|
2218
|
+
return key;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
return void 0;
|
|
2222
|
+
};
|
|
1695
2223
|
const constantTimeEqual = (expected, supplied) => {
|
|
1696
2224
|
const max = Math.max(expected.length, supplied.length);
|
|
1697
2225
|
let diff = expected.length ^ supplied.length;
|
|
@@ -1731,77 +2259,123 @@ const checkAdminAuth = (request, expected) => {
|
|
|
1731
2259
|
}
|
|
1732
2260
|
return constantTimeEqual(expected, rest.join(" ").trim());
|
|
1733
2261
|
};
|
|
1734
|
-
const checkAdminWsToken = (request, expected) => {
|
|
2262
|
+
const checkAdminWsToken = async (request, expected, requireEphemeral) => {
|
|
1735
2263
|
if (!expected || expected.length === 0) {
|
|
1736
2264
|
return false;
|
|
1737
2265
|
}
|
|
1738
2266
|
const supplied = new URL(request.url).searchParams.get("token");
|
|
1739
|
-
|
|
2267
|
+
if (supplied === null) {
|
|
2268
|
+
return false;
|
|
2269
|
+
}
|
|
2270
|
+
if (await verifyWsAdminToken(expected, supplied)) {
|
|
2271
|
+
return true;
|
|
2272
|
+
}
|
|
2273
|
+
if (requireEphemeral) {
|
|
2274
|
+
return false;
|
|
2275
|
+
}
|
|
2276
|
+
return constantTimeEqual(expected, supplied);
|
|
1740
2277
|
};
|
|
1741
2278
|
const createWorker = (options) => {
|
|
1742
2279
|
const defaultShard = options.defaultShardKey ?? "__root__";
|
|
2280
|
+
const publicResolveIdentity = wrapResolverWithContract(options.resolveIdentity, options.identity);
|
|
2281
|
+
const shardDO = applyJurisdiction(options.shardDO, options.jurisdiction);
|
|
2282
|
+
const schedulerDO = options.schedulerDO === void 0 ? void 0 : applyJurisdiction(options.schedulerDO, options.jurisdiction);
|
|
1743
2283
|
let envAdminToken;
|
|
1744
2284
|
const effectiveAdminToken = () => options.adminToken ?? envAdminToken;
|
|
2285
|
+
let envRequireEphemeralWsToken;
|
|
2286
|
+
const effectiveRequireEphemeralWsToken = () => options.requireEphemeralWsToken ?? envRequireEphemeralWsToken ?? false;
|
|
1745
2287
|
const resolveAdminTokenFromEnv = (env) => {
|
|
2288
|
+
const record = env ?? {};
|
|
2289
|
+
if (envRequireEphemeralWsToken === void 0 && options.requireEphemeralWsToken === void 0) {
|
|
2290
|
+
const raw = record["LUNORA_REQUIRE_EPHEMERAL_WS_TOKEN"];
|
|
2291
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
2292
|
+
envRequireEphemeralWsToken = REQUIRE_EPHEMERAL_ENV_VALUES.has(raw.trim().toLowerCase());
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
1746
2295
|
if (envAdminToken !== void 0 || options.adminToken !== void 0) {
|
|
1747
2296
|
return;
|
|
1748
2297
|
}
|
|
1749
|
-
const value =
|
|
2298
|
+
const value = record["LUNORA_ADMIN_TOKEN"];
|
|
1750
2299
|
if (typeof value === "string" && value.length > 0) {
|
|
1751
2300
|
envAdminToken = value;
|
|
1752
2301
|
}
|
|
1753
2302
|
};
|
|
1754
|
-
const
|
|
2303
|
+
const accessAdminGrants = /* @__PURE__ */ new WeakSet();
|
|
2304
|
+
const requestIsAdmin = (request) => checkAdminAuth(request, effectiveAdminToken()) || accessAdminGrants.has(request);
|
|
2305
|
+
const resolveAdminForwardContext = async (request, env) => {
|
|
2306
|
+
const context = await resolveForwardContext(request, env, options.resolveIdentity);
|
|
2307
|
+
if (accessAdminGrants.has(request) && context.headers["authorization"] === void 0) {
|
|
2308
|
+
const token = effectiveAdminToken();
|
|
2309
|
+
if (token !== void 0) {
|
|
2310
|
+
context.headers["authorization"] = `Bearer ${token}`;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
return context;
|
|
2314
|
+
};
|
|
1755
2315
|
let warnedUnauthenticatedShardAccess = false;
|
|
1756
|
-
const
|
|
1757
|
-
if (
|
|
2316
|
+
const guardUnauthenticatedShardAccess = (kind) => {
|
|
2317
|
+
if (!options.allowUnauthenticatedShardAccess) {
|
|
2318
|
+
const callback = kind === "fan-out" ? "authorizeFanOut" : "authorizeShard";
|
|
2319
|
+
throw new LunoraError(
|
|
2320
|
+
`${kind} access is default-denied: configure \`${callback}\` on the worker, or set \`allowUnauthenticatedShardAccess: true\` to explicitly allow unauthenticated ${kind} access (relying solely on per-row RLS).`,
|
|
2321
|
+
{ code: kind === "fan-out" ? "FORBIDDEN_FANOUT" : "FORBIDDEN_SHARD", status: 403 }
|
|
2322
|
+
);
|
|
2323
|
+
}
|
|
2324
|
+
if (warnedUnauthenticatedShardAccess) {
|
|
1758
2325
|
return;
|
|
1759
2326
|
}
|
|
1760
2327
|
warnedUnauthenticatedShardAccess = true;
|
|
1761
2328
|
console.warn(
|
|
1762
2329
|
[
|
|
1763
|
-
`[lunora] SECURITY:
|
|
2330
|
+
`[lunora] SECURITY: serving ${kind} access with \`allowUnauthenticatedShardAccess: true\` and no \`authorizeShard\`/\`authorizeFanOut\` — `,
|
|
1764
2331
|
`any caller (including unauthenticated ones) can target any shard / fan out across the table. `,
|
|
1765
|
-
`
|
|
2332
|
+
`This is safe only if every table is protected by per-row RLS. Configure \`authorizeShard\`/\`authorizeFanOut\` to gate it.`
|
|
1766
2333
|
].join("")
|
|
1767
2334
|
);
|
|
1768
2335
|
};
|
|
1769
2336
|
const orchestrationAdminRoutes = buildOrchestrationAdminRoutes({
|
|
1770
2337
|
defaultShard,
|
|
1771
2338
|
forwardToShard,
|
|
1772
|
-
isAdmin:
|
|
2339
|
+
isAdmin: requestIsAdmin,
|
|
1773
2340
|
queryCoordinator: options.queryCoordinator,
|
|
1774
|
-
resolveForwardContext:
|
|
1775
|
-
shardDO
|
|
2341
|
+
resolveForwardContext: resolveAdminForwardContext,
|
|
2342
|
+
shardDO
|
|
1776
2343
|
});
|
|
1777
|
-
const dispatchToShard = async (functionPath, args, shardKey) => {
|
|
2344
|
+
const dispatchToShard = async (functionPath, args, shardKey, mutationId, forwardedIdentity) => {
|
|
1778
2345
|
if (options.authorizeShard) {
|
|
1779
2346
|
const allowed = await options.authorizeShard(null, shardKey);
|
|
1780
2347
|
if (!allowed) {
|
|
1781
2348
|
throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
|
|
1782
2349
|
}
|
|
1783
2350
|
}
|
|
2351
|
+
const headers = { "content-type": "application/json", "x-lunora-system": "1" };
|
|
2352
|
+
if (forwardedIdentity?.userId !== void 0 && forwardedIdentity.userId.length > 0) {
|
|
2353
|
+
headers["x-lunora-userid"] = forwardedIdentity.userId;
|
|
2354
|
+
}
|
|
2355
|
+
if (forwardedIdentity?.identity !== void 0 && forwardedIdentity.identity.length > 0) {
|
|
2356
|
+
headers["x-lunora-identity"] = forwardedIdentity.identity;
|
|
2357
|
+
}
|
|
2358
|
+
if (mutationId !== void 0 && mutationId.length > 0) {
|
|
2359
|
+
headers["x-lunora-mutation-id"] = mutationId;
|
|
2360
|
+
}
|
|
1784
2361
|
const forwarded = new Request("https://shard.internal/rpc", {
|
|
1785
|
-
// `x-lunora-system` marks this as a trusted server-initiated dispatch
|
|
1786
|
-
// so the shard may run `internal` functions (scheduled/cron jobs are
|
|
1787
|
-
// typically internal). Authorization was already enforced above; this
|
|
1788
|
-
// header is set only here, never on the client RPC path.
|
|
1789
2362
|
body: JSON.stringify({ args, functionPath }),
|
|
1790
|
-
headers
|
|
2363
|
+
headers,
|
|
1791
2364
|
method: "POST"
|
|
1792
2365
|
});
|
|
1793
|
-
return forwardToShard(
|
|
2366
|
+
return forwardToShard(shardDO, shardKey, forwarded);
|
|
1794
2367
|
};
|
|
1795
|
-
const
|
|
2368
|
+
const startWorkflowInstance = async (binding, args, env, label) => {
|
|
1796
2369
|
const candidate = env?.[binding];
|
|
1797
2370
|
if (!candidate || typeof candidate.create !== "function") {
|
|
1798
|
-
throw new LunoraError(
|
|
2371
|
+
throw new LunoraError(`${label} targets workflow binding "${binding}", which is not bound on env`, {
|
|
1799
2372
|
code: "CRON_JOB_FAILED",
|
|
1800
2373
|
status: 500
|
|
1801
2374
|
});
|
|
1802
2375
|
}
|
|
1803
|
-
await candidate.create({ params:
|
|
2376
|
+
await candidate.create({ params: args });
|
|
1804
2377
|
};
|
|
2378
|
+
const startCronWorkflow = async (binding, job, env) => startWorkflowInstance(binding, job.args ?? {}, env, `cron job "${job.name}"`);
|
|
1805
2379
|
const runOneCronJob = async (job, env) => {
|
|
1806
2380
|
if (job.workflow) {
|
|
1807
2381
|
await startCronWorkflow(job.workflow, job, env);
|
|
@@ -1835,7 +2409,7 @@ const createWorker = (options) => {
|
|
|
1835
2409
|
}
|
|
1836
2410
|
};
|
|
1837
2411
|
const handleRunCronJob = async (request, env) => {
|
|
1838
|
-
if (!
|
|
2412
|
+
if (!requestIsAdmin(request)) {
|
|
1839
2413
|
throw new LunoraError("admin endpoint requires a valid admin bearer", { code: "ADMIN_FORBIDDEN", status: 403 });
|
|
1840
2414
|
}
|
|
1841
2415
|
if (request.method !== "POST") {
|
|
@@ -1858,12 +2432,12 @@ const createWorker = (options) => {
|
|
|
1858
2432
|
};
|
|
1859
2433
|
const releasePoolSlot = async (candidate) => {
|
|
1860
2434
|
const pool = typeof candidate.pool === "string" && candidate.pool.length > 0 ? candidate.pool : void 0;
|
|
1861
|
-
if (!pool || !
|
|
2435
|
+
if (!pool || !schedulerDO || typeof candidate.id !== "string") {
|
|
1862
2436
|
return;
|
|
1863
2437
|
}
|
|
1864
2438
|
const instanceName = typeof candidate.instanceName === "string" && candidate.instanceName.length > 0 ? candidate.instanceName : "default";
|
|
1865
2439
|
try {
|
|
1866
|
-
await
|
|
2440
|
+
await schedulerDO.get(schedulerDO.idFromName(instanceName)).fetch(
|
|
1867
2441
|
new Request("https://scheduler.internal/complete", {
|
|
1868
2442
|
body: JSON.stringify({ id: candidate.id, pool }),
|
|
1869
2443
|
headers: { "content-type": "application/json" },
|
|
@@ -1898,28 +2472,34 @@ const createWorker = (options) => {
|
|
|
1898
2472
|
throw new LunoraError("Scheduler dispatch body must be valid JSON", { code: "BAD_REQUEST", status: 400 });
|
|
1899
2473
|
}
|
|
1900
2474
|
const candidate = body ?? {};
|
|
2475
|
+
const args = candidate.args ?? {};
|
|
2476
|
+
if (typeof candidate.workflow === "string" && candidate.workflow.length > 0) {
|
|
2477
|
+
await startWorkflowInstance(candidate.workflow, args, env, "scheduled workflow");
|
|
2478
|
+
return Response.json({ ok: true }, { status: 200 });
|
|
2479
|
+
}
|
|
1901
2480
|
if (typeof candidate.functionPath !== "string" || candidate.functionPath.length === 0) {
|
|
1902
2481
|
throw new LunoraError("Scheduler dispatch is missing `functionPath`", { code: "BAD_REQUEST", status: 400 });
|
|
1903
2482
|
}
|
|
1904
|
-
const args = candidate.args ?? {};
|
|
1905
2483
|
const shardKey = typeof candidate.shardKey === "string" && candidate.shardKey.length > 0 ? candidate.shardKey : defaultShard;
|
|
1906
|
-
const
|
|
2484
|
+
const mutationId = typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : void 0;
|
|
2485
|
+
const identity = readForwardedIdentity(request);
|
|
2486
|
+
const response = await dispatchToShard(candidate.functionPath, args, shardKey, mutationId, identity);
|
|
1907
2487
|
await releasePoolSlot(candidate);
|
|
1908
2488
|
return response;
|
|
1909
2489
|
};
|
|
1910
2490
|
const dataMovementAdminRoutes = buildDataMovementAdminRoutes({
|
|
1911
2491
|
applyGlobals: options.applyGlobals,
|
|
1912
|
-
isAdmin:
|
|
2492
|
+
isAdmin: requestIsAdmin,
|
|
1913
2493
|
knownTables: () => collectKnownTables(),
|
|
1914
2494
|
queryCoordinator: options.queryCoordinator,
|
|
1915
|
-
resolveForwardContext:
|
|
1916
|
-
shardDO
|
|
1917
|
-
streamExportRows: (coordinator, headers, tables, writeRow) => streamExportRows(options, coordinator, headers, tables, writeRow),
|
|
1918
|
-
streamingImport: (request, headers) => streamingImport(request, options, headers),
|
|
2495
|
+
resolveForwardContext: resolveAdminForwardContext,
|
|
2496
|
+
shardDO,
|
|
2497
|
+
streamExportRows: (coordinator, headers, tables, writeRow) => streamExportRows(options, coordinator, headers, tables, writeRow, shardDO),
|
|
2498
|
+
streamingImport: (request, headers) => streamingImport(request, options, headers, shardDO),
|
|
1919
2499
|
syncGlobals: options.syncGlobals
|
|
1920
2500
|
});
|
|
1921
2501
|
const assertAdminAuthorized = (request) => {
|
|
1922
|
-
if (!
|
|
2502
|
+
if (!requestIsAdmin(request)) {
|
|
1923
2503
|
throw new LunoraError("admin endpoint requires a valid admin bearer", { code: "ADMIN_FORBIDDEN", status: 403 });
|
|
1924
2504
|
}
|
|
1925
2505
|
};
|
|
@@ -1946,17 +2526,17 @@ const createWorker = (options) => {
|
|
|
1946
2526
|
};
|
|
1947
2527
|
};
|
|
1948
2528
|
const requireSchedulerNamespace = () => {
|
|
1949
|
-
if (
|
|
2529
|
+
if (schedulerDO === void 0) {
|
|
1950
2530
|
throw new LunoraError("scheduled endpoints require a `schedulerDO` namespace on the worker", { code: "SCHEDULER_NOT_CONFIGURED", status: 400 });
|
|
1951
2531
|
}
|
|
1952
|
-
return
|
|
2532
|
+
return schedulerDO;
|
|
1953
2533
|
};
|
|
1954
2534
|
const resolveSchedulerStub = (request) => {
|
|
1955
2535
|
assertAdminAuthorized(request);
|
|
1956
2536
|
return resolveShard(requireSchedulerNamespace(), options.schedulerInstanceName ?? "default");
|
|
1957
2537
|
};
|
|
1958
2538
|
const scheduledAdminRoutes = buildScheduledAdminRoutes({
|
|
1959
|
-
checkWsAdmin: (request) =>
|
|
2539
|
+
checkWsAdmin: async (request) => requestIsAdmin(request) || checkAdminWsToken(request, effectiveAdminToken(), effectiveRequireEphemeralWsToken()),
|
|
1960
2540
|
requireSchedulerNamespace,
|
|
1961
2541
|
resolveSchedulerStub,
|
|
1962
2542
|
schedulerInstanceName: options.schedulerInstanceName ?? "default"
|
|
@@ -1984,6 +2564,11 @@ const createWorker = (options) => {
|
|
|
1984
2564
|
requireAdminOption,
|
|
1985
2565
|
vectorIntrospector: options.vectorIntrospector
|
|
1986
2566
|
});
|
|
2567
|
+
const kvAdminRoutes = buildKvAdminRoutes({
|
|
2568
|
+
kvIntrospector: options.kvIntrospector,
|
|
2569
|
+
readJsonBody: readJsonBodyWithLimit,
|
|
2570
|
+
requireAdminOption
|
|
2571
|
+
});
|
|
1987
2572
|
const introspectionAdminRoutes = buildIntrospectionAdminRoutes({
|
|
1988
2573
|
assertAdmin: assertAdminAuthorized,
|
|
1989
2574
|
options: {
|
|
@@ -1997,8 +2582,8 @@ const createWorker = (options) => {
|
|
|
1997
2582
|
queryParameter,
|
|
1998
2583
|
requireAdminOption
|
|
1999
2584
|
});
|
|
2000
|
-
const buildHttpActionContext = async (request, env) => {
|
|
2001
|
-
const { claims, headers, userId } = await resolveForwardContext(request, env,
|
|
2585
|
+
const buildHttpActionContext = async (request, env, context) => {
|
|
2586
|
+
const { claims, headers, userId } = await resolveForwardContext(request, env, publicResolveIdentity);
|
|
2002
2587
|
const run = async (reference, args = {}) => {
|
|
2003
2588
|
const functionPath = reference.__lunoraRef;
|
|
2004
2589
|
if (typeof functionPath !== "string") {
|
|
@@ -2009,7 +2594,7 @@ const createWorker = (options) => {
|
|
|
2009
2594
|
headers,
|
|
2010
2595
|
method: "POST"
|
|
2011
2596
|
});
|
|
2012
|
-
const response = await forwardToShard(
|
|
2597
|
+
const response = await forwardToShard(shardDO, defaultShard, forwarded);
|
|
2013
2598
|
const payload = await response.json();
|
|
2014
2599
|
if (payload.error) {
|
|
2015
2600
|
throw new LunoraError(payload.error.message ?? "shard RPC failed", {
|
|
@@ -2024,6 +2609,7 @@ const createWorker = (options) => {
|
|
|
2024
2609
|
getIdentity: () => Promise.resolve(claims),
|
|
2025
2610
|
userId
|
|
2026
2611
|
},
|
|
2612
|
+
cache: context.cache,
|
|
2027
2613
|
fetch: globalThis.fetch.bind(globalThis),
|
|
2028
2614
|
runAction: run,
|
|
2029
2615
|
runMutation: run,
|
|
@@ -2034,7 +2620,7 @@ const createWorker = (options) => {
|
|
|
2034
2620
|
if (!options.httpRouter) {
|
|
2035
2621
|
return void 0;
|
|
2036
2622
|
}
|
|
2037
|
-
const httpContext = await buildHttpActionContext(request, env);
|
|
2623
|
+
const httpContext = await buildHttpActionContext(request, env, context);
|
|
2038
2624
|
try {
|
|
2039
2625
|
return await options.httpRouter.fetch(request, { ...env, __lunoraCtx: httpContext }, context);
|
|
2040
2626
|
} catch (error) {
|
|
@@ -2046,15 +2632,84 @@ const createWorker = (options) => {
|
|
|
2046
2632
|
if (request.headers.get("Upgrade") !== "websocket") {
|
|
2047
2633
|
throw new LunoraError("WebSocket upgrade header missing", { code: "BAD_REQUEST", status: 426 });
|
|
2048
2634
|
}
|
|
2635
|
+
const blockedUpgrade = enforceWebSocketOrigin(request, resolvedSecurity);
|
|
2636
|
+
if (blockedUpgrade) {
|
|
2637
|
+
return blockedUpgrade;
|
|
2638
|
+
}
|
|
2049
2639
|
const shardKey = url.searchParams.get("shard") ?? defaultShard;
|
|
2050
|
-
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env,
|
|
2640
|
+
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
|
|
2051
2641
|
if (options.authorizeShard) {
|
|
2052
2642
|
const allowed = await options.authorizeShard(identity, shardKey);
|
|
2053
2643
|
if (!allowed) {
|
|
2054
2644
|
throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
|
|
2055
2645
|
}
|
|
2056
2646
|
} else if (shardKey !== defaultShard) {
|
|
2057
|
-
|
|
2647
|
+
guardUnauthenticatedShardAccess("shard");
|
|
2648
|
+
}
|
|
2649
|
+
const upgradeHeaders = new Headers(request.headers);
|
|
2650
|
+
const clientHeaderNames = [...upgradeHeaders.keys()];
|
|
2651
|
+
for (const name of clientHeaderNames) {
|
|
2652
|
+
if (name.startsWith("x-lunora-")) {
|
|
2653
|
+
upgradeHeaders.delete(name);
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
const forwardedUserId = forwardedHeaders["x-lunora-userid"];
|
|
2657
|
+
const forwardedIdentity = forwardedHeaders["x-lunora-identity"];
|
|
2658
|
+
const forwardedExp = forwardedHeaders["x-lunora-identity-exp"];
|
|
2659
|
+
if (forwardedUserId !== void 0) {
|
|
2660
|
+
upgradeHeaders.set("x-lunora-userid", forwardedUserId);
|
|
2661
|
+
}
|
|
2662
|
+
if (forwardedIdentity !== void 0) {
|
|
2663
|
+
upgradeHeaders.set("x-lunora-identity", forwardedIdentity);
|
|
2664
|
+
}
|
|
2665
|
+
if (forwardedExp !== void 0) {
|
|
2666
|
+
upgradeHeaders.set("x-lunora-identity-exp", forwardedExp);
|
|
2667
|
+
}
|
|
2668
|
+
const binding = resolveShardBindingName(env, options.shardDO);
|
|
2669
|
+
if (binding !== void 0) {
|
|
2670
|
+
upgradeHeaders.set("x-lunora-shard-binding", binding);
|
|
2671
|
+
const relayCount = await probeRelayCount(shardDO, shardKey);
|
|
2672
|
+
if (relayCount > 0) {
|
|
2673
|
+
const target = relayName(shardKey, Math.floor(Math.random() * relayCount));
|
|
2674
|
+
return forwardToShard(shardDO, target, new Request(request, { headers: upgradeHeaders }));
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return forwardToShard(shardDO, shardKey, new Request(request, { headers: upgradeHeaders }));
|
|
2678
|
+
};
|
|
2679
|
+
const handleVoiceUpgrade = async (request, env, url) => {
|
|
2680
|
+
const { voiceAgents } = options;
|
|
2681
|
+
if (voiceAgents === void 0) {
|
|
2682
|
+
return new Response("Not found", { status: 404 });
|
|
2683
|
+
}
|
|
2684
|
+
if (request.headers.get("Upgrade") !== "websocket") {
|
|
2685
|
+
return new Response("Expected a WebSocket upgrade", { headers: { allow: "GET" }, status: 426 });
|
|
2686
|
+
}
|
|
2687
|
+
const blockedUpgrade = enforceWebSocketOrigin(request, resolvedSecurity);
|
|
2688
|
+
if (blockedUpgrade) {
|
|
2689
|
+
return blockedUpgrade;
|
|
2690
|
+
}
|
|
2691
|
+
let agentName;
|
|
2692
|
+
try {
|
|
2693
|
+
agentName = decodeURIComponent(url.pathname.slice(VOICE_PATH_PREFIX.length));
|
|
2694
|
+
} catch {
|
|
2695
|
+
return new Response("Unknown voice agent", { status: 404 });
|
|
2696
|
+
}
|
|
2697
|
+
const namespace = Object.hasOwn(voiceAgents, agentName) ? voiceAgents[agentName] : void 0;
|
|
2698
|
+
if (namespace === void 0) {
|
|
2699
|
+
return new Response("Unknown voice agent", { status: 404 });
|
|
2700
|
+
}
|
|
2701
|
+
const threadKey = url.searchParams.get("threadKey");
|
|
2702
|
+
if (threadKey === null || threadKey.length === 0) {
|
|
2703
|
+
return new Response("Missing threadKey", { status: 400 });
|
|
2704
|
+
}
|
|
2705
|
+
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
|
|
2706
|
+
if (options.authorizeShard) {
|
|
2707
|
+
const allowed = await options.authorizeShard(identity, threadKey);
|
|
2708
|
+
if (!allowed) {
|
|
2709
|
+
return new Response("Forbidden", { status: 403 });
|
|
2710
|
+
}
|
|
2711
|
+
} else {
|
|
2712
|
+
guardUnauthenticatedShardAccess("shard");
|
|
2058
2713
|
}
|
|
2059
2714
|
const upgradeHeaders = new Headers(request.headers);
|
|
2060
2715
|
upgradeHeaders.delete("x-lunora-userid");
|
|
@@ -2072,31 +2727,36 @@ const createWorker = (options) => {
|
|
|
2072
2727
|
if (forwardedExp !== void 0) {
|
|
2073
2728
|
upgradeHeaders.set("x-lunora-identity-exp", forwardedExp);
|
|
2074
2729
|
}
|
|
2075
|
-
return forwardToShard(
|
|
2730
|
+
return forwardToShard(namespace, threadKey, new Request(request, { headers: upgradeHeaders }));
|
|
2731
|
+
};
|
|
2732
|
+
const authorizeFanOutEnvelope = async (fanOut, functionPath, identity) => {
|
|
2733
|
+
if (options.authorizeFanOut) {
|
|
2734
|
+
const allowed = await options.authorizeFanOut(identity, fanOut.table, functionPath);
|
|
2735
|
+
if (!allowed) {
|
|
2736
|
+
throw new LunoraError("Forbidden fan-out", { code: "FORBIDDEN_FANOUT", status: 403 });
|
|
2737
|
+
}
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
if (functionPath.startsWith("__lunora_relation__:")) {
|
|
2741
|
+
throw new LunoraError("reverse cross-backend relation reads (`__lunora_relation__:*`) require `authorizeFanOut` to be configured on the worker", {
|
|
2742
|
+
code: "FORBIDDEN_FANOUT",
|
|
2743
|
+
status: 403
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
if (options.authorizeShard) {
|
|
2747
|
+
throw new LunoraError("Fan-out requires `authorizeFanOut` to be configured on the worker when `authorizeShard` is set", {
|
|
2748
|
+
code: "FORBIDDEN_FANOUT",
|
|
2749
|
+
status: 403
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
guardUnauthenticatedShardAccess("fan-out");
|
|
2076
2753
|
};
|
|
2077
2754
|
const authorizeRpcEnvelope = async (envelope, identity) => {
|
|
2755
|
+
if (!envelope.fanOut && envelope.functionPath.startsWith("__lunora_admin__:")) {
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2078
2758
|
if (envelope.fanOut) {
|
|
2079
|
-
|
|
2080
|
-
const allowed = await options.authorizeFanOut(identity, envelope.fanOut.table, envelope.functionPath);
|
|
2081
|
-
if (!allowed) {
|
|
2082
|
-
throw new LunoraError("Forbidden fan-out", { code: "FORBIDDEN_FANOUT", status: 403 });
|
|
2083
|
-
}
|
|
2084
|
-
} else if (envelope.functionPath.startsWith("__lunora_relation__:")) {
|
|
2085
|
-
throw new LunoraError(
|
|
2086
|
-
"reverse cross-backend relation reads (`__lunora_relation__:*`) require `authorizeFanOut` to be configured on the worker",
|
|
2087
|
-
{
|
|
2088
|
-
code: "FORBIDDEN_FANOUT",
|
|
2089
|
-
status: 403
|
|
2090
|
-
}
|
|
2091
|
-
);
|
|
2092
|
-
} else if (options.authorizeShard) {
|
|
2093
|
-
throw new LunoraError("Fan-out requires `authorizeFanOut` to be configured on the worker when `authorizeShard` is set", {
|
|
2094
|
-
code: "FORBIDDEN_FANOUT",
|
|
2095
|
-
status: 403
|
|
2096
|
-
});
|
|
2097
|
-
} else {
|
|
2098
|
-
warnUnauthenticatedShardAccessOnce("fan-out");
|
|
2099
|
-
}
|
|
2759
|
+
await authorizeFanOutEnvelope(envelope.fanOut, envelope.functionPath, identity);
|
|
2100
2760
|
return;
|
|
2101
2761
|
}
|
|
2102
2762
|
if (options.authorizeShard) {
|
|
@@ -2106,19 +2766,21 @@ const createWorker = (options) => {
|
|
|
2106
2766
|
throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
|
|
2107
2767
|
}
|
|
2108
2768
|
} else if (envelope.shardKey !== void 0 && envelope.shardKey !== defaultShard) {
|
|
2109
|
-
|
|
2769
|
+
guardUnauthenticatedShardAccess("shard");
|
|
2110
2770
|
}
|
|
2111
2771
|
};
|
|
2112
2772
|
const dispatchSingleShard = async (functionPath, args, shardKey, forwardedHeaders, sinkContext) => {
|
|
2113
2773
|
const rpcStartedAt = Date.now();
|
|
2114
2774
|
const { observability } = options;
|
|
2775
|
+
const traceId = otlpRandomHex(16);
|
|
2776
|
+
const spanId = otlpRandomHex(8);
|
|
2115
2777
|
const forwarded = new Request(`https://shard.internal/rpc`, {
|
|
2116
2778
|
body: JSON.stringify({ args, functionPath }),
|
|
2117
|
-
headers: forwardedHeaders,
|
|
2779
|
+
headers: { ...forwardedHeaders, traceparent: buildTraceparent(traceId, spanId) },
|
|
2118
2780
|
method: "POST"
|
|
2119
2781
|
});
|
|
2120
2782
|
try {
|
|
2121
|
-
const response = await forwardToShard(
|
|
2783
|
+
const response = await forwardToShard(shardDO, shardKey, forwarded);
|
|
2122
2784
|
emitRpcEvent(
|
|
2123
2785
|
observability,
|
|
2124
2786
|
{
|
|
@@ -2126,19 +2788,15 @@ const createWorker = (options) => {
|
|
|
2126
2788
|
functionPath,
|
|
2127
2789
|
ok: response.ok,
|
|
2128
2790
|
shardKey,
|
|
2791
|
+
spanId,
|
|
2792
|
+
traceId,
|
|
2129
2793
|
...response.ok ? {} : { error: { code: "SHARD_ERROR", message: `shard returned ${String(response.status)}`, status: response.status } }
|
|
2130
2794
|
},
|
|
2131
2795
|
sinkContext
|
|
2132
2796
|
);
|
|
2133
|
-
const responseBookmark = response.headers.get("x-d1-bookmark");
|
|
2134
|
-
if (responseBookmark) {
|
|
2135
|
-
const headers = new Headers(response.headers);
|
|
2136
|
-
headers.set("x-d1-bookmark", responseBookmark);
|
|
2137
|
-
return new Response(response.body, { headers, status: response.status });
|
|
2138
|
-
}
|
|
2139
2797
|
return response;
|
|
2140
2798
|
} catch (error) {
|
|
2141
|
-
emitRpcEvent(observability, buildErrorEvent(functionPath, Date.now() - rpcStartedAt, error, { shardKey }), sinkContext);
|
|
2799
|
+
emitRpcEvent(observability, { ...buildErrorEvent(functionPath, Date.now() - rpcStartedAt, error, { shardKey }), spanId, traceId }, sinkContext);
|
|
2142
2800
|
throw error;
|
|
2143
2801
|
}
|
|
2144
2802
|
};
|
|
@@ -2147,6 +2805,7 @@ const createWorker = (options) => {
|
|
|
2147
2805
|
throw new LunoraError("RPC endpoint requires POST", { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
2148
2806
|
}
|
|
2149
2807
|
const envelope = await parseEnvelope(request);
|
|
2808
|
+
logRpcDebug(env, envelope);
|
|
2150
2809
|
if (envelope.fanOut && envelope.shardKey) {
|
|
2151
2810
|
throw new LunoraError("RPC envelope cannot set both `shardKey` and `fanOut`", { code: "BAD_REQUEST", status: 400 });
|
|
2152
2811
|
}
|
|
@@ -2162,14 +2821,15 @@ const createWorker = (options) => {
|
|
|
2162
2821
|
status: 400
|
|
2163
2822
|
});
|
|
2164
2823
|
}
|
|
2165
|
-
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env,
|
|
2824
|
+
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
|
|
2166
2825
|
await authorizeRpcEnvelope(envelope, identity);
|
|
2826
|
+
const x402Tag = resolveX402Charge(envelope, options);
|
|
2167
2827
|
{
|
|
2168
2828
|
const rpcStartedAt = Date.now();
|
|
2169
2829
|
const { observability } = options;
|
|
2170
2830
|
const sinkContext = context ? {
|
|
2171
2831
|
waitUntil: (promise) => {
|
|
2172
|
-
context.waitUntil(promise);
|
|
2832
|
+
context.waitUntil?.(promise);
|
|
2173
2833
|
}
|
|
2174
2834
|
} : void 0;
|
|
2175
2835
|
if (envelope.fanOut) {
|
|
@@ -2181,7 +2841,7 @@ const createWorker = (options) => {
|
|
|
2181
2841
|
});
|
|
2182
2842
|
}
|
|
2183
2843
|
try {
|
|
2184
|
-
const result = await coordinator.fanOut(
|
|
2844
|
+
const result = await coordinator.fanOut(shardDO, {
|
|
2185
2845
|
args: envelope.args ?? {},
|
|
2186
2846
|
fanOut: envelope.fanOut,
|
|
2187
2847
|
functionPath: envelope.functionPath,
|
|
@@ -2215,16 +2875,153 @@ const createWorker = (options) => {
|
|
|
2215
2875
|
}
|
|
2216
2876
|
}
|
|
2217
2877
|
const shardKey = envelope.shardKey ?? defaultShard;
|
|
2218
|
-
|
|
2878
|
+
const dispatch = () => dispatchSingleShard(envelope.functionPath, envelope.args ?? {}, shardKey, forwardedHeaders, sinkContext);
|
|
2879
|
+
if (x402Tag && options.x402Charge) {
|
|
2880
|
+
return options.x402Charge(request, { functionPath: envelope.functionPath, price: x402Tag.price }, dispatch);
|
|
2881
|
+
}
|
|
2882
|
+
return dispatch();
|
|
2219
2883
|
}
|
|
2220
2884
|
};
|
|
2885
|
+
const handleBatchRpc = async (request, env, context) => {
|
|
2886
|
+
if (request.method !== "POST") {
|
|
2887
|
+
throw new LunoraError("RPC batch endpoint requires POST", { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
2888
|
+
}
|
|
2889
|
+
const text = await readBodyTextWithLimit(request);
|
|
2890
|
+
let body;
|
|
2891
|
+
try {
|
|
2892
|
+
body = JSON.parse(text);
|
|
2893
|
+
} catch {
|
|
2894
|
+
throw new LunoraError("RPC batch body must be valid JSON", { code: "BAD_REQUEST", status: 400 });
|
|
2895
|
+
}
|
|
2896
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
2897
|
+
throw new LunoraError("RPC batch body must be an object", { code: "BAD_REQUEST", status: 400 });
|
|
2898
|
+
}
|
|
2899
|
+
const { calls } = body;
|
|
2900
|
+
if (!Array.isArray(calls)) {
|
|
2901
|
+
throw new LunoraError("RPC batch `calls` must be an array", { code: "BAD_REQUEST", status: 400 });
|
|
2902
|
+
}
|
|
2903
|
+
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
|
|
2904
|
+
const groups = groupBatchCallsByShard(calls, defaultShard);
|
|
2905
|
+
for (const entries of groups.values()) {
|
|
2906
|
+
for (const entry of entries) {
|
|
2907
|
+
if (options.functions?.[entry.functionPath]?.x402) {
|
|
2908
|
+
throw new LunoraError(
|
|
2909
|
+
`paid (\`.x402\`) function "${entry.functionPath}" cannot be called in a batch; dispatch it individually over ${RPC_PATH}`,
|
|
2910
|
+
{
|
|
2911
|
+
code: "BAD_REQUEST",
|
|
2912
|
+
status: 400
|
|
2913
|
+
}
|
|
2914
|
+
);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
await Promise.all(
|
|
2919
|
+
[...groups.entries()].flatMap(
|
|
2920
|
+
([shardKey, entries]) => entries.map((entry) => authorizeRpcEnvelope({ functionPath: entry.functionPath, shardKey }, identity))
|
|
2921
|
+
)
|
|
2922
|
+
);
|
|
2923
|
+
const { observability } = options;
|
|
2924
|
+
const sinkContext = context ? {
|
|
2925
|
+
waitUntil: (promise) => {
|
|
2926
|
+
context.waitUntil?.(promise);
|
|
2927
|
+
}
|
|
2928
|
+
} : void 0;
|
|
2929
|
+
const results = [];
|
|
2930
|
+
const bookmarks = [];
|
|
2931
|
+
const slotError = (entry, status, code, message) => {
|
|
2932
|
+
return { body: { error: { code, message } }, id: entry.id, status };
|
|
2933
|
+
};
|
|
2934
|
+
const failSubBatch = (entries, status, code, message, eventFor) => {
|
|
2935
|
+
for (const entry of entries) {
|
|
2936
|
+
emitRpcEvent(observability, eventFor(entry), sinkContext);
|
|
2937
|
+
results.push(slotError(entry, status, code, message));
|
|
2938
|
+
}
|
|
2939
|
+
};
|
|
2940
|
+
const emitEntryEvents = (entries, shardKey, durationMs, statusById, fallbackStatus) => {
|
|
2941
|
+
for (const entry of entries) {
|
|
2942
|
+
const status = statusById.get(entry.id) ?? fallbackStatus;
|
|
2943
|
+
const ok = status < 400;
|
|
2944
|
+
emitRpcEvent(
|
|
2945
|
+
observability,
|
|
2946
|
+
{
|
|
2947
|
+
durationMs,
|
|
2948
|
+
functionPath: entry.functionPath,
|
|
2949
|
+
ok,
|
|
2950
|
+
shardKey,
|
|
2951
|
+
...ok ? {} : { error: { code: "SHARD_ERROR", message: `batched call returned ${String(status)}`, status } }
|
|
2952
|
+
},
|
|
2953
|
+
sinkContext
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
await Promise.all(
|
|
2958
|
+
[...groups.entries()].map(async ([shardKey, entries]) => {
|
|
2959
|
+
const headers = new Headers(forwardedHeaders);
|
|
2960
|
+
headers.set("content-type", "application/json");
|
|
2961
|
+
const subRequest = new Request("https://shard.internal/rpc-batch", { body: JSON.stringify({ calls: entries }), headers, method: "POST" });
|
|
2962
|
+
const subStartedAt = Date.now();
|
|
2963
|
+
let response;
|
|
2964
|
+
try {
|
|
2965
|
+
response = await forwardToShard(shardDO, shardKey, subRequest);
|
|
2966
|
+
} catch (error) {
|
|
2967
|
+
const durationMs2 = Date.now() - subStartedAt;
|
|
2968
|
+
const { body: errorBody } = toErrorBody(error, { fallbackCode: "SHARD_UNAVAILABLE", redactedMessage: "shard unavailable" });
|
|
2969
|
+
failSubBatch(
|
|
2970
|
+
entries,
|
|
2971
|
+
502,
|
|
2972
|
+
errorBody.code,
|
|
2973
|
+
errorBody.message,
|
|
2974
|
+
(entry) => buildErrorEvent(entry.functionPath, durationMs2, error, { shardKey })
|
|
2975
|
+
);
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
const durationMs = Date.now() - subStartedAt;
|
|
2979
|
+
const bookmark = response.headers.get("x-d1-bookmark");
|
|
2980
|
+
if (bookmark) {
|
|
2981
|
+
bookmarks.push(bookmark);
|
|
2982
|
+
}
|
|
2983
|
+
let parsed;
|
|
2984
|
+
try {
|
|
2985
|
+
parsed = await response.json();
|
|
2986
|
+
} catch {
|
|
2987
|
+
const message = `shard batch returned a non-JSON response (${String(response.status)})`;
|
|
2988
|
+
failSubBatch(entries, response.status, "SHARD_ERROR", message, (entry) => {
|
|
2989
|
+
return {
|
|
2990
|
+
durationMs,
|
|
2991
|
+
error: { code: "SHARD_ERROR", message, status: response.status },
|
|
2992
|
+
functionPath: entry.functionPath,
|
|
2993
|
+
ok: false,
|
|
2994
|
+
shardKey
|
|
2995
|
+
};
|
|
2996
|
+
});
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
const entryResults = Array.isArray(parsed.results) ? parsed.results : [];
|
|
3000
|
+
const statusById = new Map(entryResults.map((entry) => [entry.id, entry.status ?? response.status]));
|
|
3001
|
+
const seenIds = new Set(entryResults.map((entry) => entry.id));
|
|
3002
|
+
emitEntryEvents(entries, shardKey, durationMs, statusById, response.status);
|
|
3003
|
+
results.push(...entryResults);
|
|
3004
|
+
for (const entry of entries) {
|
|
3005
|
+
if (!seenIds.has(entry.id)) {
|
|
3006
|
+
results.push(slotError(entry, response.status, "SHARD_ERROR", `shard batch omitted result for call ${String(entry.id)}`));
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
})
|
|
3010
|
+
);
|
|
3011
|
+
const responseHeaders = { "content-type": "application/json" };
|
|
3012
|
+
const [onlyBookmark] = bookmarks;
|
|
3013
|
+
if (bookmarks.length === 1 && onlyBookmark !== void 0) {
|
|
3014
|
+
responseHeaders["x-d1-bookmark"] = onlyBookmark;
|
|
3015
|
+
}
|
|
3016
|
+
return Response.json({ results }, { headers: responseHeaders, status: 200 });
|
|
3017
|
+
};
|
|
2221
3018
|
const serverQuery = async (request, env, reference, args = {}, callOptions = {}) => {
|
|
2222
3019
|
try {
|
|
2223
3020
|
const functionPath = reference.__lunoraRef;
|
|
2224
3021
|
if (typeof functionPath !== "string") {
|
|
2225
3022
|
throw new LunoraError("serverQuery: expected a function reference from the generated `api`", { code: "BAD_REQUEST", status: 400 });
|
|
2226
3023
|
}
|
|
2227
|
-
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env,
|
|
3024
|
+
const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
|
|
2228
3025
|
await authorizeRpcEnvelope({ functionPath, shardKey: callOptions.shardKey }, identity);
|
|
2229
3026
|
const shardKey = callOptions.shardKey ?? defaultShard;
|
|
2230
3027
|
return await dispatchSingleShard(functionPath, args, shardKey, forwardedHeaders);
|
|
@@ -2269,43 +3066,33 @@ const createWorker = (options) => {
|
|
|
2269
3066
|
if (!coordinator) {
|
|
2270
3067
|
throw new LunoraError("scheduled backup requires a `queryCoordinator` on the worker", { code: "BACKUP_NOT_CONFIGURED", status: 500 });
|
|
2271
3068
|
}
|
|
2272
|
-
|
|
2273
|
-
|
|
3069
|
+
const adminToken = effectiveAdminToken();
|
|
3070
|
+
if (!adminToken || adminToken.length === 0) {
|
|
3071
|
+
throw new LunoraError("scheduled backup requires an `adminToken` (or `env.LUNORA_ADMIN_TOKEN`) to authenticate the per-shard export gate", {
|
|
2274
3072
|
code: "BACKUP_NOT_CONFIGURED",
|
|
2275
3073
|
status: 500
|
|
2276
3074
|
});
|
|
2277
3075
|
}
|
|
2278
|
-
const forwardedHeaders = { authorization: `Bearer ${
|
|
3076
|
+
const forwardedHeaders = { authorization: `Bearer ${adminToken}`, "content-type": "application/json" };
|
|
2279
3077
|
const tables = options.backupTables;
|
|
2280
3078
|
let rows = 0;
|
|
2281
3079
|
let bytes = 0;
|
|
2282
|
-
|
|
2283
|
-
const
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
};
|
|
2292
|
-
try {
|
|
2293
|
-
await streamExportRows(options, coordinator, forwardedHeaders, tables, writeRow);
|
|
2294
|
-
streamController.close();
|
|
2295
|
-
} catch (error) {
|
|
2296
|
-
streamError = error instanceof Error ? error : new Error(String(error));
|
|
2297
|
-
streamController.error(error);
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
});
|
|
3080
|
+
const parts = [];
|
|
3081
|
+
const writeRow = (row) => {
|
|
3082
|
+
const line = `${JSON.stringify(row)}
|
|
3083
|
+
`;
|
|
3084
|
+
rows += 1;
|
|
3085
|
+
bytes += NDJSON_ENCODER.encode(line).byteLength;
|
|
3086
|
+
parts.push(line);
|
|
3087
|
+
};
|
|
3088
|
+
await streamExportRows(options, coordinator, forwardedHeaders, tables, writeRow, shardDO);
|
|
2301
3089
|
const prefix = options.backupPrefix ?? "backups/";
|
|
2302
3090
|
const timestamp = new Date(controller.scheduledTime).toISOString();
|
|
2303
3091
|
const fileKey = `${prefix}lunora-backup-${timestamp.replaceAll(/[.:]/gu, "-")}.ndjson`;
|
|
2304
3092
|
const manifestKey = `${fileKey}.manifest.json`;
|
|
2305
|
-
await store.put(fileKey,
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
}
|
|
3093
|
+
await store.put(fileKey, new Blob(parts, { type: "application/x-ndjson" }), {
|
|
3094
|
+
httpMetadata: { contentType: "application/x-ndjson" }
|
|
3095
|
+
});
|
|
2309
3096
|
const manifest = {
|
|
2310
3097
|
bytes,
|
|
2311
3098
|
createdAt: timestamp,
|
|
@@ -2321,6 +3108,7 @@ const createWorker = (options) => {
|
|
|
2321
3108
|
await pruneBackups(store, prefix);
|
|
2322
3109
|
};
|
|
2323
3110
|
const handleScheduled = async (controller, env, context) => {
|
|
3111
|
+
resolveAdminTokenFromEnv(env);
|
|
2324
3112
|
const errors = [];
|
|
2325
3113
|
const toError = (error) => error instanceof Error ? error : new Error(String(error));
|
|
2326
3114
|
const userHandler = options.crons?.[controller.cron];
|
|
@@ -2359,7 +3147,7 @@ const createWorker = (options) => {
|
|
|
2359
3147
|
headers: { authorization: `Bearer ${adminBearer}`, "content-type": "application/json" },
|
|
2360
3148
|
method: "POST"
|
|
2361
3149
|
});
|
|
2362
|
-
await forwardToShard(
|
|
3150
|
+
await forwardToShard(shardDO, defaultShard, recordRequest);
|
|
2363
3151
|
} catch {
|
|
2364
3152
|
}
|
|
2365
3153
|
};
|
|
@@ -2373,16 +3161,41 @@ const createWorker = (options) => {
|
|
|
2373
3161
|
}
|
|
2374
3162
|
const basePath = options.authBasePath ?? DEFAULT_AUTH_BASE_PATH;
|
|
2375
3163
|
if (isAuthAttemptPath(url.pathname, basePath)) {
|
|
2376
|
-
context.waitUntil(recordAuthAttempt(env, authResponse.status >= 400 ? "fail" : "ok"));
|
|
3164
|
+
context.waitUntil?.(recordAuthAttempt(env, authResponse.status >= 400 ? "fail" : "ok"));
|
|
2377
3165
|
}
|
|
2378
3166
|
return authResponse;
|
|
2379
3167
|
};
|
|
2380
3168
|
const customRoutes = options.routes !== void 0 && Object.keys(options.routes).length > 0 ? options.routes : void 0;
|
|
2381
3169
|
const internalRoutes = {
|
|
3170
|
+
[STATUS_PATH]: (request) => {
|
|
3171
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
3172
|
+
return new Response(void 0, { headers: { allow: "GET, HEAD" }, status: 405 });
|
|
3173
|
+
}
|
|
3174
|
+
return Response.json({ ok: true }, { headers: { "cache-control": "no-store" } });
|
|
3175
|
+
},
|
|
2382
3176
|
[WS_PATH]: (request, env, url) => handleWebSocketUpgrade(request, env, url),
|
|
2383
3177
|
[RPC_PATH]: (request, env, _url, context) => handleRpc(request, env, context),
|
|
3178
|
+
[RPC_BATCH_PATH]: (request, env, _url, context) => handleBatchRpc(request, env, context),
|
|
2384
3179
|
[SCHEDULER_DISPATCH_PATH]: (request, env) => handleSchedulerDispatch(request, env),
|
|
2385
3180
|
[CRON_JOBS_RUN_PATH]: (request, env) => handleRunCronJob(request, env),
|
|
3181
|
+
// Mint a short-lived HMAC-signed WS admin sub-token. Gated by the master
|
|
3182
|
+
// admin bearer (header) / `adminGate`; the studio then sends the minted
|
|
3183
|
+
// token — not the master credential — in the WS `?token=`
|
|
3184
|
+
// query string. Signed with the master token itself, so both isolates
|
|
3185
|
+
// verify statelessly and rotating `LUNORA_ADMIN_TOKEN` invalidates every
|
|
3186
|
+
// outstanding sub-token. `no-store` keeps the token out of caches.
|
|
3187
|
+
[ADMIN_WS_TOKEN_PATH]: async (request) => {
|
|
3188
|
+
if (request.method !== "POST") {
|
|
3189
|
+
throw new LunoraError("ws-token endpoint requires POST", { code: "METHOD_NOT_ALLOWED", status: 405 });
|
|
3190
|
+
}
|
|
3191
|
+
assertAdminAuthorized(request);
|
|
3192
|
+
const signingSecret = effectiveAdminToken();
|
|
3193
|
+
if (signingSecret === void 0) {
|
|
3194
|
+
throw new LunoraError("ws-token minting requires a configured admin token", { code: "ADMIN_TOKEN_NOT_CONFIGURED", status: 400 });
|
|
3195
|
+
}
|
|
3196
|
+
const minted = await mintWsAdminToken(signingSecret);
|
|
3197
|
+
return Response.json(minted, { headers: { "cache-control": "no-store" } });
|
|
3198
|
+
},
|
|
2386
3199
|
// Extracted handler clusters built above, merged in (mirroring the auth
|
|
2387
3200
|
// plane below): orchestration (migrate / rank / rankpage / shard-traffic /
|
|
2388
3201
|
// pitr), data-movement (export / import / sync / connector-sync / apply),
|
|
@@ -2394,13 +3207,13 @@ const createWorker = (options) => {
|
|
|
2394
3207
|
...workflowsAdminRoutes,
|
|
2395
3208
|
...storageAdminRoutes,
|
|
2396
3209
|
...vectorAdminRoutes,
|
|
3210
|
+
...kvAdminRoutes,
|
|
2397
3211
|
...introspectionAdminRoutes,
|
|
2398
3212
|
// `/_lunora/admin/auth/*` — the whole user-management plane, one route per
|
|
2399
3213
|
// `AuthAdmin` op, dispatched by the descriptor table in `./auth-admin-routes`.
|
|
2400
3214
|
...buildAuthAdminRoutes({
|
|
2401
3215
|
assertAdmin: assertAdminAuthorized,
|
|
2402
|
-
|
|
2403
|
-
getAuthAdmin: () => options.authAdmin ?? options.authIntrospector,
|
|
3216
|
+
getAuthAdmin: () => options.authAdmin,
|
|
2404
3217
|
parsePaging,
|
|
2405
3218
|
queryParameter,
|
|
2406
3219
|
readJsonBody: readJsonBodyWithLimit
|
|
@@ -2414,11 +3227,23 @@ const createWorker = (options) => {
|
|
|
2414
3227
|
resolvedSecurity = resolveSecurity(options.security, env ?? {});
|
|
2415
3228
|
}
|
|
2416
3229
|
};
|
|
3230
|
+
const applyAdminGate = async (request, pathname) => {
|
|
3231
|
+
if (options.adminGate === void 0 || !isAdminPath(pathname)) {
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
try {
|
|
3235
|
+
if (await options.adminGate(request)) {
|
|
3236
|
+
accessAdminGrants.add(request);
|
|
3237
|
+
}
|
|
3238
|
+
} catch {
|
|
3239
|
+
}
|
|
3240
|
+
};
|
|
2417
3241
|
const handle = async (request, env, context) => {
|
|
2418
3242
|
const url = new URL(request.url);
|
|
2419
3243
|
if (request.method === "POST" || request.method === "PUT") {
|
|
2420
3244
|
const contentLength = Number(request.headers.get("content-length") ?? "");
|
|
2421
|
-
|
|
3245
|
+
const maxBodyBytes = url.pathname === KV_VALUE_PATH ? KV_VALUE_MAX_BODY_BYTES : MAX_BODY_BYTES;
|
|
3246
|
+
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
2422
3247
|
throw new LunoraError("Body too large", { code: "PAYLOAD_TOO_LARGE", status: 413 });
|
|
2423
3248
|
}
|
|
2424
3249
|
}
|
|
@@ -2435,8 +3260,12 @@ const createWorker = (options) => {
|
|
|
2435
3260
|
}
|
|
2436
3261
|
const internalRoute = internalRoutes[url.pathname];
|
|
2437
3262
|
if (internalRoute) {
|
|
3263
|
+
await applyAdminGate(request, url.pathname);
|
|
2438
3264
|
return internalRoute(request, env, url, context);
|
|
2439
3265
|
}
|
|
3266
|
+
if (options.voiceAgents !== void 0 && url.pathname.startsWith(VOICE_PATH_PREFIX)) {
|
|
3267
|
+
return handleVoiceUpgrade(request, env, url);
|
|
3268
|
+
}
|
|
2440
3269
|
const httpRouteResponse = await dispatchHttpRoute(request, env, context);
|
|
2441
3270
|
if (httpRouteResponse) {
|
|
2442
3271
|
return httpRouteResponse;
|
|
@@ -2446,7 +3275,7 @@ const createWorker = (options) => {
|
|
|
2446
3275
|
return {
|
|
2447
3276
|
async fetch(request, env, context) {
|
|
2448
3277
|
if (options.passThroughOnException) {
|
|
2449
|
-
context.passThroughOnException();
|
|
3278
|
+
context.passThroughOnException?.();
|
|
2450
3279
|
}
|
|
2451
3280
|
ensureSecurityResolved(env);
|
|
2452
3281
|
resolveAdminTokenFromEnv(env);
|
|
@@ -2465,6 +3294,9 @@ const createWorker = (options) => {
|
|
|
2465
3294
|
return decorateResponse(toErrorResponse(error), request, resolvedSecurity);
|
|
2466
3295
|
}
|
|
2467
3296
|
},
|
|
3297
|
+
async queue(batch, env, context) {
|
|
3298
|
+
await options.queue?.(batch, env, context);
|
|
3299
|
+
},
|
|
2468
3300
|
async scheduled(controller, env, context) {
|
|
2469
3301
|
await handleScheduled(controller, env, context);
|
|
2470
3302
|
},
|
|
@@ -2495,10 +3327,24 @@ const withFrameworkWorker = (host, optionsInput) => {
|
|
|
2495
3327
|
const optionsFactory = optionsInput;
|
|
2496
3328
|
return {
|
|
2497
3329
|
fetch: (request, env, context) => build(optionsFactory(env)).fetch(request, env, context),
|
|
3330
|
+
queue: (batch, env, context) => build(optionsFactory(env)).queue?.(batch, env, context) ?? Promise.resolve(),
|
|
2498
3331
|
scheduled: (controller, env, context) => build(optionsFactory(env)).scheduled(controller, env, context),
|
|
2499
3332
|
serverQuery: (request, env, reference, args, options) => build(optionsFactory(env)).serverQuery(request, env, reference, args, options)
|
|
2500
3333
|
};
|
|
2501
3334
|
};
|
|
3335
|
+
const resolveLunoraOptions = (options, env) => {
|
|
3336
|
+
if (typeof options === "function") {
|
|
3337
|
+
return options(env);
|
|
3338
|
+
}
|
|
3339
|
+
const shardDO = options.shardDO ?? env?.SHARD;
|
|
3340
|
+
if (!shardDO) {
|
|
3341
|
+
throw new LunoraError(
|
|
3342
|
+
"@lunora/runtime: no shard Durable Object namespace found. Bind `SHARD` in wrangler.jsonc, or pass `createLunoraHandler({ shardDO: env.MY_SHARD })`."
|
|
3343
|
+
);
|
|
3344
|
+
}
|
|
3345
|
+
return { ...options, shardDO };
|
|
3346
|
+
};
|
|
3347
|
+
const createLunoraHandler = (options = {}) => (request, env, context) => createWorker(resolveLunoraOptions(options, env)).fetch(request, env, context ?? NOOP_EXECUTION_CONTEXT);
|
|
2502
3348
|
const defineRpcEnvelope = (envelope) => envelope;
|
|
2503
3349
|
|
|
2504
|
-
export { composeWorker, createWorker, defineRpcEnvelope, withFrameworkWorker };
|
|
3350
|
+
export { NOOP_EXECUTION_CONTEXT, composeWorker, createLunoraHandler, createWorker, defineRpcEnvelope, probeRelayCount, resolveLunoraOptions, withFrameworkWorker };
|