@lunora/runtime 1.0.0-alpha.13 → 1.0.0-alpha.14

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/dist/index.d.mts CHANGED
@@ -1150,9 +1150,13 @@ declare const createQueryCoordinator: (options: QueryCoordinatorOptions) => Quer
1150
1150
  interface SecurityHeadersOptions {
1151
1151
  /**
1152
1152
  * `Content-Security-Policy`. Omitted (`undefined`) applies a restrictive
1153
- * default to **non-HTML** responses only, so an SSR page is never broken by
1154
- * a policy it didn't opt into. Pass a string to apply that policy to every
1155
- * response (HTML included); `false` to never send one.
1153
+ * `default-src 'none'` policy to **non-HTML** responses, and a conservative
1154
+ * hardening policy to **HTML** responses (`base-uri 'none'; frame-ancestors
1155
+ * 'self'; object-src 'none'`) this does NOT set `default-src`/`script-src`,
1156
+ * so it never blocks an SSR page's own scripts/styles/images/fetches, it only
1157
+ * locks down the `base` element, framing (mirroring the `SAMEORIGIN`
1158
+ * X-Frame-Options default), and legacy plugins. Pass a string to apply that
1159
+ * exact policy to every response (HTML included); `false` to never send one.
1156
1160
  */
1157
1161
  csp?: string | false;
1158
1162
  /** `X-Frame-Options` (clickjacking). Defaults to `SAMEORIGIN`. `false` omits it. */
@@ -1201,7 +1205,7 @@ interface SecurityOptions {
1201
1205
  interface ResolvedHeaders {
1202
1206
  coop: string | undefined;
1203
1207
  csp: {
1204
- htmlToo: boolean;
1208
+ htmlValue: string | undefined;
1205
1209
  value: string;
1206
1210
  } | undefined;
1207
1211
  enabled: boolean;
@@ -1264,6 +1268,24 @@ declare const resolveSecurity: (security: SecurityOptions | undefined, env?: Rec
1264
1268
  * @returns a `403` Response when the origin is untrusted, or `undefined` when the request is allowed.
1265
1269
  */
1266
1270
  declare const enforceOrigin: (request: Request, resolved: ResolvedSecurity) => Response | undefined;
1271
+ /**
1272
+ * CSRF defense for the WebSocket upgrade (Cross-Site WebSocket Hijacking).
1273
+ *
1274
+ * A WS handshake is an HTTP `GET`, so {@link enforceOrigin}'s safe-method
1275
+ * exemption never fires for it — yet the browser auto-attaches the session
1276
+ * cookie to the handshake and WebSocket connections are NOT governed by
1277
+ * CORS/SOP. Without an explicit `Origin` check any page can open
1278
+ * `wss://app/_lunora/ws`, authenticate as the logged-in victim, and read the
1279
+ * victim's live queries + issue mutations as them. This guard closes that hole.
1280
+ *
1281
+ * Scoped to cookie-bearing upgrades — the only vector CSWSH can ride (a browser
1282
+ * attaches cookies but never a bearer token). Bearer/token/server-to-server
1283
+ * upgrades (no `Cookie`) are exempt. A browser ALWAYS sends `Origin` on a WS
1284
+ * handshake, so a missing/untrusted `Origin` on a cookie-bearing upgrade fails
1285
+ * closed (mirrors {@link enforceOrigin}).
1286
+ * @returns a `403` Response when the upgrade origin is untrusted, or `undefined` when it is allowed.
1287
+ */
1288
+
1267
1289
  /**
1268
1290
  * Answer a CORS preflight (`OPTIONS` carrying `Access-Control-Request-Method`)
1269
1291
  * for an allowlisted origin with a `204`. Returns `undefined` for non-preflight
@@ -1718,17 +1740,21 @@ interface WorkerOptions {
1718
1740
  */
1719
1741
  adminToken?: string;
1720
1742
  /**
1721
- * Acknowledge explicitly that sharded and fan-out access may be
1722
- * exercised by any caller (including unauthenticated ones) because no
1723
- * authorization callback is configured. When neither {@link WorkerOptions.authorizeShard}
1724
- * nor {@link WorkerOptions.authorizeFanOut} is set, naming a non-default shard or sending
1725
- * a fan-out envelope is authorization-open: this is the historical posture,
1726
- * preserved for backward compatibility. The runtime emits a single loud
1727
- * `console.warn` the first time such a request is seen so the gap is
1728
- * visible in logs. Set this to `true` to assert the posture is intentional
1729
- * and silence that warning. It does NOT change behaviour it is purely an
1730
- * acknowledgement flag and has no effect once an `authorize*` callback is
1731
- * configured.
1743
+ * Opt into an authorization-open posture for sharded and fan-out access.
1744
+ *
1745
+ * By default (this flag unset/`false`) the runtime FAILS CLOSED: when
1746
+ * neither {@link WorkerOptions.authorizeShard} nor {@link WorkerOptions.authorizeFanOut}
1747
+ * is configured, naming a non-default shard (a potential cross-tenant hop)
1748
+ * or sending a fan-out envelope is rejected with a `403`
1749
+ * (`FORBIDDEN_SHARD`/`FORBIDDEN_FANOUT`). Set this to `true` to allow such
1750
+ * requests from any caller (including unauthenticated ones) appropriate
1751
+ * only when every table is protected by per-row RLS. The runtime then emits
1752
+ * a single `console.warn` so the open posture stays visible in logs. Has no
1753
+ * effect once an `authorize*` callback is configured (those gate directly).
1754
+ *
1755
+ * NOTE: this is a behaviour change from earlier alphas, where the same
1756
+ * situation was warn-once-then-allow. Apps that relied on client-chosen
1757
+ * shard keys without an `authorize*` callback must set this flag explicitly.
1732
1758
  */
1733
1759
  allowUnauthenticatedShardAccess?: boolean;
1734
1760
  /**
package/dist/index.d.ts CHANGED
@@ -1150,9 +1150,13 @@ declare const createQueryCoordinator: (options: QueryCoordinatorOptions) => Quer
1150
1150
  interface SecurityHeadersOptions {
1151
1151
  /**
1152
1152
  * `Content-Security-Policy`. Omitted (`undefined`) applies a restrictive
1153
- * default to **non-HTML** responses only, so an SSR page is never broken by
1154
- * a policy it didn't opt into. Pass a string to apply that policy to every
1155
- * response (HTML included); `false` to never send one.
1153
+ * `default-src 'none'` policy to **non-HTML** responses, and a conservative
1154
+ * hardening policy to **HTML** responses (`base-uri 'none'; frame-ancestors
1155
+ * 'self'; object-src 'none'`) this does NOT set `default-src`/`script-src`,
1156
+ * so it never blocks an SSR page's own scripts/styles/images/fetches, it only
1157
+ * locks down the `base` element, framing (mirroring the `SAMEORIGIN`
1158
+ * X-Frame-Options default), and legacy plugins. Pass a string to apply that
1159
+ * exact policy to every response (HTML included); `false` to never send one.
1156
1160
  */
1157
1161
  csp?: string | false;
1158
1162
  /** `X-Frame-Options` (clickjacking). Defaults to `SAMEORIGIN`. `false` omits it. */
@@ -1201,7 +1205,7 @@ interface SecurityOptions {
1201
1205
  interface ResolvedHeaders {
1202
1206
  coop: string | undefined;
1203
1207
  csp: {
1204
- htmlToo: boolean;
1208
+ htmlValue: string | undefined;
1205
1209
  value: string;
1206
1210
  } | undefined;
1207
1211
  enabled: boolean;
@@ -1264,6 +1268,24 @@ declare const resolveSecurity: (security: SecurityOptions | undefined, env?: Rec
1264
1268
  * @returns a `403` Response when the origin is untrusted, or `undefined` when the request is allowed.
1265
1269
  */
1266
1270
  declare const enforceOrigin: (request: Request, resolved: ResolvedSecurity) => Response | undefined;
1271
+ /**
1272
+ * CSRF defense for the WebSocket upgrade (Cross-Site WebSocket Hijacking).
1273
+ *
1274
+ * A WS handshake is an HTTP `GET`, so {@link enforceOrigin}'s safe-method
1275
+ * exemption never fires for it — yet the browser auto-attaches the session
1276
+ * cookie to the handshake and WebSocket connections are NOT governed by
1277
+ * CORS/SOP. Without an explicit `Origin` check any page can open
1278
+ * `wss://app/_lunora/ws`, authenticate as the logged-in victim, and read the
1279
+ * victim's live queries + issue mutations as them. This guard closes that hole.
1280
+ *
1281
+ * Scoped to cookie-bearing upgrades — the only vector CSWSH can ride (a browser
1282
+ * attaches cookies but never a bearer token). Bearer/token/server-to-server
1283
+ * upgrades (no `Cookie`) are exempt. A browser ALWAYS sends `Origin` on a WS
1284
+ * handshake, so a missing/untrusted `Origin` on a cookie-bearing upgrade fails
1285
+ * closed (mirrors {@link enforceOrigin}).
1286
+ * @returns a `403` Response when the upgrade origin is untrusted, or `undefined` when it is allowed.
1287
+ */
1288
+
1267
1289
  /**
1268
1290
  * Answer a CORS preflight (`OPTIONS` carrying `Access-Control-Request-Method`)
1269
1291
  * for an allowlisted origin with a `204`. Returns `undefined` for non-preflight
@@ -1718,17 +1740,21 @@ interface WorkerOptions {
1718
1740
  */
1719
1741
  adminToken?: string;
1720
1742
  /**
1721
- * Acknowledge explicitly that sharded and fan-out access may be
1722
- * exercised by any caller (including unauthenticated ones) because no
1723
- * authorization callback is configured. When neither {@link WorkerOptions.authorizeShard}
1724
- * nor {@link WorkerOptions.authorizeFanOut} is set, naming a non-default shard or sending
1725
- * a fan-out envelope is authorization-open: this is the historical posture,
1726
- * preserved for backward compatibility. The runtime emits a single loud
1727
- * `console.warn` the first time such a request is seen so the gap is
1728
- * visible in logs. Set this to `true` to assert the posture is intentional
1729
- * and silence that warning. It does NOT change behaviour it is purely an
1730
- * acknowledgement flag and has no effect once an `authorize*` callback is
1731
- * configured.
1743
+ * Opt into an authorization-open posture for sharded and fan-out access.
1744
+ *
1745
+ * By default (this flag unset/`false`) the runtime FAILS CLOSED: when
1746
+ * neither {@link WorkerOptions.authorizeShard} nor {@link WorkerOptions.authorizeFanOut}
1747
+ * is configured, naming a non-default shard (a potential cross-tenant hop)
1748
+ * or sending a fan-out envelope is rejected with a `403`
1749
+ * (`FORBIDDEN_SHARD`/`FORBIDDEN_FANOUT`). Set this to `true` to allow such
1750
+ * requests from any caller (including unauthenticated ones) appropriate
1751
+ * only when every table is protected by per-row RLS. The runtime then emits
1752
+ * a single `console.warn` so the open posture stays visible in logs. Has no
1753
+ * effect once an `authorize*` callback is configured (those gate directly).
1754
+ *
1755
+ * NOTE: this is a behaviour change from earlier alphas, where the same
1756
+ * situation was warn-once-then-allow. Apps that relied on client-chosen
1757
+ * shard keys without an `authorize*` callback must set this flag explicitly.
1732
1758
  */
1733
1759
  allowUnauthenticatedShardAccess?: boolean;
1734
1760
  /**
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  export { toAirbyteMessages, toFivetranResponse } from './packem_shared/toAirbyteMessages-DrHdplb4.mjs';
2
- export { composeWorker, createLunoraHandler, createWorker, defineRpcEnvelope, resolveLunoraOptions, withFrameworkWorker } from './packem_shared/composeWorker-BOB2YZ6v.mjs';
2
+ export { composeWorker, createLunoraHandler, createWorker, defineRpcEnvelope, resolveLunoraOptions, withFrameworkWorker } from './packem_shared/composeWorker-M4mPqTJx.mjs';
3
3
  export { createCrossShardRelationCapabilities } from './packem_shared/createCrossShardRelationCapabilities-C0KOf7er.mjs';
4
4
  export { DEFAULT_REGISTRY_CACHE_TTL_MS, SHARD_REGISTRY_DO_NAME, createDynamicShardRegistry } from './packem_shared/DEFAULT_REGISTRY_CACHE_TTL_MS-ocax8v0n.mjs';
5
5
  export { LunoraError, toErrorResponse } from './packem_shared/LunoraError-CL0aOtpo.mjs';
@@ -7,7 +7,7 @@ export { emitLogEvent, emitRpcEvent } from './packem_shared/emitLogEvent-pEdtqAK
7
7
  export { analyticsEngineSink, combineSinks, consoleSink, sentrySink, webhookSink } from './packem_shared/analyticsEngineSink-DqEvrQs0.mjs';
8
8
  export { createQueryCoordinator, createStaticShardRegistry, mergeStrategyForAggregate } from './packem_shared/createQueryCoordinator-ZeZYUPNu.mjs';
9
9
  export { applyJurisdiction, resolveShard } from './packem_shared/applyJurisdiction-BkZtTkct.mjs';
10
- export { decorateResponse, enforceOrigin, handleCorsPreflight, resolveSecurity } from './packem_shared/decorateResponse-DbISh_Wi.mjs';
10
+ export { decorateResponse, enforceOrigin, handleCorsPreflight, resolveSecurity } from './packem_shared/decorateResponse-HRXo-oOD.mjs';
11
11
  export { NOOP_EXECUTION_CONTEXT } from './packem_shared/NOOP_EXECUTION_CONTEXT-CCTu0Bf1.mjs';
12
12
  export { composeIdentityResolvers, routeIdentityResolvers } from './packem_shared/composeIdentityResolvers-YjvUKisc.mjs';
13
13
 
@@ -4,7 +4,7 @@ import { wrapResolverWithContract } from './composeIdentityResolvers-YjvUKisc.mj
4
4
  export { composeIdentityResolvers, routeIdentityResolvers } from './composeIdentityResolvers-YjvUKisc.mjs';
5
5
  import { emitRpcEvent } from './emitLogEvent-pEdtqAK8.mjs';
6
6
  import { resolveShard, applyJurisdiction } from './applyJurisdiction-BkZtTkct.mjs';
7
- import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse } from './decorateResponse-DbISh_Wi.mjs';
7
+ import { resolveSecurity, handleCorsPreflight, enforceOrigin, decorateResponse, enforceWebSocketOrigin } from './decorateResponse-HRXo-oOD.mjs';
8
8
 
9
9
  const RELAY_NAME_INFIX = "::relay::";
10
10
  const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
@@ -250,15 +250,15 @@ const buildAuthAdminRoutes = (deps) => {
250
250
  }
251
251
  const candidate = error;
252
252
  const code = typeof candidate.code === "string" ? candidate.code : "AUTH_ADMIN_ERROR";
253
- const message = typeof candidate.message === "string" ? candidate.message : "auth admin operation failed";
254
- throw new LunoraError(message, { code, status: AUTH_ADMIN_ERROR_STATUS[code] ?? 400 });
253
+ console.error("[lunora] auth admin operation failed:", error);
254
+ throw new LunoraError("auth admin operation failed", { code, status: AUTH_ADMIN_ERROR_STATUS[code] ?? 400 });
255
255
  }
256
256
  };
257
257
  const handle = async (request, descriptor) => {
258
+ deps.assertAdmin(request);
258
259
  if (request.method !== descriptor.http) {
259
260
  throw new LunoraError(`Auth admin endpoint requires ${descriptor.http}`, { code: "METHOD_NOT_ALLOWED", status: 405 });
260
261
  }
261
- deps.assertAdmin(request);
262
262
  const admin = deps.getAuthAdmin();
263
263
  if (admin === void 0) {
264
264
  throw new LunoraError("auth endpoints require an `authAdmin` on the worker", { code: "AUTH_NOT_CONFIGURED", status: 400 });
@@ -1744,9 +1744,18 @@ const parseEnvelope = async (request) => {
1744
1744
  throw new LunoraError("RPC `shardKey` must be a string", { code: "BAD_REQUEST", status: 400 });
1745
1745
  }
1746
1746
  const envelope = body;
1747
+ const fanOut = validateFanOut(envelope.fanOut);
1748
+ const args = envelope.args ?? {};
1749
+ if (fanOut && envelope.functionPath.startsWith("__lunora_relation__:")) {
1750
+ const requestedTable = args.table;
1751
+ if (typeof requestedTable === "string" && requestedTable !== fanOut.table) {
1752
+ throw new LunoraError("RPC `args.table` must match the authorized `fanOut.table` for a relation fan-out", { code: "BAD_REQUEST", status: 400 });
1753
+ }
1754
+ args.table = fanOut.table;
1755
+ }
1747
1756
  return {
1748
- args: envelope.args ?? {},
1749
- fanOut: validateFanOut(envelope.fanOut),
1757
+ args,
1758
+ fanOut,
1750
1759
  functionPath: envelope.functionPath,
1751
1760
  shardKey: envelope.shardKey
1752
1761
  };
@@ -1864,18 +1873,24 @@ const createWorker = (options) => {
1864
1873
  }
1865
1874
  return context;
1866
1875
  };
1867
- const hasAnyShardAuth = Boolean(options.authorizeShard) || Boolean(options.authorizeFanOut);
1868
1876
  let warnedUnauthenticatedShardAccess = false;
1869
- const warnUnauthenticatedShardAccessOnce = (kind) => {
1870
- if (hasAnyShardAuth || options.allowUnauthenticatedShardAccess || warnedUnauthenticatedShardAccess) {
1877
+ const guardUnauthenticatedShardAccess = (kind) => {
1878
+ if (!options.allowUnauthenticatedShardAccess) {
1879
+ const callback = kind === "fan-out" ? "authorizeFanOut" : "authorizeShard";
1880
+ throw new LunoraError(
1881
+ `${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).`,
1882
+ { code: kind === "fan-out" ? "FORBIDDEN_FANOUT" : "FORBIDDEN_SHARD", status: 403 }
1883
+ );
1884
+ }
1885
+ if (warnedUnauthenticatedShardAccess) {
1871
1886
  return;
1872
1887
  }
1873
1888
  warnedUnauthenticatedShardAccess = true;
1874
1889
  console.warn(
1875
1890
  [
1876
- `[lunora] SECURITY: received ${kind} access but neither \`authorizeShard\` nor \`authorizeFanOut\` is configured — `,
1891
+ `[lunora] SECURITY: serving ${kind} access with \`allowUnauthenticatedShardAccess: true\` and no \`authorizeShard\`/\`authorizeFanOut\` — `,
1877
1892
  `any caller (including unauthenticated ones) can target any shard / fan out across the table. `,
1878
- `Configure \`authorizeShard\`/\`authorizeFanOut\`, or set \`allowUnauthenticatedShardAccess: true\` to acknowledge this posture and silence this warning.`
1893
+ `This is safe only if every table is protected by per-row RLS. Configure \`authorizeShard\`/\`authorizeFanOut\` to gate it.`
1879
1894
  ].join("")
1880
1895
  );
1881
1896
  };
@@ -1887,20 +1902,20 @@ const createWorker = (options) => {
1887
1902
  resolveForwardContext: resolveAdminForwardContext,
1888
1903
  shardDO
1889
1904
  });
1890
- const dispatchToShard = async (functionPath, args, shardKey) => {
1905
+ const dispatchToShard = async (functionPath, args, shardKey, mutationId) => {
1891
1906
  if (options.authorizeShard) {
1892
1907
  const allowed = await options.authorizeShard(null, shardKey);
1893
1908
  if (!allowed) {
1894
1909
  throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
1895
1910
  }
1896
1911
  }
1912
+ const headers = { "content-type": "application/json", "x-lunora-system": "1" };
1913
+ if (mutationId !== void 0 && mutationId.length > 0) {
1914
+ headers["x-lunora-mutation-id"] = mutationId;
1915
+ }
1897
1916
  const forwarded = new Request("https://shard.internal/rpc", {
1898
- // `x-lunora-system` marks this as a trusted server-initiated dispatch
1899
- // so the shard may run `internal` functions (scheduled/cron jobs are
1900
- // typically internal). Authorization was already enforced above; this
1901
- // header is set only here, never on the client RPC path.
1902
1917
  body: JSON.stringify({ args, functionPath }),
1903
- headers: { "content-type": "application/json", "x-lunora-system": "1" },
1918
+ headers,
1904
1919
  method: "POST"
1905
1920
  });
1906
1921
  return forwardToShard(shardDO, shardKey, forwarded);
@@ -2016,7 +2031,8 @@ const createWorker = (options) => {
2016
2031
  }
2017
2032
  const args = candidate.args ?? {};
2018
2033
  const shardKey = typeof candidate.shardKey === "string" && candidate.shardKey.length > 0 ? candidate.shardKey : defaultShard;
2019
- const response = await dispatchToShard(candidate.functionPath, args, shardKey);
2034
+ const mutationId = typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : void 0;
2035
+ const response = await dispatchToShard(candidate.functionPath, args, shardKey, mutationId);
2020
2036
  await releasePoolSlot(candidate);
2021
2037
  return response;
2022
2038
  };
@@ -2159,6 +2175,10 @@ const createWorker = (options) => {
2159
2175
  if (request.headers.get("Upgrade") !== "websocket") {
2160
2176
  throw new LunoraError("WebSocket upgrade header missing", { code: "BAD_REQUEST", status: 426 });
2161
2177
  }
2178
+ const blockedUpgrade = enforceWebSocketOrigin(request, resolvedSecurity);
2179
+ if (blockedUpgrade) {
2180
+ return blockedUpgrade;
2181
+ }
2162
2182
  const shardKey = url.searchParams.get("shard") ?? defaultShard;
2163
2183
  const { headers: forwardedHeaders, identity } = await resolveForwardContext(request, env, publicResolveIdentity);
2164
2184
  if (options.authorizeShard) {
@@ -2167,7 +2187,7 @@ const createWorker = (options) => {
2167
2187
  throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
2168
2188
  }
2169
2189
  } else if (shardKey !== defaultShard) {
2170
- warnUnauthenticatedShardAccessOnce("shard");
2190
+ guardUnauthenticatedShardAccess("shard");
2171
2191
  }
2172
2192
  const upgradeHeaders = new Headers(request.headers);
2173
2193
  upgradeHeaders.delete("x-lunora-userid");
@@ -2196,29 +2216,34 @@ const createWorker = (options) => {
2196
2216
  }
2197
2217
  return forwardToShard(shardDO, shardKey, new Request(request, { headers: upgradeHeaders }));
2198
2218
  };
2219
+ const authorizeFanOutEnvelope = async (fanOut, functionPath, identity) => {
2220
+ if (options.authorizeFanOut) {
2221
+ const allowed = await options.authorizeFanOut(identity, fanOut.table, functionPath);
2222
+ if (!allowed) {
2223
+ throw new LunoraError("Forbidden fan-out", { code: "FORBIDDEN_FANOUT", status: 403 });
2224
+ }
2225
+ return;
2226
+ }
2227
+ if (functionPath.startsWith("__lunora_relation__:")) {
2228
+ throw new LunoraError("reverse cross-backend relation reads (`__lunora_relation__:*`) require `authorizeFanOut` to be configured on the worker", {
2229
+ code: "FORBIDDEN_FANOUT",
2230
+ status: 403
2231
+ });
2232
+ }
2233
+ if (options.authorizeShard) {
2234
+ throw new LunoraError("Fan-out requires `authorizeFanOut` to be configured on the worker when `authorizeShard` is set", {
2235
+ code: "FORBIDDEN_FANOUT",
2236
+ status: 403
2237
+ });
2238
+ }
2239
+ guardUnauthenticatedShardAccess("fan-out");
2240
+ };
2199
2241
  const authorizeRpcEnvelope = async (envelope, identity) => {
2242
+ if (!envelope.fanOut && envelope.functionPath.startsWith("__lunora_admin__:")) {
2243
+ return;
2244
+ }
2200
2245
  if (envelope.fanOut) {
2201
- if (options.authorizeFanOut) {
2202
- const allowed = await options.authorizeFanOut(identity, envelope.fanOut.table, envelope.functionPath);
2203
- if (!allowed) {
2204
- throw new LunoraError("Forbidden fan-out", { code: "FORBIDDEN_FANOUT", status: 403 });
2205
- }
2206
- } else if (envelope.functionPath.startsWith("__lunora_relation__:")) {
2207
- throw new LunoraError(
2208
- "reverse cross-backend relation reads (`__lunora_relation__:*`) require `authorizeFanOut` to be configured on the worker",
2209
- {
2210
- code: "FORBIDDEN_FANOUT",
2211
- status: 403
2212
- }
2213
- );
2214
- } else if (options.authorizeShard) {
2215
- throw new LunoraError("Fan-out requires `authorizeFanOut` to be configured on the worker when `authorizeShard` is set", {
2216
- code: "FORBIDDEN_FANOUT",
2217
- status: 403
2218
- });
2219
- } else {
2220
- warnUnauthenticatedShardAccessOnce("fan-out");
2221
- }
2246
+ await authorizeFanOutEnvelope(envelope.fanOut, envelope.functionPath, identity);
2222
2247
  return;
2223
2248
  }
2224
2249
  if (options.authorizeShard) {
@@ -2228,7 +2253,7 @@ const createWorker = (options) => {
2228
2253
  throw new LunoraError("Forbidden shard", { code: "FORBIDDEN_SHARD", status: 403 });
2229
2254
  }
2230
2255
  } else if (envelope.shardKey !== void 0 && envelope.shardKey !== defaultShard) {
2231
- warnUnauthenticatedShardAccessOnce("shard");
2256
+ guardUnauthenticatedShardAccess("shard");
2232
2257
  }
2233
2258
  };
2234
2259
  const dispatchSingleShard = async (functionPath, args, shardKey, forwardedHeaders, sinkContext) => {
@@ -1,4 +1,13 @@
1
1
  const DEFAULT_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
2
+ const htmlCspFor = (frameOptions) => {
3
+ const parts = ["base-uri 'none'", "object-src 'none'"];
4
+ if (frameOptions === "DENY") {
5
+ parts.push("frame-ancestors 'none'");
6
+ } else if (frameOptions === "SAMEORIGIN") {
7
+ parts.push("frame-ancestors 'self'");
8
+ }
9
+ return parts.join("; ");
10
+ };
2
11
  const DEFAULT_PERMISSIONS_POLICY = "accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
3
12
  const DEFAULT_CORS_HEADERS = ["Authorization", "Content-Type", "X-D1-Bookmark", "X-Lunora-Mutation-Id"];
4
13
  const DEFAULT_CORS_METHODS = ["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"];
@@ -13,14 +22,14 @@ const resolveHstsHeader = (hsts) => {
13
22
  const includeSubDomains = config.includeSubDomains ?? true;
14
23
  return `max-age=${String(maxAge)}${includeSubDomains ? "; includeSubDomains" : ""}${config.preload ? "; preload" : ""}`;
15
24
  };
16
- const resolveCspHeader = (csp) => {
25
+ const resolveCspHeader = (csp, htmlDefault) => {
17
26
  if (csp === false) {
18
27
  return void 0;
19
28
  }
20
29
  if (typeof csp === "string") {
21
- return { htmlToo: true, value: csp };
30
+ return { htmlValue: csp, value: csp };
22
31
  }
23
- return { htmlToo: false, value: DEFAULT_CSP };
32
+ return { htmlValue: htmlDefault, value: DEFAULT_CSP };
24
33
  };
25
34
  const resolveHeaders = (input) => {
26
35
  if (input === false) {
@@ -35,11 +44,12 @@ const resolveHeaders = (input) => {
35
44
  };
36
45
  }
37
46
  const options = input === void 0 || input === true ? {} : input;
47
+ const frameOptions = options.frameOptions === false ? void 0 : options.frameOptions ?? "SAMEORIGIN";
38
48
  return {
39
49
  coop: "same-origin",
40
- csp: resolveCspHeader(options.csp),
50
+ csp: resolveCspHeader(options.csp, htmlCspFor(frameOptions)),
41
51
  enabled: true,
42
- frameOptions: options.frameOptions === false ? void 0 : options.frameOptions ?? "SAMEORIGIN",
52
+ frameOptions,
43
53
  hsts: resolveHstsHeader(options.hsts),
44
54
  permissionsPolicy: options.permissionsPolicy === false ? void 0 : options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY,
45
55
  referrerPolicy: options.referrerPolicy === false ? void 0 : options.referrerPolicy ?? "strict-origin-when-cross-origin"
@@ -65,6 +75,11 @@ const resolveCors = (input) => {
65
75
  if (typeof origins === "function") {
66
76
  isAllowed = origins;
67
77
  isExplicitlyAllowed = origins;
78
+ if (allowCredentials) {
79
+ console.warn(
80
+ "@lunora/runtime: security.cors combines a custom `allowedOrigins` predicate with `allowCredentials: true`. Ensure the predicate matches ONLY trusted origins by exact equality — an over-broad predicate (e.g. `() => true`, or `endsWith`/`includes` checks) reflects any origin with credentials, defeating the allowlist and the CSRF guard."
81
+ );
82
+ }
68
83
  } else {
69
84
  const originsList = origins;
70
85
  if (originsList.includes("*") && allowCredentials) {
@@ -152,6 +167,20 @@ const enforceOrigin = (request, resolved) => {
152
167
  { headers: { "content-type": "application/json" }, status: 403 }
153
168
  );
154
169
  };
170
+ const enforceWebSocketOrigin = (request, resolved) => {
171
+ if (!resolved.csrf.enabled || !request.headers.get("cookie")) {
172
+ return void 0;
173
+ }
174
+ const selfOrigin = new URL(request.url).origin;
175
+ const source = originOf(request.headers.get("origin"));
176
+ if (source !== void 0 && isTrustedOrigin(source, selfOrigin, resolved)) {
177
+ return void 0;
178
+ }
179
+ return Response.json(
180
+ { error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin websocket upgrade rejected" } },
181
+ { headers: { "content-type": "application/json" }, status: 403 }
182
+ );
183
+ };
155
184
  const corsResponseHeaders = (origin, cors) => {
156
185
  const headers = new Headers();
157
186
  headers.set("access-control-allow-origin", origin);
@@ -199,8 +228,11 @@ const applyBaselineHeaders = (headers, request, response, config) => {
199
228
  if (config.coop !== void 0) {
200
229
  setIfAbsent(headers, "cross-origin-opener-policy", config.coop);
201
230
  }
202
- if (config.csp !== void 0 && (config.csp.htmlToo || !isHtmlResponse(response))) {
203
- setIfAbsent(headers, "content-security-policy", config.csp.value);
231
+ if (config.csp !== void 0) {
232
+ const cspValue = isHtmlResponse(response) ? config.csp.htmlValue : config.csp.value;
233
+ if (cspValue !== void 0) {
234
+ setIfAbsent(headers, "content-security-policy", cspValue);
235
+ }
204
236
  }
205
237
  };
206
238
  const applyCorsHeaders = (headers, request, cors) => {
@@ -230,4 +262,4 @@ const decorateResponse = (response, request, resolved) => {
230
262
  return new Response(response.body, { headers, status: response.status, statusText: response.statusText });
231
263
  };
232
264
 
233
- export { decorateResponse, enforceOrigin, handleCorsPreflight, resolveSecurity };
265
+ export { decorateResponse, enforceOrigin, enforceWebSocketOrigin, handleCorsPreflight, resolveSecurity };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/runtime",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.14",
4
4
  "description": "Lunora Worker runtime: the RPC router, shard resolver, and query coordinator",
5
5
  "keywords": [
6
6
  "cloudflare",