@lunora/runtime 1.0.0-alpha.2 → 1.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,20 @@
1
- import { LunoraError, toErrorResponse, isStructuralLunoraError, isStructuralConflictError } from './LunoraError-CL0aOtpo.mjs';
1
+ import { isLunoraError, toErrorBody } from '@lunora/errors';
2
+ import { NOOP_EXECUTION_CONTEXT } from './NOOP_EXECUTION_CONTEXT-CCTu0Bf1.mjs';
3
+ import { LunoraError, toErrorResponse } from './LunoraError-Bpb9EFJ3.mjs';
4
+ import { wrapResolverWithContract } from './composeIdentityResolvers-XGjO7V1J.mjs';
5
+ export { composeIdentityResolvers, routeIdentityResolvers } from './composeIdentityResolvers-XGjO7V1J.mjs';
2
6
  import { emitRpcEvent } from './emitLogEvent-pEdtqAK8.mjs';
3
- import { resolveShard } from './resolveShard-DDkzWtrU.mjs';
4
- import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse } from './decorateResponse-DbISh_Wi.mjs';
7
+ import { resolveShard, applyJurisdiction } from './applyJurisdiction-BkZtTkct.mjs';
8
+ import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse, enforceWebSocketOrigin } from './decorateResponse-CsZc49QC.mjs';
9
+
10
+ const RELAY_NAME_INFIX = "::relay::";
11
+ const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
5
12
 
6
13
  const AUTH_BASE = "/_lunora/admin/auth";
7
14
  const AUTH_ADMIN_ERROR_STATUS = {
15
+ INVITER_REQUIRED: 400,
16
+ ORG_SLUG_INVALID: 400,
17
+ ORG_SLUG_TAKEN: 409,
8
18
  PASSWORD_TOO_LONG: 400,
9
19
  PASSWORD_TOO_SHORT: 400,
10
20
  USER_ALREADY_EXISTS: 409,
@@ -34,6 +44,23 @@ const parseRoleInput = (value) => {
34
44
  return void 0;
35
45
  };
36
46
  const optionalBodyString = (body, field) => typeof body[field] === "string" ? body[field] : void 0;
47
+ const optionalBodyObject = (body, field) => {
48
+ const value = body[field];
49
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
50
+ };
51
+ const requirePermission = (body) => {
52
+ const value = body["permission"];
53
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
54
+ throw new LunoraError("`permission` object is required", { code: "BAD_REQUEST", status: 400 });
55
+ }
56
+ const out = {};
57
+ for (const [resource, actions] of Object.entries(value)) {
58
+ if (Array.isArray(actions) && actions.every((action) => typeof action === "string")) {
59
+ out[resource] = actions;
60
+ }
61
+ }
62
+ return out;
63
+ };
37
64
  const AUTH_ROUTES = {
38
65
  [`${AUTH_BASE}/capabilities`]: {
39
66
  build: () => {
@@ -100,6 +127,34 @@ const AUTH_ROUTES = {
100
127
  http: "GET",
101
128
  method: "listInvitations"
102
129
  },
130
+ [`${AUTH_BASE}/config`]: {
131
+ build: () => {
132
+ return {};
133
+ },
134
+ http: "GET",
135
+ method: "config"
136
+ },
137
+ [`${AUTH_BASE}/organizations/teams`]: {
138
+ build: ({ paging, query }) => {
139
+ return { ...paging, organizationId: requireQuery$1(query, "organizationId") };
140
+ },
141
+ http: "GET",
142
+ method: "listTeams"
143
+ },
144
+ [`${AUTH_BASE}/organizations/teams/members`]: {
145
+ build: ({ paging, query }) => {
146
+ return { ...paging, teamId: requireQuery$1(query, "teamId") };
147
+ },
148
+ http: "GET",
149
+ method: "listTeamMembers"
150
+ },
151
+ [`${AUTH_BASE}/organizations/roles`]: {
152
+ build: ({ paging, query }) => {
153
+ return { ...paging, organizationId: requireQuery$1(query, "organizationId") };
154
+ },
155
+ http: "GET",
156
+ method: "listOrgRoles"
157
+ },
103
158
  // --- mutations (POST) -------------------------------------------------------
104
159
  [`${AUTH_BASE}/users/create`]: {
105
160
  build: ({ body }) => {
@@ -232,6 +287,137 @@ const AUTH_ROUTES = {
232
287
  http: "POST",
233
288
  method: "cancelInvitation",
234
289
  returns: "void"
290
+ },
291
+ [`${AUTH_BASE}/organizations/create`]: {
292
+ build: ({ body }) => {
293
+ return {
294
+ logo: optionalBodyString(body, "logo"),
295
+ metadata: optionalBodyObject(body, "metadata"),
296
+ name: requireBodyString(body, "name"),
297
+ ownerId: optionalBodyString(body, "ownerId"),
298
+ slug: optionalBodyString(body, "slug")
299
+ };
300
+ },
301
+ http: "POST",
302
+ method: "createOrganization"
303
+ },
304
+ [`${AUTH_BASE}/organizations/update`]: {
305
+ build: ({ body }) => {
306
+ return {
307
+ logo: optionalBodyString(body, "logo"),
308
+ metadata: optionalBodyObject(body, "metadata"),
309
+ name: optionalBodyString(body, "name"),
310
+ organizationId: requireBodyString(body, "organizationId"),
311
+ slug: optionalBodyString(body, "slug")
312
+ };
313
+ },
314
+ http: "POST",
315
+ method: "updateOrganization"
316
+ },
317
+ [`${AUTH_BASE}/organizations/remove`]: {
318
+ build: ({ body }) => {
319
+ return { organizationId: requireBodyString(body, "organizationId") };
320
+ },
321
+ http: "POST",
322
+ method: "deleteOrganization",
323
+ returns: "void"
324
+ },
325
+ [`${AUTH_BASE}/organizations/members/add`]: {
326
+ build: ({ body }) => {
327
+ return {
328
+ organizationId: requireBodyString(body, "organizationId"),
329
+ role: optionalBodyString(body, "role"),
330
+ userId: requireBodyString(body, "userId")
331
+ };
332
+ },
333
+ http: "POST",
334
+ method: "addMember"
335
+ },
336
+ [`${AUTH_BASE}/organizations/members/invite`]: {
337
+ build: ({ body }) => {
338
+ return {
339
+ email: requireBodyString(body, "email"),
340
+ inviterId: optionalBodyString(body, "inviterId"),
341
+ organizationId: requireBodyString(body, "organizationId"),
342
+ role: optionalBodyString(body, "role")
343
+ };
344
+ },
345
+ http: "POST",
346
+ method: "inviteMember"
347
+ },
348
+ [`${AUTH_BASE}/organizations/members/role`]: {
349
+ build: ({ body }) => {
350
+ const role = parseRoleInput(body["role"]);
351
+ if (role === void 0 || typeof role === "string" && role.trim() === "") {
352
+ throw new LunoraError("`role` is required", { code: "BAD_REQUEST", status: 400 });
353
+ }
354
+ return { memberId: requireBodyString(body, "memberId"), role };
355
+ },
356
+ http: "POST",
357
+ method: "updateMemberRole"
358
+ },
359
+ [`${AUTH_BASE}/organizations/teams/create`]: {
360
+ build: ({ body }) => {
361
+ return { name: requireBodyString(body, "name"), organizationId: requireBodyString(body, "organizationId") };
362
+ },
363
+ http: "POST",
364
+ method: "createTeam"
365
+ },
366
+ [`${AUTH_BASE}/organizations/teams/update`]: {
367
+ build: ({ body }) => {
368
+ return { name: requireBodyString(body, "name"), teamId: requireBodyString(body, "teamId") };
369
+ },
370
+ http: "POST",
371
+ method: "updateTeam"
372
+ },
373
+ [`${AUTH_BASE}/organizations/teams/remove`]: {
374
+ build: ({ body }) => {
375
+ return { teamId: requireBodyString(body, "teamId") };
376
+ },
377
+ http: "POST",
378
+ method: "removeTeam",
379
+ returns: "void"
380
+ },
381
+ [`${AUTH_BASE}/organizations/teams/members/add`]: {
382
+ build: ({ body }) => {
383
+ return { teamId: requireBodyString(body, "teamId"), userId: requireBodyString(body, "userId") };
384
+ },
385
+ http: "POST",
386
+ method: "addTeamMember"
387
+ },
388
+ [`${AUTH_BASE}/organizations/teams/members/remove`]: {
389
+ build: ({ body }) => {
390
+ return { teamMemberId: requireBodyString(body, "teamMemberId") };
391
+ },
392
+ http: "POST",
393
+ method: "removeTeamMember",
394
+ returns: "void"
395
+ },
396
+ [`${AUTH_BASE}/organizations/roles/create`]: {
397
+ build: ({ body }) => {
398
+ return {
399
+ organizationId: requireBodyString(body, "organizationId"),
400
+ permission: requirePermission(body),
401
+ role: requireBodyString(body, "role")
402
+ };
403
+ },
404
+ http: "POST",
405
+ method: "createOrgRole"
406
+ },
407
+ [`${AUTH_BASE}/organizations/roles/update`]: {
408
+ build: ({ body }) => {
409
+ return { permission: requirePermission(body), roleId: requireBodyString(body, "roleId") };
410
+ },
411
+ http: "POST",
412
+ method: "updateOrgRole"
413
+ },
414
+ [`${AUTH_BASE}/organizations/roles/remove`]: {
415
+ build: ({ body }) => {
416
+ return { roleId: requireBodyString(body, "roleId") };
417
+ },
418
+ http: "POST",
419
+ method: "deleteOrgRole",
420
+ returns: "void"
235
421
  }
236
422
  };
237
423
  const buildAuthAdminRoutes = (deps) => {
@@ -244,15 +430,15 @@ const buildAuthAdminRoutes = (deps) => {
244
430
  }
245
431
  const candidate = error;
246
432
  const code = typeof candidate.code === "string" ? candidate.code : "AUTH_ADMIN_ERROR";
247
- const message = typeof candidate.message === "string" ? candidate.message : "auth admin operation failed";
248
- throw new LunoraError(message, { code, status: AUTH_ADMIN_ERROR_STATUS[code] ?? 400 });
433
+ console.error("[lunora] auth admin operation failed:", error);
434
+ throw new LunoraError("auth admin operation failed", { code, status: AUTH_ADMIN_ERROR_STATUS[code] ?? 500 });
249
435
  }
250
436
  };
251
437
  const handle = async (request, descriptor) => {
438
+ deps.assertAdmin(request);
252
439
  if (request.method !== descriptor.http) {
253
440
  throw new LunoraError(`Auth admin endpoint requires ${descriptor.http}`, { code: "METHOD_NOT_ALLOWED", status: 405 });
254
441
  }
255
- deps.assertAdmin(request);
256
442
  const admin = deps.getAuthAdmin();
257
443
  if (admin === void 0) {
258
444
  throw new LunoraError("auth endpoints require an `authAdmin` on the worker", { code: "AUTH_NOT_CONFIGURED", status: 400 });
@@ -278,6 +464,45 @@ const buildAuthAdminRoutes = (deps) => {
278
464
  return routes;
279
465
  };
280
466
 
467
+ const MAX_BATCH_ENTRIES = 500;
468
+
469
+ const normalizeBatchCall = (raw, index, defaultShard) => {
470
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
471
+ throw new LunoraError("each batch call must be an object", { code: "BAD_REQUEST", status: 400 });
472
+ }
473
+ const call = raw;
474
+ if (typeof call.functionPath !== "string") {
475
+ throw new LunoraError("each batch call needs a string `functionPath`", { code: "BAD_REQUEST", status: 400 });
476
+ }
477
+ if (call.functionPath.startsWith("__lunora_relation__:") || call.functionPath.startsWith("__lunora_admin__")) {
478
+ throw new LunoraError("reserved function path cannot be batched", { code: "FORBIDDEN", status: 403 });
479
+ }
480
+ return {
481
+ entry: {
482
+ args: call.args === void 0 ? {} : call.args,
483
+ clientId: typeof call.clientId === "string" ? call.clientId : void 0,
484
+ clientSeq: typeof call.clientSeq === "number" ? call.clientSeq : void 0,
485
+ functionPath: call.functionPath,
486
+ id: typeof call.id === "number" ? call.id : index,
487
+ mutationId: typeof call.mutationId === "string" ? call.mutationId : void 0
488
+ },
489
+ shardKey: typeof call.shardKey === "string" ? call.shardKey : defaultShard
490
+ };
491
+ };
492
+ const groupBatchCallsByShard = (calls, defaultShard) => {
493
+ if (calls.length > MAX_BATCH_ENTRIES) {
494
+ throw new LunoraError(`RPC batch exceeds the ${String(MAX_BATCH_ENTRIES)}-call limit`, { code: "BAD_REQUEST", status: 400 });
495
+ }
496
+ const groups = /* @__PURE__ */ new Map();
497
+ for (const [index, raw] of calls.entries()) {
498
+ const { entry, shardKey } = normalizeBatchCall(raw, index, defaultShard);
499
+ const group = groups.get(shardKey) ?? [];
500
+ group.push(entry);
501
+ groups.set(shardKey, group);
502
+ }
503
+ return groups;
504
+ };
505
+
281
506
  const MAX_BODY_BYTES = 1048576;
282
507
  const readBodyTextWithLimit = async (request, limit = MAX_BODY_BYTES) => {
283
508
  if (!request.body) {
@@ -335,9 +560,9 @@ const readBodyBytesWithLimit = async (request, limit = MAX_BODY_BYTES) => {
335
560
  }
336
561
  return out.buffer;
337
562
  };
338
- const readJsonBodyWithLimit = async (request) => {
563
+ const readJsonBodyWithLimit = async (request, limit = MAX_BODY_BYTES) => {
339
564
  try {
340
- const text = await readBodyTextWithLimit(request);
565
+ const text = await readBodyTextWithLimit(request, limit);
341
566
  return text === "" ? {} : JSON.parse(text);
342
567
  } catch (error) {
343
568
  if (error instanceof LunoraError) {
@@ -597,14 +822,14 @@ const partitionExportTables = (options, tables) => {
597
822
  }
598
823
  return { globalTables, shardLocalTables };
599
824
  };
600
- const exportShardLocalRows = async (options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow) => {
825
+ const exportShardLocalRows = async (options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow, namespace) => {
601
826
  if (tables !== void 0 && shardLocalTables.length === 0) {
602
827
  return;
603
828
  }
604
829
  const exportTables = tables === void 0 ? [] : shardLocalTables;
605
830
  const probeFallback = tables === void 0 ? collectKnownTables() : [];
606
831
  const probeTables = exportTables.length > 0 ? exportTables : probeFallback;
607
- const result = await coordinator.orchestrateExport(options.shardDO, {
832
+ const result = await coordinator.orchestrateExport(namespace, {
608
833
  args: { tables: exportTables },
609
834
  headers: forwardedHeaders,
610
835
  tables: probeTables
@@ -618,9 +843,9 @@ const exportShardLocalRows = async (options, coordinator, forwardedHeaders, tabl
618
843
  }
619
844
  }
620
845
  };
621
- const streamExportRows = async (options, coordinator, forwardedHeaders, tables, writeRow) => {
846
+ const streamExportRows = async (options, coordinator, forwardedHeaders, tables, writeRow, namespace) => {
622
847
  const { globalTables, shardLocalTables } = partitionExportTables(options, tables);
623
- await exportShardLocalRows(options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow);
848
+ await exportShardLocalRows(options, coordinator, forwardedHeaders, tables, shardLocalTables, writeRow, namespace);
624
849
  const exportGlobalsFunction = options.exportGlobals;
625
850
  const wantGlobals = tables === void 0 || globalTables.length > 0;
626
851
  if (wantGlobals && exportGlobalsFunction) {
@@ -740,7 +965,7 @@ const mergeImportResult = (totals, result) => {
740
965
  }
741
966
  totals.conflicts += result.conflicts;
742
967
  };
743
- const streamingImport = async (request, options, forwardedHeaders) => {
968
+ const streamingImport = async (request, options, forwardedHeaders, namespace) => {
744
969
  const defaultShard = options.defaultShardKey ?? "__root__";
745
970
  const { errors, globalRows, perShard } = await bucketImportStream(request, options, defaultShard);
746
971
  const totals = { conflicts: 0, errors, inserted: {} };
@@ -749,7 +974,7 @@ const streamingImport = async (request, options, forwardedHeaders) => {
749
974
  if (!coordinator) {
750
975
  throw new LunoraError("Import endpoint requires a `queryCoordinator` on the worker", { code: "BAD_REQUEST", status: 400 });
751
976
  }
752
- const result = await coordinator.orchestrateImport(options.shardDO, {
977
+ const result = await coordinator.orchestrateImport(namespace, {
753
978
  batches: [...perShard.values()],
754
979
  headers: forwardedHeaders
755
980
  });
@@ -966,7 +1191,129 @@ const buildIntrospectionAdminRoutes = (deps) => {
966
1191
  };
967
1192
  };
968
1193
 
969
- const MIGRATE_PATH = "/_lunora/migrate";
1194
+ const KV_NAMESPACES_PATH = "/_lunora/admin/kv/namespaces";
1195
+ const KV_KEYS_PATH = "/_lunora/admin/kv/keys";
1196
+ const KV_VALUE_PATH = "/_lunora/admin/kv/value";
1197
+ const KV_VALUE_MAX_BODY_BYTES = 32 * 1048576;
1198
+ const KV_MIN_EXPIRATION_SECONDS = 60;
1199
+ const buildKvAdminRoutes = (deps) => {
1200
+ const { readJsonBody, requireAdminOption } = deps;
1201
+ const gate = (request) => requireAdminOption(request, deps.kvIntrospector, {
1202
+ code: "KV_NOT_CONFIGURED",
1203
+ message: "KV endpoints require a `kvIntrospector` on the worker"
1204
+ });
1205
+ const ok = (payload) => Response.json(payload, { headers: { "content-type": "application/json" }, status: 200 });
1206
+ const requireNamespaceAndKey = (request, verb) => {
1207
+ const url = new URL(request.url);
1208
+ const namespace = url.searchParams.get("namespace") ?? "";
1209
+ const key = url.searchParams.get("key") ?? "";
1210
+ if (namespace === "") {
1211
+ throw new LunoraError(`KV-value ${verb} request requires a \`namespace\` query parameter`, { code: "BAD_REQUEST", status: 400 });
1212
+ }
1213
+ if (key === "") {
1214
+ throw new LunoraError(`KV-value ${verb} request requires a \`key\` query parameter`, { code: "BAD_REQUEST", status: 400 });
1215
+ }
1216
+ return { key, namespace };
1217
+ };
1218
+ const requireKnownNamespace = async (introspector, namespace) => {
1219
+ const namespaces = await introspector.listNamespaces();
1220
+ if (!namespaces.some((entry) => entry.binding === namespace)) {
1221
+ throw new LunoraError(`Unknown KV namespace binding \`${namespace}\``, { code: "NOT_FOUND", status: 404 });
1222
+ }
1223
+ };
1224
+ const handleKvNamespaces = async (request) => {
1225
+ if (request.method !== "GET") {
1226
+ throw new LunoraError("KV-namespaces endpoint requires GET", { code: "METHOD_NOT_ALLOWED", status: 405 });
1227
+ }
1228
+ return ok({ namespaces: await gate(request).listNamespaces() });
1229
+ };
1230
+ const handleKvKeys = async (request) => {
1231
+ if (request.method !== "GET") {
1232
+ throw new LunoraError("KV-keys endpoint requires GET", { code: "METHOD_NOT_ALLOWED", status: 405 });
1233
+ }
1234
+ const introspector = gate(request);
1235
+ const url = new URL(request.url);
1236
+ const namespace = url.searchParams.get("namespace") ?? "";
1237
+ if (namespace === "") {
1238
+ throw new LunoraError("KV-keys request requires a `namespace` query parameter", { code: "BAD_REQUEST", status: 400 });
1239
+ }
1240
+ const prefix = url.searchParams.get("prefix") ?? void 0;
1241
+ const cursor = url.searchParams.get("cursor") ?? void 0;
1242
+ const limitRaw = url.searchParams.get("limit");
1243
+ const parsedLimit = limitRaw === null ? void 0 : Number.parseInt(limitRaw, 10);
1244
+ if (parsedLimit !== void 0 && (!Number.isInteger(parsedLimit) || parsedLimit < 1)) {
1245
+ throw new LunoraError("KV-keys `limit` must be a positive integer", { code: "BAD_REQUEST", status: 400 });
1246
+ }
1247
+ const limit = parsedLimit === void 0 ? void 0 : Math.min(parsedLimit, 1e3);
1248
+ await requireKnownNamespace(introspector, namespace);
1249
+ return ok(await introspector.listKeys({ cursor, limit, namespace, prefix }));
1250
+ };
1251
+ const handleKvValueGet = async (request) => {
1252
+ const introspector = gate(request);
1253
+ const params = requireNamespaceAndKey(request, "GET");
1254
+ await requireKnownNamespace(introspector, params.namespace);
1255
+ return ok(await introspector.getValue(params));
1256
+ };
1257
+ const handleKvValuePut = async (request) => {
1258
+ const introspector = gate(request);
1259
+ const candidate = await readJsonBody(request, KV_VALUE_MAX_BODY_BYTES);
1260
+ if (typeof candidate.namespace !== "string" || candidate.namespace === "") {
1261
+ throw new LunoraError("KV-value PUT request requires a `namespace` string", { code: "BAD_REQUEST", status: 400 });
1262
+ }
1263
+ if (typeof candidate.key !== "string" || candidate.key === "") {
1264
+ throw new LunoraError("KV-value PUT request requires a `key` string", { code: "BAD_REQUEST", status: 400 });
1265
+ }
1266
+ if (typeof candidate.value !== "string") {
1267
+ throw new LunoraError("KV-value PUT request requires a `value` string", { code: "BAD_REQUEST", status: 400 });
1268
+ }
1269
+ if (candidate.expirationTtl !== void 0 && (typeof candidate.expirationTtl !== "number" || !Number.isInteger(candidate.expirationTtl) || candidate.expirationTtl < KV_MIN_EXPIRATION_SECONDS)) {
1270
+ throw new LunoraError("KV-value PUT `expirationTtl` must be an integer ≥ 60", { code: "BAD_REQUEST", status: 400 });
1271
+ }
1272
+ const minExpiration = Math.floor(Date.now() / 1e3) + KV_MIN_EXPIRATION_SECONDS;
1273
+ if (candidate.expiration !== void 0 && (typeof candidate.expiration !== "number" || !Number.isInteger(candidate.expiration) || candidate.expiration < minExpiration)) {
1274
+ throw new LunoraError("KV-value PUT `expiration` must be a Unix-seconds timestamp at least 60 seconds in the future", {
1275
+ code: "BAD_REQUEST",
1276
+ status: 400
1277
+ });
1278
+ }
1279
+ await requireKnownNamespace(introspector, candidate.namespace);
1280
+ await introspector.putValue({
1281
+ expiration: candidate.expiration,
1282
+ expirationTtl: candidate.expirationTtl,
1283
+ key: candidate.key,
1284
+ metadata: candidate.metadata,
1285
+ namespace: candidate.namespace,
1286
+ value: candidate.value
1287
+ });
1288
+ return ok({ ok: true });
1289
+ };
1290
+ const handleKvValueDelete = async (request) => {
1291
+ const introspector = gate(request);
1292
+ const params = requireNamespaceAndKey(request, "DELETE");
1293
+ await requireKnownNamespace(introspector, params.namespace);
1294
+ await introspector.deleteKey(params);
1295
+ return ok({ deleted: true });
1296
+ };
1297
+ const kvValueHandlers = {
1298
+ DELETE: handleKvValueDelete,
1299
+ GET: handleKvValueGet,
1300
+ PUT: handleKvValuePut
1301
+ };
1302
+ const handleKvValue = (request) => {
1303
+ const handler = kvValueHandlers[request.method];
1304
+ if (!handler) {
1305
+ throw new LunoraError("KV-value endpoint requires GET, PUT, or DELETE", { code: "METHOD_NOT_ALLOWED", status: 405 });
1306
+ }
1307
+ return handler(request);
1308
+ };
1309
+ return {
1310
+ [KV_NAMESPACES_PATH]: handleKvNamespaces,
1311
+ [KV_KEYS_PATH]: handleKvKeys,
1312
+ [KV_VALUE_PATH]: handleKvValue
1313
+ };
1314
+ };
1315
+
1316
+ const MIGRATE_PATH$1 = "/_lunora/migrate";
970
1317
  const PITR_PATH = "/_lunora/admin/pitr";
971
1318
  const RANK_PATH = "/_lunora/admin/rank";
972
1319
  const RANKPAGE_PATH = "/_lunora/admin/rankpage";
@@ -1223,7 +1570,7 @@ const buildOrchestrationAdminRoutes = (deps) => {
1223
1570
  return forwardToShard(shardDO, pitr.shardKey ?? defaultShard, forwarded);
1224
1571
  };
1225
1572
  return {
1226
- [MIGRATE_PATH]: handleMigrate,
1573
+ [MIGRATE_PATH$1]: handleMigrate,
1227
1574
  [PITR_PATH]: handlePitr,
1228
1575
  [RANK_PATH]: handleRank,
1229
1576
  [RANKPAGE_PATH]: handleRankPage,
@@ -1502,7 +1849,7 @@ const buildWorkflowsAdminRoutes = (deps) => {
1502
1849
  assertAdmin(request);
1503
1850
  const client = resolveWorkflowsClient(env);
1504
1851
  if (!client) {
1505
- return throwNotConfigured();
1852
+ return Response.json({ configured: false, instances: [], page: 1, perPage: 0, totalCount: 0 });
1506
1853
  }
1507
1854
  const workflowName = requireQuery(url, "name");
1508
1855
  const status = toInstanceStatus(url.searchParams.get("status"));
@@ -1554,9 +1901,14 @@ const buildWorkflowsAdminRoutes = (deps) => {
1554
1901
 
1555
1902
  const NDJSON_ENCODER = new TextEncoder();
1556
1903
  const RPC_PATH = "/_lunora/rpc";
1904
+ const RPC_BATCH_PATH = "/_lunora/rpc-batch";
1557
1905
  const WS_PATH = "/_lunora/ws";
1558
1906
  const SCHEDULER_DISPATCH_PATH = "/_lunora/scheduler/dispatch";
1559
1907
  const CRON_JOBS_RUN_PATH = "/_lunora/admin/cron-jobs/run";
1908
+ const ADMIN_PATH_PREFIX = "/_lunora/admin/";
1909
+ const MIGRATE_PATH = "/_lunora/migrate";
1910
+ const STATUS_PATH = "/_lunora/status";
1911
+ const isAdminPath = (pathname) => pathname.startsWith(ADMIN_PATH_PREFIX) || pathname === MIGRATE_PATH;
1560
1912
  const DEFAULT_AUTH_BASE_PATH = "/api/auth";
1561
1913
  const RECORD_AUTH_EVENT_OP = "__lunora_admin__:recordAuthEvent";
1562
1914
  const AUTH_ATTEMPT_SEGMENTS = ["/sign-in", "/sign-up", "/callback"];
@@ -1569,7 +1921,7 @@ const isAuthAttemptPath = (pathname, basePath) => {
1569
1921
  return AUTH_ATTEMPT_SEGMENTS.some((segment) => suffix === segment || suffix.startsWith(`${segment}/`));
1570
1922
  };
1571
1923
  const buildErrorEvent = (functionPath, durationMs, error, extra) => {
1572
- const mappable = error instanceof LunoraError || isStructuralLunoraError(error) || isStructuralConflictError(error);
1924
+ const mappable = isLunoraError(error);
1573
1925
  const code = mappable ? error.code : "INTERNAL_SERVER_ERROR";
1574
1926
  const status = mappable ? error.status : 500;
1575
1927
  const message = error instanceof Error ? error.message : String(error);
@@ -1598,6 +1950,8 @@ const resolveForwardContext = async (request, env, resolveIdentity) => {
1598
1950
  const cookie = request.headers.get("cookie");
1599
1951
  const bookmark = request.headers.get("x-d1-bookmark");
1600
1952
  const mutationId = request.headers.get("x-lunora-mutation-id");
1953
+ const clientId = request.headers.get("x-lunora-client-id");
1954
+ const clientSeq = request.headers.get("x-lunora-client-seq");
1601
1955
  if (authorization) {
1602
1956
  headers["authorization"] = authorization;
1603
1957
  }
@@ -1610,6 +1964,12 @@ const resolveForwardContext = async (request, env, resolveIdentity) => {
1610
1964
  if (mutationId) {
1611
1965
  headers["x-lunora-mutation-id"] = mutationId;
1612
1966
  }
1967
+ if (clientId) {
1968
+ headers["x-lunora-client-id"] = clientId;
1969
+ }
1970
+ if (clientSeq) {
1971
+ headers["x-lunora-client-seq"] = clientSeq;
1972
+ }
1613
1973
  const clientIp = request.headers.get("cf-connecting-ip");
1614
1974
  if (clientIp) {
1615
1975
  headers["x-lunora-client-ip"] = clientIp;
@@ -1662,6 +2022,12 @@ const validateFanOut = (fanOut) => {
1662
2022
  }
1663
2023
  return spec;
1664
2024
  };
2025
+ const logRpcDebug = (env, envelope) => {
2026
+ if (!env?.LUNORA_DEBUG_RPC) {
2027
+ return;
2028
+ }
2029
+ console.warn(`[lunora:rpc] ${envelope.fanOut ? "fan-out" : `shard=${envelope.shardKey ?? "(root)"}`} ${envelope.functionPath}`);
2030
+ };
1665
2031
  const parseEnvelope = async (request) => {
1666
2032
  const text = await readBodyTextWithLimit(request);
1667
2033
  let body;
@@ -1681,9 +2047,18 @@ const parseEnvelope = async (request) => {
1681
2047
  throw new LunoraError("RPC `shardKey` must be a string", { code: "BAD_REQUEST", status: 400 });
1682
2048
  }
1683
2049
  const envelope = body;
2050
+ const fanOut = validateFanOut(envelope.fanOut);
2051
+ const args = envelope.args ?? {};
2052
+ if (fanOut && envelope.functionPath.startsWith("__lunora_relation__:")) {
2053
+ const requestedTable = args.table;
2054
+ if (typeof requestedTable === "string" && requestedTable !== fanOut.table) {
2055
+ throw new LunoraError("RPC `args.table` must match the authorized `fanOut.table` for a relation fan-out", { code: "BAD_REQUEST", status: 400 });
2056
+ }
2057
+ args.table = fanOut.table;
2058
+ }
1684
2059
  return {
1685
- args: envelope.args ?? {},
1686
- fanOut: validateFanOut(envelope.fanOut),
2060
+ args,
2061
+ fanOut,
1687
2062
  functionPath: envelope.functionPath,
1688
2063
  shardKey: envelope.shardKey
1689
2064
  };
@@ -1692,6 +2067,41 @@ const forwardToShard = async (namespace, shardKey, request) => {
1692
2067
  const stub = resolveShard(namespace, shardKey);
1693
2068
  return stub.fetch(request);
1694
2069
  };
2070
+ const relayProbeCache = /* @__PURE__ */ new Map();
2071
+ const RELAY_PROBE_TTL_MS = 5e3;
2072
+ const probeRelayCount = async (namespace, shardKey) => {
2073
+ const now = Date.now();
2074
+ const cached = relayProbeCache.get(shardKey);
2075
+ if (cached !== void 0 && cached.expiresMs > now) {
2076
+ return cached.relayCount;
2077
+ }
2078
+ let relayCount = 0;
2079
+ try {
2080
+ const response = await resolveShard(namespace, shardKey).fetch(new Request("https://shard.internal/_lunora/route"));
2081
+ if (response.ok) {
2082
+ const body = await response.json();
2083
+ const reported = body.relayCount;
2084
+ if (typeof reported === "number" && reported > 0) {
2085
+ relayCount = Math.floor(reported);
2086
+ }
2087
+ }
2088
+ } catch {
2089
+ relayCount = 0;
2090
+ }
2091
+ relayProbeCache.set(shardKey, { expiresMs: now + RELAY_PROBE_TTL_MS, relayCount });
2092
+ return relayCount;
2093
+ };
2094
+ const resolveShardBindingName = (env, namespace) => {
2095
+ if (env === null || typeof env !== "object") {
2096
+ return void 0;
2097
+ }
2098
+ for (const [key, value] of Object.entries(env)) {
2099
+ if (value === namespace) {
2100
+ return key;
2101
+ }
2102
+ }
2103
+ return void 0;
2104
+ };
1695
2105
  const constantTimeEqual = (expected, supplied) => {
1696
2106
  const max = Math.max(expected.length, supplied.length);
1697
2107
  let diff = expected.length ^ supplied.length;
@@ -1740,6 +2150,9 @@ const checkAdminWsToken = (request, expected) => {
1740
2150
  };
1741
2151
  const createWorker = (options) => {
1742
2152
  const defaultShard = options.defaultShardKey ?? "__root__";
2153
+ const publicResolveIdentity = wrapResolverWithContract(options.resolveIdentity, options.identity);
2154
+ const shardDO = applyJurisdiction(options.shardDO, options.jurisdiction);
2155
+ const schedulerDO = options.schedulerDO === void 0 ? void 0 : applyJurisdiction(options.schedulerDO, options.jurisdiction);
1743
2156
  let envAdminToken;
1744
2157
  const effectiveAdminToken = () => options.adminToken ?? envAdminToken;
1745
2158
  const resolveAdminTokenFromEnv = (env) => {
@@ -1751,46 +2164,64 @@ const createWorker = (options) => {
1751
2164
  envAdminToken = value;
1752
2165
  }
1753
2166
  };
1754
- const hasAnyShardAuth = Boolean(options.authorizeShard) || Boolean(options.authorizeFanOut);
2167
+ const accessAdminGrants = /* @__PURE__ */ new WeakSet();
2168
+ const requestIsAdmin = (request) => checkAdminAuth(request, effectiveAdminToken()) || accessAdminGrants.has(request);
2169
+ const resolveAdminForwardContext = async (request, env) => {
2170
+ const context = await resolveForwardContext(request, env, options.resolveIdentity);
2171
+ if (accessAdminGrants.has(request) && context.headers["authorization"] === void 0) {
2172
+ const token = effectiveAdminToken();
2173
+ if (token !== void 0) {
2174
+ context.headers["authorization"] = `Bearer ${token}`;
2175
+ }
2176
+ }
2177
+ return context;
2178
+ };
1755
2179
  let warnedUnauthenticatedShardAccess = false;
1756
- const warnUnauthenticatedShardAccessOnce = (kind) => {
1757
- if (hasAnyShardAuth || options.allowUnauthenticatedShardAccess || warnedUnauthenticatedShardAccess) {
2180
+ const guardUnauthenticatedShardAccess = (kind) => {
2181
+ if (!options.allowUnauthenticatedShardAccess) {
2182
+ const callback = kind === "fan-out" ? "authorizeFanOut" : "authorizeShard";
2183
+ throw new LunoraError(
2184
+ `${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).`,
2185
+ { code: kind === "fan-out" ? "FORBIDDEN_FANOUT" : "FORBIDDEN_SHARD", status: 403 }
2186
+ );
2187
+ }
2188
+ if (warnedUnauthenticatedShardAccess) {
1758
2189
  return;
1759
2190
  }
1760
2191
  warnedUnauthenticatedShardAccess = true;
1761
2192
  console.warn(
1762
2193
  [
1763
- `[lunora] SECURITY: received ${kind} access but neither \`authorizeShard\` nor \`authorizeFanOut\` is configured — `,
2194
+ `[lunora] SECURITY: serving ${kind} access with \`allowUnauthenticatedShardAccess: true\` and no \`authorizeShard\`/\`authorizeFanOut\` — `,
1764
2195
  `any caller (including unauthenticated ones) can target any shard / fan out across the table. `,
1765
- `Configure \`authorizeShard\`/\`authorizeFanOut\`, or set \`allowUnauthenticatedShardAccess: true\` to acknowledge this posture and silence this warning.`
2196
+ `This is safe only if every table is protected by per-row RLS. Configure \`authorizeShard\`/\`authorizeFanOut\` to gate it.`
1766
2197
  ].join("")
1767
2198
  );
1768
2199
  };
1769
2200
  const orchestrationAdminRoutes = buildOrchestrationAdminRoutes({
1770
2201
  defaultShard,
1771
2202
  forwardToShard,
1772
- isAdmin: (request) => checkAdminAuth(request, effectiveAdminToken()),
2203
+ isAdmin: requestIsAdmin,
1773
2204
  queryCoordinator: options.queryCoordinator,
1774
- resolveForwardContext: (request, env) => resolveForwardContext(request, env, options.resolveIdentity),
1775
- shardDO: options.shardDO
2205
+ resolveForwardContext: resolveAdminForwardContext,
2206
+ shardDO
1776
2207
  });
1777
- const dispatchToShard = async (functionPath, args, shardKey) => {
2208
+ const dispatchToShard = async (functionPath, args, shardKey, mutationId) => {
1778
2209
  if (options.authorizeShard) {
1779
2210
  const allowed = await options.authorizeShard(null, shardKey);
1780
2211
  if (!allowed) {
1781
2212
  throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
1782
2213
  }
1783
2214
  }
2215
+ const headers = { "content-type": "application/json", "x-lunora-system": "1" };
2216
+ if (mutationId !== void 0 && mutationId.length > 0) {
2217
+ headers["x-lunora-mutation-id"] = mutationId;
2218
+ }
1784
2219
  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
2220
  body: JSON.stringify({ args, functionPath }),
1790
- headers: { "content-type": "application/json", "x-lunora-system": "1" },
2221
+ headers,
1791
2222
  method: "POST"
1792
2223
  });
1793
- return forwardToShard(options.shardDO, shardKey, forwarded);
2224
+ return forwardToShard(shardDO, shardKey, forwarded);
1794
2225
  };
1795
2226
  const startCronWorkflow = async (binding, job, env) => {
1796
2227
  const candidate = env?.[binding];
@@ -1835,7 +2266,7 @@ const createWorker = (options) => {
1835
2266
  }
1836
2267
  };
1837
2268
  const handleRunCronJob = async (request, env) => {
1838
- if (!checkAdminAuth(request, effectiveAdminToken())) {
2269
+ if (!requestIsAdmin(request)) {
1839
2270
  throw new LunoraError("admin endpoint requires a valid admin bearer", { code: "ADMIN_FORBIDDEN", status: 403 });
1840
2271
  }
1841
2272
  if (request.method !== "POST") {
@@ -1858,12 +2289,12 @@ const createWorker = (options) => {
1858
2289
  };
1859
2290
  const releasePoolSlot = async (candidate) => {
1860
2291
  const pool = typeof candidate.pool === "string" && candidate.pool.length > 0 ? candidate.pool : void 0;
1861
- if (!pool || !options.schedulerDO || typeof candidate.id !== "string") {
2292
+ if (!pool || !schedulerDO || typeof candidate.id !== "string") {
1862
2293
  return;
1863
2294
  }
1864
2295
  const instanceName = typeof candidate.instanceName === "string" && candidate.instanceName.length > 0 ? candidate.instanceName : "default";
1865
2296
  try {
1866
- await options.schedulerDO.get(options.schedulerDO.idFromName(instanceName)).fetch(
2297
+ await schedulerDO.get(schedulerDO.idFromName(instanceName)).fetch(
1867
2298
  new Request("https://scheduler.internal/complete", {
1868
2299
  body: JSON.stringify({ id: candidate.id, pool }),
1869
2300
  headers: { "content-type": "application/json" },
@@ -1903,23 +2334,24 @@ const createWorker = (options) => {
1903
2334
  }
1904
2335
  const args = candidate.args ?? {};
1905
2336
  const shardKey = typeof candidate.shardKey === "string" && candidate.shardKey.length > 0 ? candidate.shardKey : defaultShard;
1906
- const response = await dispatchToShard(candidate.functionPath, args, shardKey);
2337
+ const mutationId = typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : void 0;
2338
+ const response = await dispatchToShard(candidate.functionPath, args, shardKey, mutationId);
1907
2339
  await releasePoolSlot(candidate);
1908
2340
  return response;
1909
2341
  };
1910
2342
  const dataMovementAdminRoutes = buildDataMovementAdminRoutes({
1911
2343
  applyGlobals: options.applyGlobals,
1912
- isAdmin: (request) => checkAdminAuth(request, effectiveAdminToken()),
2344
+ isAdmin: requestIsAdmin,
1913
2345
  knownTables: () => collectKnownTables(),
1914
2346
  queryCoordinator: options.queryCoordinator,
1915
- resolveForwardContext: (request, env) => resolveForwardContext(request, env, options.resolveIdentity),
1916
- shardDO: options.shardDO,
1917
- streamExportRows: (coordinator, headers, tables, writeRow) => streamExportRows(options, coordinator, headers, tables, writeRow),
1918
- streamingImport: (request, headers) => streamingImport(request, options, headers),
2347
+ resolveForwardContext: resolveAdminForwardContext,
2348
+ shardDO,
2349
+ streamExportRows: (coordinator, headers, tables, writeRow) => streamExportRows(options, coordinator, headers, tables, writeRow, shardDO),
2350
+ streamingImport: (request, headers) => streamingImport(request, options, headers, shardDO),
1919
2351
  syncGlobals: options.syncGlobals
1920
2352
  });
1921
2353
  const assertAdminAuthorized = (request) => {
1922
- if (!checkAdminAuth(request, effectiveAdminToken())) {
2354
+ if (!requestIsAdmin(request)) {
1923
2355
  throw new LunoraError("admin endpoint requires a valid admin bearer", { code: "ADMIN_FORBIDDEN", status: 403 });
1924
2356
  }
1925
2357
  };
@@ -1946,17 +2378,17 @@ const createWorker = (options) => {
1946
2378
  };
1947
2379
  };
1948
2380
  const requireSchedulerNamespace = () => {
1949
- if (options.schedulerDO === void 0) {
2381
+ if (schedulerDO === void 0) {
1950
2382
  throw new LunoraError("scheduled endpoints require a `schedulerDO` namespace on the worker", { code: "SCHEDULER_NOT_CONFIGURED", status: 400 });
1951
2383
  }
1952
- return options.schedulerDO;
2384
+ return schedulerDO;
1953
2385
  };
1954
2386
  const resolveSchedulerStub = (request) => {
1955
2387
  assertAdminAuthorized(request);
1956
2388
  return resolveShard(requireSchedulerNamespace(), options.schedulerInstanceName ?? "default");
1957
2389
  };
1958
2390
  const scheduledAdminRoutes = buildScheduledAdminRoutes({
1959
- checkWsAdmin: (request) => checkAdminAuth(request, effectiveAdminToken()) || checkAdminWsToken(request, effectiveAdminToken()),
2391
+ checkWsAdmin: (request) => requestIsAdmin(request) || checkAdminWsToken(request, effectiveAdminToken()),
1960
2392
  requireSchedulerNamespace,
1961
2393
  resolveSchedulerStub,
1962
2394
  schedulerInstanceName: options.schedulerInstanceName ?? "default"
@@ -1984,6 +2416,11 @@ const createWorker = (options) => {
1984
2416
  requireAdminOption,
1985
2417
  vectorIntrospector: options.vectorIntrospector
1986
2418
  });
2419
+ const kvAdminRoutes = buildKvAdminRoutes({
2420
+ kvIntrospector: options.kvIntrospector,
2421
+ readJsonBody: readJsonBodyWithLimit,
2422
+ requireAdminOption
2423
+ });
1987
2424
  const introspectionAdminRoutes = buildIntrospectionAdminRoutes({
1988
2425
  assertAdmin: assertAdminAuthorized,
1989
2426
  options: {
@@ -1997,8 +2434,8 @@ const createWorker = (options) => {
1997
2434
  queryParameter,
1998
2435
  requireAdminOption
1999
2436
  });
2000
- const buildHttpActionContext = async (request, env) => {
2001
- const { claims, headers, userId } = await resolveForwardContext(request, env, options.resolveIdentity);
2437
+ const buildHttpActionContext = async (request, env, context) => {
2438
+ const { claims, headers, userId } = await resolveForwardContext(request, env, publicResolveIdentity);
2002
2439
  const run = async (reference, args = {}) => {
2003
2440
  const functionPath = reference.__lunoraRef;
2004
2441
  if (typeof functionPath !== "string") {
@@ -2009,7 +2446,7 @@ const createWorker = (options) => {
2009
2446
  headers,
2010
2447
  method: "POST"
2011
2448
  });
2012
- const response = await forwardToShard(options.shardDO, defaultShard, forwarded);
2449
+ const response = await forwardToShard(shardDO, defaultShard, forwarded);
2013
2450
  const payload = await response.json();
2014
2451
  if (payload.error) {
2015
2452
  throw new LunoraError(payload.error.message ?? "shard RPC failed", {
@@ -2024,6 +2461,7 @@ const createWorker = (options) => {
2024
2461
  getIdentity: () => Promise.resolve(claims),
2025
2462
  userId
2026
2463
  },
2464
+ cache: context.cache,
2027
2465
  fetch: globalThis.fetch.bind(globalThis),
2028
2466
  runAction: run,
2029
2467
  runMutation: run,
@@ -2034,7 +2472,7 @@ const createWorker = (options) => {
2034
2472
  if (!options.httpRouter) {
2035
2473
  return void 0;
2036
2474
  }
2037
- const httpContext = await buildHttpActionContext(request, env);
2475
+ const httpContext = await buildHttpActionContext(request, env, context);
2038
2476
  try {
2039
2477
  return await options.httpRouter.fetch(request, { ...env, __lunoraCtx: httpContext }, context);
2040
2478
  } catch (error) {
@@ -2046,15 +2484,19 @@ const createWorker = (options) => {
2046
2484
  if (request.headers.get("Upgrade") !== "websocket") {
2047
2485
  throw new LunoraError("WebSocket upgrade header missing", { code: "BAD_REQUEST", status: 426 });
2048
2486
  }
2487
+ const blockedUpgrade = enforceWebSocketOrigin(request, resolvedSecurity);
2488
+ if (blockedUpgrade) {
2489
+ return blockedUpgrade;
2490
+ }
2049
2491
  const shardKey = url.searchParams.get("shard") ?? defaultShard;
2050
- const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, options.resolveIdentity);
2492
+ const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
2051
2493
  if (options.authorizeShard) {
2052
2494
  const allowed = await options.authorizeShard(identity, shardKey);
2053
2495
  if (!allowed) {
2054
2496
  throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
2055
2497
  }
2056
2498
  } else if (shardKey !== defaultShard) {
2057
- warnUnauthenticatedShardAccessOnce("shard");
2499
+ guardUnauthenticatedShardAccess("shard");
2058
2500
  }
2059
2501
  const upgradeHeaders = new Headers(request.headers);
2060
2502
  upgradeHeaders.delete("x-lunora-userid");
@@ -2072,31 +2514,45 @@ const createWorker = (options) => {
2072
2514
  if (forwardedExp !== void 0) {
2073
2515
  upgradeHeaders.set("x-lunora-identity-exp", forwardedExp);
2074
2516
  }
2075
- return forwardToShard(options.shardDO, shardKey, new Request(request, { headers: upgradeHeaders }));
2517
+ const binding = resolveShardBindingName(env, options.shardDO);
2518
+ if (binding !== void 0) {
2519
+ upgradeHeaders.set("x-lunora-shard-binding", binding);
2520
+ const relayCount = await probeRelayCount(shardDO, shardKey);
2521
+ if (relayCount > 0) {
2522
+ const target = relayName(shardKey, Math.floor(Math.random() * relayCount));
2523
+ return forwardToShard(shardDO, target, new Request(request, { headers: upgradeHeaders }));
2524
+ }
2525
+ }
2526
+ return forwardToShard(shardDO, shardKey, new Request(request, { headers: upgradeHeaders }));
2527
+ };
2528
+ const authorizeFanOutEnvelope = async (fanOut, functionPath, identity) => {
2529
+ if (options.authorizeFanOut) {
2530
+ const allowed = await options.authorizeFanOut(identity, fanOut.table, functionPath);
2531
+ if (!allowed) {
2532
+ throw new LunoraError("Forbidden fan-out", { code: "FORBIDDEN_FANOUT", status: 403 });
2533
+ }
2534
+ return;
2535
+ }
2536
+ if (functionPath.startsWith("__lunora_relation__:")) {
2537
+ throw new LunoraError("reverse cross-backend relation reads (`__lunora_relation__:*`) require `authorizeFanOut` to be configured on the worker", {
2538
+ code: "FORBIDDEN_FANOUT",
2539
+ status: 403
2540
+ });
2541
+ }
2542
+ if (options.authorizeShard) {
2543
+ throw new LunoraError("Fan-out requires `authorizeFanOut` to be configured on the worker when `authorizeShard` is set", {
2544
+ code: "FORBIDDEN_FANOUT",
2545
+ status: 403
2546
+ });
2547
+ }
2548
+ guardUnauthenticatedShardAccess("fan-out");
2076
2549
  };
2077
2550
  const authorizeRpcEnvelope = async (envelope, identity) => {
2551
+ if (!envelope.fanOut && envelope.functionPath.startsWith("__lunora_admin__:")) {
2552
+ return;
2553
+ }
2078
2554
  if (envelope.fanOut) {
2079
- if (options.authorizeFanOut) {
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
- }
2555
+ await authorizeFanOutEnvelope(envelope.fanOut, envelope.functionPath, identity);
2100
2556
  return;
2101
2557
  }
2102
2558
  if (options.authorizeShard) {
@@ -2106,7 +2562,7 @@ const createWorker = (options) => {
2106
2562
  throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
2107
2563
  }
2108
2564
  } else if (envelope.shardKey !== void 0 && envelope.shardKey !== defaultShard) {
2109
- warnUnauthenticatedShardAccessOnce("shard");
2565
+ guardUnauthenticatedShardAccess("shard");
2110
2566
  }
2111
2567
  };
2112
2568
  const dispatchSingleShard = async (functionPath, args, shardKey, forwardedHeaders, sinkContext) => {
@@ -2118,7 +2574,7 @@ const createWorker = (options) => {
2118
2574
  method: "POST"
2119
2575
  });
2120
2576
  try {
2121
- const response = await forwardToShard(options.shardDO, shardKey, forwarded);
2577
+ const response = await forwardToShard(shardDO, shardKey, forwarded);
2122
2578
  emitRpcEvent(
2123
2579
  observability,
2124
2580
  {
@@ -2147,6 +2603,7 @@ const createWorker = (options) => {
2147
2603
  throw new LunoraError("RPC endpoint requires POST", { code: "METHOD_NOT_ALLOWED", status: 405 });
2148
2604
  }
2149
2605
  const envelope = await parseEnvelope(request);
2606
+ logRpcDebug(env, envelope);
2150
2607
  if (envelope.fanOut && envelope.shardKey) {
2151
2608
  throw new LunoraError("RPC envelope cannot set both `shardKey` and `fanOut`", { code: "BAD_REQUEST", status: 400 });
2152
2609
  }
@@ -2162,14 +2619,14 @@ const createWorker = (options) => {
2162
2619
  status: 400
2163
2620
  });
2164
2621
  }
2165
- const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, options.resolveIdentity);
2622
+ const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
2166
2623
  await authorizeRpcEnvelope(envelope, identity);
2167
2624
  {
2168
2625
  const rpcStartedAt = Date.now();
2169
2626
  const { observability } = options;
2170
2627
  const sinkContext = context ? {
2171
2628
  waitUntil: (promise) => {
2172
- context.waitUntil(promise);
2629
+ context.waitUntil?.(promise);
2173
2630
  }
2174
2631
  } : void 0;
2175
2632
  if (envelope.fanOut) {
@@ -2181,7 +2638,7 @@ const createWorker = (options) => {
2181
2638
  });
2182
2639
  }
2183
2640
  try {
2184
- const result = await coordinator.fanOut(options.shardDO, {
2641
+ const result = await coordinator.fanOut(shardDO, {
2185
2642
  args: envelope.args ?? {},
2186
2643
  fanOut: envelope.fanOut,
2187
2644
  functionPath: envelope.functionPath,
@@ -2218,13 +2675,132 @@ const createWorker = (options) => {
2218
2675
  return dispatchSingleShard(envelope.functionPath, envelope.args ?? {}, shardKey, forwardedHeaders, sinkContext);
2219
2676
  }
2220
2677
  };
2678
+ const handleBatchRpc = async (request, env, context) => {
2679
+ if (request.method !== "POST") {
2680
+ throw new LunoraError("RPC batch endpoint requires POST", { code: "METHOD_NOT_ALLOWED", status: 405 });
2681
+ }
2682
+ const text = await readBodyTextWithLimit(request);
2683
+ let body;
2684
+ try {
2685
+ body = JSON.parse(text);
2686
+ } catch {
2687
+ throw new LunoraError("RPC batch body must be valid JSON", { code: "BAD_REQUEST", status: 400 });
2688
+ }
2689
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
2690
+ throw new LunoraError("RPC batch body must be an object", { code: "BAD_REQUEST", status: 400 });
2691
+ }
2692
+ const { calls } = body;
2693
+ if (!Array.isArray(calls)) {
2694
+ throw new LunoraError("RPC batch `calls` must be an array", { code: "BAD_REQUEST", status: 400 });
2695
+ }
2696
+ const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, options.resolveIdentity);
2697
+ const groups = groupBatchCallsByShard(calls, defaultShard);
2698
+ await Promise.all(
2699
+ [...groups.entries()].flatMap(
2700
+ ([shardKey, entries]) => entries.map((entry) => authorizeRpcEnvelope({ functionPath: entry.functionPath, shardKey }, identity))
2701
+ )
2702
+ );
2703
+ const { observability } = options;
2704
+ const sinkContext = context ? {
2705
+ waitUntil: (promise) => {
2706
+ context.waitUntil?.(promise);
2707
+ }
2708
+ } : void 0;
2709
+ const results = [];
2710
+ let latestBookmark;
2711
+ const slotError = (entry, status, code, message) => {
2712
+ return { body: { error: { code, message } }, id: entry.id, status };
2713
+ };
2714
+ const failSubBatch = (entries, status, code, message, eventFor) => {
2715
+ for (const entry of entries) {
2716
+ emitRpcEvent(observability, eventFor(entry), sinkContext);
2717
+ results.push(slotError(entry, status, code, message));
2718
+ }
2719
+ };
2720
+ const emitEntryEvents = (entries, shardKey, durationMs, statusById, fallbackStatus) => {
2721
+ for (const entry of entries) {
2722
+ const status = statusById.get(entry.id) ?? fallbackStatus;
2723
+ const ok = status < 400;
2724
+ emitRpcEvent(
2725
+ observability,
2726
+ {
2727
+ durationMs,
2728
+ functionPath: entry.functionPath,
2729
+ ok,
2730
+ shardKey,
2731
+ ...ok ? {} : { error: { code: "SHARD_ERROR", message: `batched call returned ${String(status)}`, status } }
2732
+ },
2733
+ sinkContext
2734
+ );
2735
+ }
2736
+ };
2737
+ await Promise.all(
2738
+ [...groups.entries()].map(async ([shardKey, entries]) => {
2739
+ const headers = new Headers(forwardedHeaders);
2740
+ headers.set("content-type", "application/json");
2741
+ const subRequest = new Request("https://shard.internal/rpc-batch", { body: JSON.stringify({ calls: entries }), headers, method: "POST" });
2742
+ const subStartedAt = Date.now();
2743
+ let response;
2744
+ try {
2745
+ response = await forwardToShard(shardDO, shardKey, subRequest);
2746
+ } catch (error) {
2747
+ const durationMs2 = Date.now() - subStartedAt;
2748
+ const { body: errorBody } = toErrorBody(error, { fallbackCode: "SHARD_UNAVAILABLE", redactedMessage: "shard unavailable" });
2749
+ failSubBatch(
2750
+ entries,
2751
+ 502,
2752
+ errorBody.code,
2753
+ errorBody.message,
2754
+ (entry) => buildErrorEvent(entry.functionPath, durationMs2, error, { shardKey })
2755
+ );
2756
+ return;
2757
+ }
2758
+ const durationMs = Date.now() - subStartedAt;
2759
+ const bookmark = response.headers.get("x-d1-bookmark");
2760
+ if (bookmark) {
2761
+ latestBookmark = bookmark;
2762
+ }
2763
+ let parsed;
2764
+ try {
2765
+ parsed = await response.json();
2766
+ } catch {
2767
+ const message = `shard batch returned a non-JSON response (${String(response.status)})`;
2768
+ failSubBatch(entries, response.status, "SHARD_ERROR", message, (entry) => {
2769
+ return {
2770
+ durationMs,
2771
+ error: { code: "SHARD_ERROR", message, status: response.status },
2772
+ functionPath: entry.functionPath,
2773
+ ok: false,
2774
+ shardKey
2775
+ };
2776
+ });
2777
+ return;
2778
+ }
2779
+ const entryResults = Array.isArray(parsed.results) ? parsed.results : [];
2780
+ const statusById = new Map(entryResults.map((entry) => [entry.id, entry.status ?? response.status]));
2781
+ const seenIds = new Set(entryResults.map((entry) => entry.id));
2782
+ emitEntryEvents(entries, shardKey, durationMs, statusById, response.status);
2783
+ results.push(...entryResults);
2784
+ for (const entry of entries) {
2785
+ if (!seenIds.has(entry.id)) {
2786
+ results.push(slotError(entry, response.status, "SHARD_ERROR", `shard batch omitted result for call ${String(entry.id)}`));
2787
+ }
2788
+ }
2789
+ })
2790
+ );
2791
+ const responseHeaders = { "content-type": "application/json" };
2792
+ if (latestBookmark !== void 0) {
2793
+ responseHeaders["x-d1-bookmark"] = latestBookmark;
2794
+ }
2795
+ return Response.json({ results }, { headers: responseHeaders, status: 200 });
2796
+ };
2221
2797
  const serverQuery = async (request, env, reference, args = {}, callOptions = {}) => {
2222
2798
  try {
2223
2799
  const functionPath = reference.__lunoraRef;
2224
2800
  if (typeof functionPath !== "string") {
2225
2801
  throw new LunoraError("serverQuery: expected a function reference from the generated `api`", { code: "BAD_REQUEST", status: 400 });
2226
2802
  }
2227
- const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, options.resolveIdentity);
2803
+ const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
2228
2804
  await authorizeRpcEnvelope({ functionPath, shardKey: callOptions.shardKey }, identity);
2229
2805
  const shardKey = callOptions.shardKey ?? defaultShard;
2230
2806
  return await dispatchSingleShard(functionPath, args, shardKey, forwardedHeaders);
@@ -2290,7 +2866,7 @@ const createWorker = (options) => {
2290
2866
  streamController.enqueue(encoded);
2291
2867
  };
2292
2868
  try {
2293
- await streamExportRows(options, coordinator, forwardedHeaders, tables, writeRow);
2869
+ await streamExportRows(options, coordinator, forwardedHeaders, tables, writeRow, shardDO);
2294
2870
  streamController.close();
2295
2871
  } catch (error) {
2296
2872
  streamError = error instanceof Error ? error : new Error(String(error));
@@ -2359,7 +2935,7 @@ const createWorker = (options) => {
2359
2935
  headers: { authorization: `Bearer ${adminBearer}`, "content-type": "application/json" },
2360
2936
  method: "POST"
2361
2937
  });
2362
- await forwardToShard(options.shardDO, defaultShard, recordRequest);
2938
+ await forwardToShard(shardDO, defaultShard, recordRequest);
2363
2939
  } catch {
2364
2940
  }
2365
2941
  };
@@ -2373,13 +2949,21 @@ const createWorker = (options) => {
2373
2949
  }
2374
2950
  const basePath = options.authBasePath ?? DEFAULT_AUTH_BASE_PATH;
2375
2951
  if (isAuthAttemptPath(url.pathname, basePath)) {
2376
- context.waitUntil(recordAuthAttempt(env, authResponse.status >= 400 ? "fail" : "ok"));
2952
+ context.waitUntil?.(recordAuthAttempt(env, authResponse.status >= 400 ? "fail" : "ok"));
2377
2953
  }
2378
2954
  return authResponse;
2379
2955
  };
2956
+ const customRoutes = options.routes !== void 0 && Object.keys(options.routes).length > 0 ? options.routes : void 0;
2380
2957
  const internalRoutes = {
2958
+ [STATUS_PATH]: (request) => {
2959
+ if (request.method !== "GET" && request.method !== "HEAD") {
2960
+ return new Response(void 0, { headers: { allow: "GET, HEAD" }, status: 405 });
2961
+ }
2962
+ return Response.json({ ok: true }, { headers: { "cache-control": "no-store" } });
2963
+ },
2381
2964
  [WS_PATH]: (request, env, url) => handleWebSocketUpgrade(request, env, url),
2382
2965
  [RPC_PATH]: (request, env, _url, context) => handleRpc(request, env, context),
2966
+ [RPC_BATCH_PATH]: (request, env, _url, context) => handleBatchRpc(request, env, context),
2383
2967
  [SCHEDULER_DISPATCH_PATH]: (request, env) => handleSchedulerDispatch(request, env),
2384
2968
  [CRON_JOBS_RUN_PATH]: (request, env) => handleRunCronJob(request, env),
2385
2969
  // Extracted handler clusters built above, merged in (mirroring the auth
@@ -2393,6 +2977,7 @@ const createWorker = (options) => {
2393
2977
  ...workflowsAdminRoutes,
2394
2978
  ...storageAdminRoutes,
2395
2979
  ...vectorAdminRoutes,
2980
+ ...kvAdminRoutes,
2396
2981
  ...introspectionAdminRoutes,
2397
2982
  // `/_lunora/admin/auth/*` — the whole user-management plane, one route per
2398
2983
  // `AuthAdmin` op, dispatched by the descriptor table in `./auth-admin-routes`.
@@ -2413,6 +2998,17 @@ const createWorker = (options) => {
2413
2998
  resolvedSecurity = resolveSecurity(options.security, env ?? {});
2414
2999
  }
2415
3000
  };
3001
+ const applyAdminGate = async (request, pathname) => {
3002
+ if (options.adminGate === void 0 || !isAdminPath(pathname)) {
3003
+ return;
3004
+ }
3005
+ try {
3006
+ if (await options.adminGate(request)) {
3007
+ accessAdminGrants.add(request);
3008
+ }
3009
+ } catch {
3010
+ }
3011
+ };
2416
3012
  const handle = async (request, env, context) => {
2417
3013
  const url = new URL(request.url);
2418
3014
  if (request.method === "POST" || request.method === "PUT") {
@@ -2425,13 +3021,16 @@ const createWorker = (options) => {
2425
3021
  if (authResponse) {
2426
3022
  return authResponse;
2427
3023
  }
2428
- const methodAndPath = `${request.method} ${url.pathname}`;
2429
- const route = options.routes?.[methodAndPath] ?? options.routes?.[url.pathname];
2430
- if (route) {
2431
- return route(request, env, context);
3024
+ if (customRoutes) {
3025
+ const methodAndPath = `${request.method} ${url.pathname}`;
3026
+ const route = customRoutes[methodAndPath] ?? customRoutes[url.pathname];
3027
+ if (route) {
3028
+ return route(request, env, context);
3029
+ }
2432
3030
  }
2433
3031
  const internalRoute = internalRoutes[url.pathname];
2434
3032
  if (internalRoute) {
3033
+ await applyAdminGate(request, url.pathname);
2435
3034
  return internalRoute(request, env, url, context);
2436
3035
  }
2437
3036
  const httpRouteResponse = await dispatchHttpRoute(request, env, context);
@@ -2443,7 +3042,7 @@ const createWorker = (options) => {
2443
3042
  return {
2444
3043
  async fetch(request, env, context) {
2445
3044
  if (options.passThroughOnException) {
2446
- context.passThroughOnException();
3045
+ context.passThroughOnException?.();
2447
3046
  }
2448
3047
  ensureSecurityResolved(env);
2449
3048
  resolveAdminTokenFromEnv(env);
@@ -2462,6 +3061,9 @@ const createWorker = (options) => {
2462
3061
  return decorateResponse(toErrorResponse(error), request, resolvedSecurity);
2463
3062
  }
2464
3063
  },
3064
+ async queue(batch, env, context) {
3065
+ await options.queue?.(batch, env, context);
3066
+ },
2465
3067
  async scheduled(controller, env, context) {
2466
3068
  await handleScheduled(controller, env, context);
2467
3069
  },
@@ -2492,10 +3094,24 @@ const withFrameworkWorker = (host, optionsInput) => {
2492
3094
  const optionsFactory = optionsInput;
2493
3095
  return {
2494
3096
  fetch: (request, env, context) => build(optionsFactory(env)).fetch(request, env, context),
3097
+ queue: (batch, env, context) => build(optionsFactory(env)).queue?.(batch, env, context) ?? Promise.resolve(),
2495
3098
  scheduled: (controller, env, context) => build(optionsFactory(env)).scheduled(controller, env, context),
2496
3099
  serverQuery: (request, env, reference, args, options) => build(optionsFactory(env)).serverQuery(request, env, reference, args, options)
2497
3100
  };
2498
3101
  };
3102
+ const resolveLunoraOptions = (options, env) => {
3103
+ if (typeof options === "function") {
3104
+ return options(env);
3105
+ }
3106
+ const shardDO = options.shardDO ?? env?.SHARD;
3107
+ if (!shardDO) {
3108
+ throw new LunoraError(
3109
+ "@lunora/runtime: no shard Durable Object namespace found. Bind `SHARD` in wrangler.jsonc, or pass `createLunoraHandler({ shardDO: env.MY_SHARD })`."
3110
+ );
3111
+ }
3112
+ return { ...options, shardDO };
3113
+ };
3114
+ const createLunoraHandler = (options = {}) => (request, env, context) => createWorker(resolveLunoraOptions(options, env)).fetch(request, env, context ?? NOOP_EXECUTION_CONTEXT);
2499
3115
  const defineRpcEnvelope = (envelope) => envelope;
2500
3116
 
2501
- export { composeWorker, createWorker, defineRpcEnvelope, withFrameworkWorker };
3117
+ export { NOOP_EXECUTION_CONTEXT, composeWorker, createLunoraHandler, createWorker, defineRpcEnvelope, resolveLunoraOptions, withFrameworkWorker };