@sekiban/dcb-runtime 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -12,6 +12,33 @@ The experimental Cosmos provider is never selected by environment values. A
12
12
  consumer must import `createCosmosStoreProvider` from `/cosmos` and pass it as
13
13
  `createRuntimeWorker({ storeProvider })`.
14
14
 
15
+ ## Query parameters
16
+
17
+ A scalar query accepts only a parsed empty object. A list query accepts only
18
+ `PageNumber`, `PageSize`, `NewestFirst`, and `consistency`. Any other parsed
19
+ scalar value, and any unknown list key, is HTTP 400 `query_params_unsupported`
20
+ with the message "The selected query does not support these parameters." The
21
+ check runs after JSON parsing and endpoint matching and before projector
22
+ lookup or a store read, on every backing. The V1 response envelope is
23
+ unchanged. Typed query handlers are planned for `0.3.0`; their API is not
24
+ defined here.
25
+
26
+ ## Configured bearer checks
27
+
28
+ `checkConfiguredBearer(request, binding, options?)` is exported from
29
+ `@sekiban/dcb-runtime` and `@sekiban/dcb-runtime/cloudflare`. `binding` is
30
+ `unknown`. The result is `"unconfigured" | "missing" | "invalid" | "authorized"`.
31
+ Callers choose the HTTP status. A binding is configured only when it is a
32
+ string containing a non-whitespace character. The default normalization keeps
33
+ the original bytes. `{ tokenNormalization: "trim" }` is the only other mode
34
+ and is reserved for incident maintenance, which already trimmed its token.
35
+
36
+ The helper hashes the complete expected `Bearer ` credential and the complete
37
+ supplied `Authorization` header with global WebCrypto SHA-256, then compares
38
+ every digest byte. It does not use Node crypto. Scheme spelling and case are
39
+ exact. Treat `unconfigured` as disabled on every exposed shared-secret route.
40
+ Do not compare a blank binding as if it were a credential.
41
+
15
42
  ## License
16
43
 
17
44
  Elastic License 2.0. See [LICENSE](./LICENSE).
@@ -1,5 +1,7 @@
1
1
  import type { BootstrapManifest } from "./types";
2
2
  import type { StoreProvider, StoreProviderEnvironment } from "../store/provider";
3
+ /** Missing and unconfigured stay indistinguishable from an unknown path. */
4
+ export declare function authenticateOperatorBootstrap(request: Request, binding: unknown): Promise<Response | undefined>;
3
5
  export interface OperatorBootstrapEnv extends StoreProviderEnvironment {
4
6
  readonly BOOTSTRAP: DurableObjectNamespace;
5
7
  readonly ALLOCATOR: DurableObjectNamespace;
@@ -16,5 +18,7 @@ export interface OperatorBootstrapHooks {
16
18
  readonly manifest: BootstrapManifest;
17
19
  }) => Promise<void>;
18
20
  }
19
- /** Bearer-only operator lane. Incoming headers are never forwarded to a DO. */
21
+ /** Authorized continuation. Authentication runs before this function. */
22
+ export declare function handleAuthorizedOperatorBootstrap(request: Request, env: OperatorBootstrapEnv, storeProvider: StoreProvider, hooks?: OperatorBootstrapHooks): Promise<Response>;
23
+ /** Bearer-only bootstrap lane. Incoming headers are never forwarded to a DO. */
20
24
  export declare function handleOperatorBootstrap(request: Request, env: OperatorBootstrapEnv, storeProvider: StoreProvider, hooks?: OperatorBootstrapHooks): Promise<Response>;
@@ -1,22 +1,22 @@
1
1
  import { parseBootstrapDump } from "./manifest";
2
2
  import { createBootstrapStoreAdapter } from "./BootstrapStoreAdapter";
3
+ import { checkConfiguredBearer } from "../http/ConfiguredBearer";
3
4
  import { scopeIdFor } from "../scope/ScopeName";
4
5
  function json(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } }); }
5
6
  function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
6
- function bearer(request, token) {
7
- const supplied = request.headers.get("authorization");
8
- // Keep absence indistinguishable from an undiscovered management surface.
9
- if (supplied === null)
7
+ /** Missing and unconfigured stay indistinguishable from an unknown path. */
8
+ export async function authenticateOperatorBootstrap(request, binding) {
9
+ const decision = await checkConfiguredBearer(request, binding);
10
+ if (decision === "unconfigured" || decision === "missing") {
10
11
  return json({ code: "not_found", error: "not found" }, 404);
11
- if (supplied !== `Bearer ${token}`)
12
+ }
13
+ if (decision === "invalid") {
12
14
  return json({ code: "operator_auth_required", error: "valid bearer required" }, 403);
15
+ }
13
16
  return undefined;
14
17
  }
15
- /** Bearer-only operator lane. Incoming headers are never forwarded to a DO. */
16
- export async function handleOperatorBootstrap(request, env, storeProvider, hooks = {}) {
17
- const denied = bearer(request, env.REPAIR_OPERATOR_TOKEN);
18
- if (denied !== undefined)
19
- return denied;
18
+ /** Authorized continuation. Authentication runs before this function. */
19
+ export async function handleAuthorizedOperatorBootstrap(request, env, storeProvider, hooks = {}) {
20
20
  const url = new URL(request.url);
21
21
  const match = url.pathname.match(/^\/operator\/bootstrap\/([^/]+)\/(plan|import|status|abort|export)$/);
22
22
  if (match === null)
@@ -102,3 +102,10 @@ export async function handleOperatorBootstrap(request, env, storeProvider, hooks
102
102
  }
103
103
  return invoke("/ready", body);
104
104
  }
105
+ /** Bearer-only bootstrap lane. Incoming headers are never forwarded to a DO. */
106
+ export async function handleOperatorBootstrap(request, env, storeProvider, hooks = {}) {
107
+ const denied = await authenticateOperatorBootstrap(request, env.REPAIR_OPERATOR_TOKEN);
108
+ if (denied !== undefined)
109
+ return denied;
110
+ return handleAuthorizedOperatorBootstrap(request, env, storeProvider, hooks);
111
+ }
@@ -7,5 +7,10 @@ export interface OperatorRepairEnv {
7
7
  REPAIR_EXCLUSION_LOOKUP?: Fetcher;
8
8
  SDT_SERVICE_ID?: string;
9
9
  }
10
- /** Authenticated, bounded control-plane command; it is not a V1 public wire endpoint. */
10
+ /** Non-POST stays a route miss for every binding state, before authentication. */
11
+ export declare function nonPostOperatorRepairResponse(request: Request): Response | undefined;
12
+ export declare function authenticateOperatorRepair(request: Request, binding: unknown): Promise<Response | undefined>;
13
+ /** Authorized continuation. Authentication and the POST check run before this. */
14
+ export declare function handleAuthorizedOperatorRepair(request: Request, env: OperatorRepairEnv, nativeTracing?: NativeTracing, serviceIdentityProvider?: ServiceIdentityProvider): Promise<Response>;
15
+ /** Authenticated, bounded repair command; it is not a V1 public wire endpoint. */
11
16
  export declare function handleOperatorRepair(request: Request, env: OperatorRepairEnv, nativeTracing?: NativeTracing, serviceIdentityProvider?: ServiceIdentityProvider): Promise<Response>;
@@ -1,4 +1,5 @@
1
1
  import { BindingExclusionLookupClient } from "../downstream/ExclusionLookup";
2
+ import { checkConfiguredBearer } from "../http/ConfiguredBearer";
2
3
  import { RepairWorker, RepairWorkerFailure } from "../repair/RepairWorker";
3
4
  import { noOpNativeTracing } from "../trace/CommitTrace";
4
5
  import { envServiceIdentity, requireServiceIdentity } from "../service/ServiceIdentityProvider";
@@ -74,15 +75,24 @@ function testFaultFromRequest(request) {
74
75
  ? value
75
76
  : undefined;
76
77
  }
77
- /** Authenticated, bounded control-plane command; it is not a V1 public wire endpoint. */
78
- export async function handleOperatorRepair(request, env, nativeTracing = noOpNativeTracing, serviceIdentityProvider) {
79
- if (request.method !== "POST") {
80
- return error(404, "operator_repair_route_not_found", "Operator repair requires POST");
78
+ /** Non-POST stays a route miss for every binding state, before authentication. */
79
+ export function nonPostOperatorRepairResponse(request) {
80
+ if (request.method === "POST")
81
+ return undefined;
82
+ return error(404, "operator_repair_route_not_found", "Operator repair requires POST");
83
+ }
84
+ export async function authenticateOperatorRepair(request, binding) {
85
+ const decision = await checkConfiguredBearer(request, binding);
86
+ if (decision === "unconfigured") {
87
+ return error(404, "operator_repair_route_not_found", "Operator repair route not found");
81
88
  }
82
- const token = env.REPAIR_OPERATOR_TOKEN;
83
- if (!isNonEmptyString(token) || request.headers.get("authorization") !== `Bearer ${token}`) {
89
+ if (decision === "missing" || decision === "invalid") {
84
90
  return error(401, "operator_auth_required", "A valid operator repair bearer token is required");
85
91
  }
92
+ return undefined;
93
+ }
94
+ /** Authorized continuation. Authentication and the POST check run before this. */
95
+ export async function handleAuthorizedOperatorRepair(request, env, nativeTracing = noOpNativeTracing, serviceIdentityProvider) {
86
96
  let body;
87
97
  try {
88
98
  body = await request.json();
@@ -106,3 +116,13 @@ export async function handleOperatorRepair(request, env, nativeTracing = noOpNat
106
116
  return error(500, "operator_repair_failed", message);
107
117
  }
108
118
  }
119
+ /** Authenticated, bounded repair command; it is not a V1 public wire endpoint. */
120
+ export async function handleOperatorRepair(request, env, nativeTracing = noOpNativeTracing, serviceIdentityProvider) {
121
+ const methodDenied = nonPostOperatorRepairResponse(request);
122
+ if (methodDenied !== undefined)
123
+ return methodDenied;
124
+ const denied = await authenticateOperatorRepair(request, env.REPAIR_OPERATOR_TOKEN);
125
+ if (denied !== undefined)
126
+ return denied;
127
+ return handleAuthorizedOperatorRepair(request, env, nativeTracing, serviceIdentityProvider);
128
+ }
@@ -30,6 +30,8 @@ export { buildScopeName, DURABLE_OBJECT_SCOPE_CLASSES, isScopeServiceId, parseSc
30
30
  export type { DurableObjectScope, DurableObjectScopeClass, ScopeNameNamespace } from "./scope/ScopeName";
31
31
  export { envServiceIdentity, G11_SERVICE_ID_HEADER, injectableServiceIdentity, requestServiceIdentity, requireServiceIdentity, ServiceIdentityMissingError, TEST_SERVICE_ID_HEADER, } from "./service/ServiceIdentityProvider";
32
32
  export type { ServiceIdentityBehavior, ServiceIdentityEnvironment, ServiceIdentityProvider, ServiceIdentityRequestOptions, ServiceIdentityResolution, } from "./service/ServiceIdentityProvider";
33
+ export { checkConfiguredBearer } from "./http/ConfiguredBearer";
34
+ export type { CheckConfiguredBearerOptions, ConfiguredBearerResult, ConfiguredBearerTokenNormalization, } from "./http/ConfiguredBearer";
33
35
  export interface CloudflareOnlyEnv {
34
36
  ALLOCATOR: DurableObjectNamespace;
35
37
  BOOTSTRAP: DurableObjectNamespace;
@@ -5458,15 +5458,15 @@ var BootstrapCoordinatorDurableObject = class {
5458
5458
  async permit(serviceId, body) {
5459
5459
  if (!object(body) || !string(body.commandId) || !string(body.digest)) return reject("bootstrap_permit_invalid", "commandId and digest are required", 400);
5460
5460
  const commandId = body.commandId;
5461
- const digest2 = body.digest;
5461
+ const digest = body.digest;
5462
5462
  const result2 = await this.ctx.storage.transaction(async (txn) => {
5463
5463
  const control = await txn.get(CONTROL) ?? empty(serviceId);
5464
5464
  const active2 = permits(control);
5465
5465
  if (active2[commandId] !== void 0) {
5466
- return active2[commandId] === digest2 ? { control } : { digestMismatch: true };
5466
+ return active2[commandId] === digest ? { control } : { digestMismatch: true };
5467
5467
  }
5468
5468
  if (control.status !== "EMPTY" && control.status !== "READY") return { rejected: true };
5469
- const updated = { ...control, writePermits: { ...active2, [commandId]: digest2 } };
5469
+ const updated = { ...control, writePermits: { ...active2, [commandId]: digest } };
5470
5470
  await txn.put(CONTROL, updated);
5471
5471
  return { control: updated };
5472
5472
  });
@@ -5477,11 +5477,11 @@ var BootstrapCoordinatorDurableObject = class {
5477
5477
  async permitRelease(serviceId, body) {
5478
5478
  if (!object(body) || !string(body.commandId)) return reject("bootstrap_permit_invalid", "commandId is required", 400);
5479
5479
  const commandId = body.commandId;
5480
- const digest2 = string(body.digest) ? body.digest : void 0;
5480
+ const digest = string(body.digest) ? body.digest : void 0;
5481
5481
  const result2 = await this.ctx.storage.transaction(async (txn) => {
5482
5482
  const control = await txn.get(CONTROL) ?? empty(serviceId);
5483
5483
  const active2 = permits(control);
5484
- if (active2[commandId] !== void 0 && digest2 !== void 0 && active2[commandId] !== digest2) {
5484
+ if (active2[commandId] !== void 0 && digest !== void 0 && active2[commandId] !== digest) {
5485
5485
  return { digestMismatch: true };
5486
5486
  }
5487
5487
  if (active2[commandId] === void 0) return { control };
@@ -5667,8 +5667,8 @@ function eventDigestBytes(input) {
5667
5667
  async function eventDigestHex(input) {
5668
5668
  const bytes2 = eventDigestBytes(input);
5669
5669
  const source = bytes2.buffer.slice(bytes2.byteOffset, bytes2.byteOffset + bytes2.byteLength);
5670
- const digest2 = await crypto.subtle.digest("SHA-256", source);
5671
- return [...new Uint8Array(digest2)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
5670
+ const digest = await crypto.subtle.digest("SHA-256", source);
5671
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
5672
5672
  }
5673
5673
 
5674
5674
  // src/bootstrap/BootstrapStoreAdapter.ts
@@ -5814,6 +5814,34 @@ function createBootstrapStoreAdapter(provider, store, identityGuard) {
5814
5814
  return new BootstrapStoreAdapter(provider, store, identityGuard);
5815
5815
  }
5816
5816
 
5817
+ // src/http/ConfiguredBearer.ts
5818
+ async function sha256(value) {
5819
+ const encoded = new TextEncoder().encode(value);
5820
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", encoded));
5821
+ }
5822
+ function digestsMatch(expected, supplied) {
5823
+ let difference = expected.length ^ supplied.length;
5824
+ const length = Math.max(expected.length, supplied.length);
5825
+ for (let index = 0; index < length; index += 1) {
5826
+ difference |= (expected[index] ?? 0) ^ (supplied[index] ?? 0);
5827
+ }
5828
+ return difference === 0;
5829
+ }
5830
+ async function checkConfiguredBearer(request, binding, options) {
5831
+ if (typeof binding !== "string" || binding.trim().length === 0) {
5832
+ return "unconfigured";
5833
+ }
5834
+ const effectiveToken = options?.tokenNormalization === "trim" ? binding.trim() : binding;
5835
+ const supplied = request.headers.get("authorization");
5836
+ if (supplied === null) return "missing";
5837
+ const expected = `Bearer ${effectiveToken}`;
5838
+ const [expectedDigest, suppliedDigest] = await Promise.all([
5839
+ sha256(expected),
5840
+ sha256(supplied)
5841
+ ]);
5842
+ return digestsMatch(expectedDigest, suppliedDigest) ? "authorized" : "invalid";
5843
+ }
5844
+
5817
5845
  // src/bootstrap/OperatorBootstrap.ts
5818
5846
  function json2(body, status = 200) {
5819
5847
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } });
@@ -5821,15 +5849,17 @@ function json2(body, status = 200) {
5821
5849
  function object2(value) {
5822
5850
  return typeof value === "object" && value !== null && !Array.isArray(value);
5823
5851
  }
5824
- function bearer(request, token) {
5825
- const supplied = request.headers.get("authorization");
5826
- if (supplied === null) return json2({ code: "not_found", error: "not found" }, 404);
5827
- if (supplied !== `Bearer ${token}`) return json2({ code: "operator_auth_required", error: "valid bearer required" }, 403);
5852
+ async function authenticateOperatorBootstrap(request, binding) {
5853
+ const decision = await checkConfiguredBearer(request, binding);
5854
+ if (decision === "unconfigured" || decision === "missing") {
5855
+ return json2({ code: "not_found", error: "not found" }, 404);
5856
+ }
5857
+ if (decision === "invalid") {
5858
+ return json2({ code: "operator_auth_required", error: "valid bearer required" }, 403);
5859
+ }
5828
5860
  return void 0;
5829
5861
  }
5830
- async function handleOperatorBootstrap(request, env, storeProvider, hooks = {}) {
5831
- const denied = bearer(request, env.REPAIR_OPERATOR_TOKEN);
5832
- if (denied !== void 0) return denied;
5862
+ async function handleAuthorizedOperatorBootstrap(request, env, storeProvider, hooks = {}) {
5833
5863
  const url = new URL(request.url);
5834
5864
  const match = url.pathname.match(/^\/operator\/bootstrap\/([^/]+)\/(plan|import|status|abort|export)$/);
5835
5865
  if (match === null) return json2({ code: "not_found", error: "not found" }, 404);
@@ -6728,14 +6758,21 @@ function testFaultFromRequest(request) {
6728
6758
  const value = request.headers.get("x-sdt-g6-test-fault");
6729
6759
  return value === "after-lease-before-prepare" || value === "after-prepare-before-apply" || value === "after-apply-before-observation" || value === "after-verify-before-audit" || value === "after-audit-before-clear" || value === "after-clear-before-final-observation" ? value : void 0;
6730
6760
  }
6731
- async function handleOperatorRepair(request, env, nativeTracing = noOpNativeTracing, serviceIdentityProvider) {
6732
- if (request.method !== "POST") {
6733
- return error2(404, "operator_repair_route_not_found", "Operator repair requires POST");
6761
+ function nonPostOperatorRepairResponse(request) {
6762
+ if (request.method === "POST") return void 0;
6763
+ return error2(404, "operator_repair_route_not_found", "Operator repair requires POST");
6764
+ }
6765
+ async function authenticateOperatorRepair(request, binding) {
6766
+ const decision = await checkConfiguredBearer(request, binding);
6767
+ if (decision === "unconfigured") {
6768
+ return error2(404, "operator_repair_route_not_found", "Operator repair route not found");
6734
6769
  }
6735
- const token = env.REPAIR_OPERATOR_TOKEN;
6736
- if (!isNonEmptyString3(token) || request.headers.get("authorization") !== `Bearer ${token}`) {
6770
+ if (decision === "missing" || decision === "invalid") {
6737
6771
  return error2(401, "operator_auth_required", "A valid operator repair bearer token is required");
6738
6772
  }
6773
+ return void 0;
6774
+ }
6775
+ async function handleAuthorizedOperatorRepair(request, env, nativeTracing = noOpNativeTracing, serviceIdentityProvider) {
6739
6776
  let body;
6740
6777
  try {
6741
6778
  body = await request.json();
@@ -7358,7 +7395,7 @@ var CommitWorker = class {
7358
7395
  identity: "coordinator"
7359
7396
  }));
7360
7397
  }
7361
- async bootstrapPermitAcquire(commandId, digest2) {
7398
+ async bootstrapPermitAcquire(commandId, digest) {
7362
7399
  const stub = this.bootstrapStub();
7363
7400
  if (stub === void 0) return { ok: true };
7364
7401
  try {
@@ -7367,7 +7404,7 @@ var CommitWorker = class {
7367
7404
  const response3 = await stub.fetch(new Request(url, {
7368
7405
  method: "POST",
7369
7406
  headers: { "content-type": "application/json" },
7370
- body: JSON.stringify({ commandId, digest: digest2 })
7407
+ body: JSON.stringify({ commandId, digest })
7371
7408
  }));
7372
7409
  if (response3.ok) return { ok: true };
7373
7410
  const body = await response3.clone().json().catch(() => void 0);
@@ -7376,7 +7413,7 @@ var CommitWorker = class {
7376
7413
  return { ok: false };
7377
7414
  }
7378
7415
  }
7379
- async bootstrapPermitRelease(commandId, digest2) {
7416
+ async bootstrapPermitRelease(commandId, digest) {
7380
7417
  const stub = this.bootstrapStub();
7381
7418
  if (stub === void 0) return;
7382
7419
  try {
@@ -7385,7 +7422,7 @@ var CommitWorker = class {
7385
7422
  await stub.fetch(new Request(url, {
7386
7423
  method: "POST",
7387
7424
  headers: { "content-type": "application/json" },
7388
- body: JSON.stringify({ commandId, digest: digest2 })
7425
+ body: JSON.stringify({ commandId, digest })
7389
7426
  }));
7390
7427
  } catch {
7391
7428
  }
@@ -13899,6 +13936,33 @@ function parseRequest(value) {
13899
13936
  }
13900
13937
  };
13901
13938
  }
13939
+ var QUERY_PARAMS_UNSUPPORTED_MESSAGE = "The selected query does not support these parameters.";
13940
+ var LIST_QUERY_PARAM_KEYS = /* @__PURE__ */ new Set(["PageNumber", "PageSize", "NewestFirst", "consistency"]);
13941
+ function unsupportedQueryParams(endpoint, queryParams) {
13942
+ if (endpoint === "query") {
13943
+ if (isObject9(queryParams) && Object.keys(queryParams).length === 0) {
13944
+ return void 0;
13945
+ }
13946
+ return {
13947
+ status: 400,
13948
+ code: "query_params_unsupported",
13949
+ error: QUERY_PARAMS_UNSUPPORTED_MESSAGE
13950
+ };
13951
+ }
13952
+ if (!isObject9(queryParams)) {
13953
+ return void 0;
13954
+ }
13955
+ for (const key of Object.keys(queryParams)) {
13956
+ if (!LIST_QUERY_PARAM_KEYS.has(key)) {
13957
+ return {
13958
+ status: 400,
13959
+ code: "query_params_unsupported",
13960
+ error: QUERY_PARAMS_UNSUPPORTED_MESSAGE
13961
+ };
13962
+ }
13963
+ }
13964
+ return void 0;
13965
+ }
13902
13966
  function paginationFrom(value) {
13903
13967
  if (!isObject9(value)) {
13904
13968
  return { error: "list-query queryParamsJson must contain an object" };
@@ -14079,6 +14143,10 @@ async function handleSerializedQuery(request, env, options = {}) {
14079
14143
  if (definition === void 0 || definition.endpoint !== endpoint) {
14080
14144
  return error6(400, "validation_error", "queryType is not mapped for this endpoint");
14081
14145
  }
14146
+ const unsupported = unsupportedQueryParams(endpoint, parsed.value.queryParams);
14147
+ if (unsupported !== void 0) {
14148
+ return error6(unsupported.status, unsupported.code, unsupported.error);
14149
+ }
14082
14150
  if (!definition.enabled || (options.projectors ?? DEPLOYED_PROJECTOR_REGISTRY).resolve(definition.tagProjector) === void 0) {
14083
14151
  return error6(503, "projection_unavailable", "The mapped query projection is unavailable");
14084
14152
  }
@@ -14567,11 +14635,11 @@ function normalizeCorrection(value) {
14567
14635
  throw new IncidentLifecycleError("incident_invalid_request", 400, "correction.kind must be event or receipt");
14568
14636
  }
14569
14637
  const reference = boundedString(value.reference, "correction.reference", MAX_LONG_TEXT_BYTES);
14570
- const digest2 = boundedString(value.digest, "correction.digest", MAX_IDENTITY_BYTES);
14571
- if (reference === null || digest2 === null || !SHA256_DIGEST.test(digest2)) {
14638
+ const digest = boundedString(value.digest, "correction.digest", MAX_IDENTITY_BYTES);
14639
+ if (reference === null || digest === null || !SHA256_DIGEST.test(digest)) {
14572
14640
  throw new IncidentLifecycleError("incident_invalid_request", 400, "correction.digest must be lowercase sha256");
14573
14641
  }
14574
- return { kind: value.kind, reference, digest: digest2 };
14642
+ return { kind: value.kind, reference, digest };
14575
14643
  }
14576
14644
  function normalizeRequest(value) {
14577
14645
  if (!isObject11(value) || !hasOnlyKeys(value, ["action", "incidentIdentity", "transitionKey", "expectedVersion", "reason", "ownerId", "deadlineAt", "correction", "resolution"])) {
@@ -14627,8 +14695,8 @@ function canonicalize(value) {
14627
14695
  async function digestFor(request, actorId) {
14628
14696
  const envelope = canonicalize({ action: request, actor: actorId });
14629
14697
  const encoded = new TextEncoder().encode(JSON.stringify(envelope));
14630
- const digest2 = await crypto.subtle.digest("SHA-256", encoded);
14631
- return `sha256:${Array.from(new Uint8Array(digest2), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
14698
+ const digest = await crypto.subtle.digest("SHA-256", encoded);
14699
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
14632
14700
  }
14633
14701
  function projectionFromRow(row, finding2) {
14634
14702
  if (row === null) {
@@ -14855,13 +14923,13 @@ var IncidentLifecycle = class {
14855
14923
  }
14856
14924
  if (actorId === null) throw new IncidentLifecycleError("incident_invalid_actor", 400, "x-sdt-maintainer must be non-empty");
14857
14925
  const finding2 = await this.finding(serviceId, request.incidentIdentity);
14858
- const digest2 = await digestFor(request, actorId);
14926
+ const digest = await digestFor(request, actorId);
14859
14927
  const existing = await this.database.prepare(
14860
14928
  `SELECT * FROM serialized_dcb_incident_transitions
14861
14929
  WHERE service_id = ? AND incident_identity = ? AND transition_key = ?`
14862
14930
  ).bind(serviceId, request.incidentIdentity, request.transitionKey).first();
14863
14931
  if (existing !== null && existing !== void 0) {
14864
- if (existing.request_digest !== digest2) {
14932
+ if (existing.request_digest !== digest) {
14865
14933
  throw new IncidentLifecycleError("incident_idempotency_conflict", 409, "The transition key was already used with different content");
14866
14934
  }
14867
14935
  return { projection: transitionFromRow(existing).after, transition: transitionFromRow(existing), idempotent: true };
@@ -14924,7 +14992,7 @@ var IncidentLifecycle = class {
14924
14992
  serviceId,
14925
14993
  request.incidentIdentity,
14926
14994
  request.transitionKey,
14927
- digest2,
14995
+ digest,
14928
14996
  request.action,
14929
14997
  before.lifecycleState,
14930
14998
  before.version,
@@ -14953,7 +15021,7 @@ var IncidentLifecycle = class {
14953
15021
  `SELECT * FROM serialized_dcb_incident_transitions
14954
15022
  WHERE service_id = ? AND incident_identity = ? AND transition_key = ?`
14955
15023
  ).bind(serviceId, request.incidentIdentity, request.transitionKey).first();
14956
- if (competing !== null && competing !== void 0 && competing.request_digest !== digest2) {
15024
+ if (competing !== null && competing !== void 0 && competing.request_digest !== digest) {
14957
15025
  throw new IncidentLifecycleError("incident_idempotency_conflict", 409, "The transition key was already used with different content");
14958
15026
  }
14959
15027
  if (competing !== null && competing !== void 0) {
@@ -15055,29 +15123,6 @@ function json10(body, status = 200) {
15055
15123
  function error8(status, code, message) {
15056
15124
  return json10({ error: message, code }, status);
15057
15125
  }
15058
- function configuredToken(value) {
15059
- if (typeof value !== "string") return void 0;
15060
- const token = value.trim();
15061
- return token.length === 0 ? void 0 : token;
15062
- }
15063
- async function digest(value) {
15064
- const bytes2 = new TextEncoder().encode(value);
15065
- return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes2));
15066
- }
15067
- function fixedLengthEqual(left, right) {
15068
- let difference = left.length ^ right.length;
15069
- const length = Math.max(left.length, right.length);
15070
- for (let index = 0; index < length; index += 1) {
15071
- difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
15072
- }
15073
- return difference === 0;
15074
- }
15075
- async function authenticated(request, token) {
15076
- const credential = request.headers.get("authorization") ?? "";
15077
- const suppliedToken = credential.startsWith("Bearer ") ? credential.slice("Bearer ".length) : "";
15078
- const [expectedDigest, suppliedDigest] = await Promise.all([digest(token), digest(suppliedToken)]);
15079
- return fixedLengthEqual(expectedDigest, suppliedDigest);
15080
- }
15081
15126
  function boolFilter(value, name) {
15082
15127
  if (value === null) return void 0;
15083
15128
  if (value === "true") return true;
@@ -15131,11 +15176,13 @@ function isIncidentMaintenancePath(pathname) {
15131
15176
  return pathname === "/maintenance/incidents" || pathname.startsWith("/maintenance/incidents/");
15132
15177
  }
15133
15178
  async function handleIncidentMaintenance(request, env, serviceIdentityProvider) {
15134
- const token = configuredToken(env.INCIDENT_MAINTAINER_TOKEN);
15135
- if (token === void 0) {
15179
+ const decision = await checkConfiguredBearer(request, env.INCIDENT_MAINTAINER_TOKEN, {
15180
+ tokenNormalization: "trim"
15181
+ });
15182
+ if (decision === "unconfigured") {
15136
15183
  return error8(503, "incident_maintenance_unavailable", "Incident maintenance is not configured");
15137
15184
  }
15138
- if (!await authenticated(request, token)) {
15185
+ if (decision === "missing" || decision === "invalid") {
15139
15186
  return error8(401, "incident_auth_required", "A valid incident maintenance bearer token is required");
15140
15187
  }
15141
15188
  let serviceId;
@@ -20248,6 +20295,18 @@ function createCloudflareOnlyRuntimeWorker(options = {}) {
20248
20295
  if (isIncidentMaintenancePath(url.pathname)) {
20249
20296
  return handleIncidentMaintenance(request, env, options.serviceIdentityProvider ?? envServiceIdentity(env));
20250
20297
  }
20298
+ const operatorRepair = url.pathname === "/operator/repair";
20299
+ const operatorBootstrap = url.pathname.startsWith("/operator/bootstrap/");
20300
+ if (operatorRepair) {
20301
+ const methodDenied = nonPostOperatorRepairResponse(request);
20302
+ if (methodDenied !== void 0) return methodDenied;
20303
+ const denied = await authenticateOperatorRepair(request, env.REPAIR_OPERATOR_TOKEN);
20304
+ if (denied !== void 0) return denied;
20305
+ }
20306
+ if (operatorBootstrap) {
20307
+ const denied = await authenticateOperatorBootstrap(request, env.REPAIR_OPERATOR_TOKEN);
20308
+ if (denied !== void 0) return denied;
20309
+ }
20251
20310
  const serviceIdentity = options.serviceIdentityProvider ?? envServiceIdentity(env);
20252
20311
  const requestIdentityOptions = { allowG11Verification: env.G11_VERIFICATION_ENABLED === "true" };
20253
20312
  let requestServiceId;
@@ -20300,11 +20359,11 @@ function createCloudflareOnlyRuntimeWorker(options = {}) {
20300
20359
  serviceIdentityProvider: serviceIdentity
20301
20360
  });
20302
20361
  }
20303
- if (url.pathname === "/operator/repair") {
20304
- return handleOperatorRepair(request, env, cloudflareTracing(ctx), serviceIdentity);
20362
+ if (operatorRepair) {
20363
+ return handleAuthorizedOperatorRepair(request, env, cloudflareTracing(ctx), serviceIdentity);
20305
20364
  }
20306
- if (url.pathname.startsWith("/operator/bootstrap/")) {
20307
- return handleOperatorBootstrap(request, env, storeProvider, {
20365
+ if (operatorBootstrap) {
20366
+ return handleAuthorizedOperatorBootstrap(request, env, storeProvider, {
20308
20367
  afterVerifyBeforeReady: options.afterBootstrapVerify === void 0 ? void 0 : ({ serviceId }) => options.afterBootstrapVerify({ serviceId, env })
20309
20368
  });
20310
20369
  }
@@ -20478,6 +20537,7 @@ export {
20478
20537
  assertNoSilentLedgerMerge,
20479
20538
  assertStrictlyBefore,
20480
20539
  buildScopeName,
20540
+ checkConfiguredBearer,
20481
20541
  classifyDirectDoorbellFailure,
20482
20542
  cleanupG42JournalProbeTrial,
20483
20543
  createCloudflareOnlyRuntimeWorker,
@@ -0,0 +1,12 @@
1
+ export type ConfiguredBearerResult = "unconfigured" | "missing" | "invalid" | "authorized";
2
+ export type ConfiguredBearerTokenNormalization = "exact" | "trim";
3
+ export interface CheckConfiguredBearerOptions {
4
+ readonly tokenNormalization?: ConfiguredBearerTokenNormalization;
5
+ }
6
+ /**
7
+ * Decide whether a configured shared secret admits this request.
8
+ * Callers map the four results onto their own HTTP responses.
9
+ * The binding is configured only when it is a string with a non-whitespace
10
+ * character. Exact mode keeps those bytes; trim mode is opt-in.
11
+ */
12
+ export declare function checkConfiguredBearer(request: Request, binding: unknown, options?: CheckConfiguredBearerOptions): Promise<ConfiguredBearerResult>;
@@ -0,0 +1,33 @@
1
+ async function sha256(value) {
2
+ const encoded = new TextEncoder().encode(value);
3
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", encoded));
4
+ }
5
+ function digestsMatch(expected, supplied) {
6
+ let difference = expected.length ^ supplied.length;
7
+ const length = Math.max(expected.length, supplied.length);
8
+ for (let index = 0; index < length; index += 1) {
9
+ difference |= (expected[index] ?? 0) ^ (supplied[index] ?? 0);
10
+ }
11
+ return difference === 0;
12
+ }
13
+ /**
14
+ * Decide whether a configured shared secret admits this request.
15
+ * Callers map the four results onto their own HTTP responses.
16
+ * The binding is configured only when it is a string with a non-whitespace
17
+ * character. Exact mode keeps those bytes; trim mode is opt-in.
18
+ */
19
+ export async function checkConfiguredBearer(request, binding, options) {
20
+ if (typeof binding !== "string" || binding.trim().length === 0) {
21
+ return "unconfigured";
22
+ }
23
+ const effectiveToken = options?.tokenNormalization === "trim" ? binding.trim() : binding;
24
+ const supplied = request.headers.get("authorization");
25
+ if (supplied === null)
26
+ return "missing";
27
+ const expected = `Bearer ${effectiveToken}`;
28
+ const [expectedDigest, suppliedDigest] = await Promise.all([
29
+ sha256(expected),
30
+ sha256(supplied),
31
+ ]);
32
+ return digestsMatch(expectedDigest, suppliedDigest) ? "authorized" : "invalid";
33
+ }
@@ -1,5 +1,6 @@
1
1
  import { IncidentLifecycle, IncidentLifecycleError, normalizeIncidentIdentity, } from "../completeness/IncidentLifecycle";
2
2
  import { requestServiceIdentity, } from "../service/ServiceIdentityProvider";
3
+ import { checkConfiguredBearer } from "./ConfiguredBearer";
3
4
  function json(body, status = 200) {
4
5
  return new Response(JSON.stringify(body), {
5
6
  status,
@@ -9,30 +10,6 @@ function json(body, status = 200) {
9
10
  function error(status, code, message) {
10
11
  return json({ error: message, code }, status);
11
12
  }
12
- function configuredToken(value) {
13
- if (typeof value !== "string")
14
- return undefined;
15
- const token = value.trim();
16
- return token.length === 0 ? undefined : token;
17
- }
18
- async function digest(value) {
19
- const bytes = new TextEncoder().encode(value);
20
- return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
21
- }
22
- function fixedLengthEqual(left, right) {
23
- let difference = left.length ^ right.length;
24
- const length = Math.max(left.length, right.length);
25
- for (let index = 0; index < length; index += 1) {
26
- difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
27
- }
28
- return difference === 0;
29
- }
30
- async function authenticated(request, token) {
31
- const credential = request.headers.get("authorization") ?? "";
32
- const suppliedToken = credential.startsWith("Bearer ") ? credential.slice("Bearer ".length) : "";
33
- const [expectedDigest, suppliedDigest] = await Promise.all([digest(token), digest(suppliedToken)]);
34
- return fixedLengthEqual(expectedDigest, suppliedDigest);
35
- }
36
13
  function boolFilter(value, name) {
37
14
  if (value === null)
38
15
  return undefined;
@@ -92,11 +69,13 @@ export function isIncidentMaintenancePath(pathname) {
92
69
  return pathname === "/maintenance/incidents" || pathname.startsWith("/maintenance/incidents/");
93
70
  }
94
71
  export async function handleIncidentMaintenance(request, env, serviceIdentityProvider) {
95
- const token = configuredToken(env.INCIDENT_MAINTAINER_TOKEN);
96
- if (token === undefined) {
72
+ const decision = await checkConfiguredBearer(request, env.INCIDENT_MAINTAINER_TOKEN, {
73
+ tokenNormalization: "trim",
74
+ });
75
+ if (decision === "unconfigured") {
97
76
  return error(503, "incident_maintenance_unavailable", "Incident maintenance is not configured");
98
77
  }
99
- if (!(await authenticated(request, token))) {
78
+ if (decision === "missing" || decision === "invalid") {
100
79
  return error(401, "incident_auth_required", "A valid incident maintenance bearer token is required");
101
80
  }
102
81
  let serviceId;
@@ -57,6 +57,39 @@ function parseRequest(value) {
57
57
  },
58
58
  };
59
59
  }
60
+ const QUERY_PARAMS_UNSUPPORTED_MESSAGE = "The selected query does not support these parameters.";
61
+ const LIST_QUERY_PARAM_KEYS = new Set(["PageNumber", "PageSize", "NewestFirst", "consistency"]);
62
+ /**
63
+ * Scalar queries have no parameter interpreter, so only an empty plain object
64
+ * is a supported no-parameter call. List queries keep the four existing
65
+ * controls and leave their value checks to paginationFrom. Non-object list
66
+ * input is also left to paginationFrom so that validation response stays put.
67
+ */
68
+ function unsupportedQueryParams(endpoint, queryParams) {
69
+ if (endpoint === "query") {
70
+ if (isObject(queryParams) && Object.keys(queryParams).length === 0) {
71
+ return undefined;
72
+ }
73
+ return {
74
+ status: 400,
75
+ code: "query_params_unsupported",
76
+ error: QUERY_PARAMS_UNSUPPORTED_MESSAGE,
77
+ };
78
+ }
79
+ if (!isObject(queryParams)) {
80
+ return undefined;
81
+ }
82
+ for (const key of Object.keys(queryParams)) {
83
+ if (!LIST_QUERY_PARAM_KEYS.has(key)) {
84
+ return {
85
+ status: 400,
86
+ code: "query_params_unsupported",
87
+ error: QUERY_PARAMS_UNSUPPORTED_MESSAGE,
88
+ };
89
+ }
90
+ }
91
+ return undefined;
92
+ }
60
93
  function paginationFrom(value) {
61
94
  if (!isObject(value)) {
62
95
  return { error: "list-query queryParamsJson must contain an object" };
@@ -295,6 +328,10 @@ export async function handleSerializedQuery(request, env, options = {}) {
295
328
  if (definition === undefined || definition.endpoint !== endpoint) {
296
329
  return error(400, "validation_error", "queryType is not mapped for this endpoint");
297
330
  }
331
+ const unsupported = unsupportedQueryParams(endpoint, parsed.value.queryParams);
332
+ if (unsupported !== undefined) {
333
+ return error(unsupported.status, unsupported.code, unsupported.error);
334
+ }
298
335
  if (!definition.enabled || (options.projectors ?? DEPLOYED_PROJECTOR_REGISTRY).resolve(definition.tagProjector) === undefined) {
299
336
  return error(503, "projection_unavailable", "The mapped query projection is unavailable");
300
337
  }
package/dist/index.d.ts CHANGED
@@ -55,6 +55,8 @@ export type { SerializedEventMetadata } from "./eventRecord";
55
55
  export type { DeliveryCoreEnvironment, DeliveryCoreFailure, DeliveryCoreOptions, DeliveryCoreResult, DeliverySource, DeliveryViewApplyResult, DeliveryViewFailureClass, DeliveryViewHandler, DeliveryViewInput, DeliveryViewResult, } from "./downstream/DeliveryCore";
56
56
  export type { DeliveryClass, DirectDoorbellDegradation, DirectDoorbellDeploymentConfig, DirectDoorbellPreflightResult, DirectDoorbellReceiverMode, DirectDoorbellFailureKind, DownstreamDoorbellBinding, } from "./downstream/Doorbell";
57
57
  export type { JsonValue, MaterializedViewRowPatch } from "@sekiban/dcb-core";
58
+ export { checkConfiguredBearer } from "./http/ConfiguredBearer";
59
+ export type { CheckConfiguredBearerOptions, ConfiguredBearerResult, ConfiguredBearerTokenNormalization, } from "./http/ConfiguredBearer";
58
60
  export { composeRuntime, createRuntimeCommitPort, registeredEventParsers, RuntimeCommandRegistry, } from "./composition";
59
61
  export type { RuntimeCommitAllocationLike, RuntimeCommitCandidateLike, RuntimeCommitPort, RuntimeCommitPortOptions, RuntimeCommitPortResult, RuntimeCommandLike, RuntimeDomainLike, RuntimeQueryDefinition, RuntimeWorkerConfig, } from "./composition";
60
62
  export { POSTGRES_STORE_PROVIDER, createPostgresBootstrapAdapter, createPostgresStoreProvider } from "./store/provider";
package/dist/index.js CHANGED
@@ -5542,15 +5542,15 @@ var BootstrapCoordinatorDurableObject = class {
5542
5542
  async permit(serviceId, body) {
5543
5543
  if (!object(body) || !string(body.commandId) || !string(body.digest)) return reject("bootstrap_permit_invalid", "commandId and digest are required", 400);
5544
5544
  const commandId = body.commandId;
5545
- const digest2 = body.digest;
5545
+ const digest = body.digest;
5546
5546
  const result2 = await this.ctx.storage.transaction(async (txn) => {
5547
5547
  const control = await txn.get(CONTROL) ?? empty(serviceId);
5548
5548
  const active2 = permits(control);
5549
5549
  if (active2[commandId] !== void 0) {
5550
- return active2[commandId] === digest2 ? { control } : { digestMismatch: true };
5550
+ return active2[commandId] === digest ? { control } : { digestMismatch: true };
5551
5551
  }
5552
5552
  if (control.status !== "EMPTY" && control.status !== "READY") return { rejected: true };
5553
- const updated = { ...control, writePermits: { ...active2, [commandId]: digest2 } };
5553
+ const updated = { ...control, writePermits: { ...active2, [commandId]: digest } };
5554
5554
  await txn.put(CONTROL, updated);
5555
5555
  return { control: updated };
5556
5556
  });
@@ -5561,11 +5561,11 @@ var BootstrapCoordinatorDurableObject = class {
5561
5561
  async permitRelease(serviceId, body) {
5562
5562
  if (!object(body) || !string(body.commandId)) return reject("bootstrap_permit_invalid", "commandId is required", 400);
5563
5563
  const commandId = body.commandId;
5564
- const digest2 = string(body.digest) ? body.digest : void 0;
5564
+ const digest = string(body.digest) ? body.digest : void 0;
5565
5565
  const result2 = await this.ctx.storage.transaction(async (txn) => {
5566
5566
  const control = await txn.get(CONTROL) ?? empty(serviceId);
5567
5567
  const active2 = permits(control);
5568
- if (active2[commandId] !== void 0 && digest2 !== void 0 && active2[commandId] !== digest2) {
5568
+ if (active2[commandId] !== void 0 && digest !== void 0 && active2[commandId] !== digest) {
5569
5569
  return { digestMismatch: true };
5570
5570
  }
5571
5571
  if (active2[commandId] === void 0) return { control };
@@ -5751,8 +5751,8 @@ function eventDigestBytes(input) {
5751
5751
  async function eventDigestHex(input) {
5752
5752
  const bytes2 = eventDigestBytes(input);
5753
5753
  const source = bytes2.buffer.slice(bytes2.byteOffset, bytes2.byteOffset + bytes2.byteLength);
5754
- const digest2 = await crypto.subtle.digest("SHA-256", source);
5755
- return [...new Uint8Array(digest2)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
5754
+ const digest = await crypto.subtle.digest("SHA-256", source);
5755
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
5756
5756
  }
5757
5757
 
5758
5758
  // src/bootstrap/BootstrapStoreAdapter.ts
@@ -5898,6 +5898,34 @@ function createBootstrapStoreAdapter(provider, store, identityGuard) {
5898
5898
  return new BootstrapStoreAdapter(provider, store, identityGuard);
5899
5899
  }
5900
5900
 
5901
+ // src/http/ConfiguredBearer.ts
5902
+ async function sha256(value) {
5903
+ const encoded = new TextEncoder().encode(value);
5904
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", encoded));
5905
+ }
5906
+ function digestsMatch(expected, supplied) {
5907
+ let difference = expected.length ^ supplied.length;
5908
+ const length = Math.max(expected.length, supplied.length);
5909
+ for (let index = 0; index < length; index += 1) {
5910
+ difference |= (expected[index] ?? 0) ^ (supplied[index] ?? 0);
5911
+ }
5912
+ return difference === 0;
5913
+ }
5914
+ async function checkConfiguredBearer(request, binding, options) {
5915
+ if (typeof binding !== "string" || binding.trim().length === 0) {
5916
+ return "unconfigured";
5917
+ }
5918
+ const effectiveToken = options?.tokenNormalization === "trim" ? binding.trim() : binding;
5919
+ const supplied = request.headers.get("authorization");
5920
+ if (supplied === null) return "missing";
5921
+ const expected = `Bearer ${effectiveToken}`;
5922
+ const [expectedDigest, suppliedDigest] = await Promise.all([
5923
+ sha256(expected),
5924
+ sha256(supplied)
5925
+ ]);
5926
+ return digestsMatch(expectedDigest, suppliedDigest) ? "authorized" : "invalid";
5927
+ }
5928
+
5901
5929
  // src/bootstrap/OperatorBootstrap.ts
5902
5930
  function json2(body, status = 200) {
5903
5931
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } });
@@ -5905,15 +5933,17 @@ function json2(body, status = 200) {
5905
5933
  function object2(value) {
5906
5934
  return typeof value === "object" && value !== null && !Array.isArray(value);
5907
5935
  }
5908
- function bearer(request, token) {
5909
- const supplied = request.headers.get("authorization");
5910
- if (supplied === null) return json2({ code: "not_found", error: "not found" }, 404);
5911
- if (supplied !== `Bearer ${token}`) return json2({ code: "operator_auth_required", error: "valid bearer required" }, 403);
5936
+ async function authenticateOperatorBootstrap(request, binding) {
5937
+ const decision = await checkConfiguredBearer(request, binding);
5938
+ if (decision === "unconfigured" || decision === "missing") {
5939
+ return json2({ code: "not_found", error: "not found" }, 404);
5940
+ }
5941
+ if (decision === "invalid") {
5942
+ return json2({ code: "operator_auth_required", error: "valid bearer required" }, 403);
5943
+ }
5912
5944
  return void 0;
5913
5945
  }
5914
- async function handleOperatorBootstrap(request, env, storeProvider, hooks = {}) {
5915
- const denied = bearer(request, env.REPAIR_OPERATOR_TOKEN);
5916
- if (denied !== void 0) return denied;
5946
+ async function handleAuthorizedOperatorBootstrap(request, env, storeProvider, hooks = {}) {
5917
5947
  const url = new URL(request.url);
5918
5948
  const match = url.pathname.match(/^\/operator\/bootstrap\/([^/]+)\/(plan|import|status|abort|export)$/);
5919
5949
  if (match === null) return json2({ code: "not_found", error: "not found" }, 404);
@@ -6812,14 +6842,21 @@ function testFaultFromRequest(request) {
6812
6842
  const value = request.headers.get("x-sdt-g6-test-fault");
6813
6843
  return value === "after-lease-before-prepare" || value === "after-prepare-before-apply" || value === "after-apply-before-observation" || value === "after-verify-before-audit" || value === "after-audit-before-clear" || value === "after-clear-before-final-observation" ? value : void 0;
6814
6844
  }
6815
- async function handleOperatorRepair(request, env, nativeTracing = noOpNativeTracing, serviceIdentityProvider) {
6816
- if (request.method !== "POST") {
6817
- return error2(404, "operator_repair_route_not_found", "Operator repair requires POST");
6845
+ function nonPostOperatorRepairResponse(request) {
6846
+ if (request.method === "POST") return void 0;
6847
+ return error2(404, "operator_repair_route_not_found", "Operator repair requires POST");
6848
+ }
6849
+ async function authenticateOperatorRepair(request, binding) {
6850
+ const decision = await checkConfiguredBearer(request, binding);
6851
+ if (decision === "unconfigured") {
6852
+ return error2(404, "operator_repair_route_not_found", "Operator repair route not found");
6818
6853
  }
6819
- const token = env.REPAIR_OPERATOR_TOKEN;
6820
- if (!isNonEmptyString3(token) || request.headers.get("authorization") !== `Bearer ${token}`) {
6854
+ if (decision === "missing" || decision === "invalid") {
6821
6855
  return error2(401, "operator_auth_required", "A valid operator repair bearer token is required");
6822
6856
  }
6857
+ return void 0;
6858
+ }
6859
+ async function handleAuthorizedOperatorRepair(request, env, nativeTracing = noOpNativeTracing, serviceIdentityProvider) {
6823
6860
  let body;
6824
6861
  try {
6825
6862
  body = await request.json();
@@ -7442,7 +7479,7 @@ var CommitWorker = class {
7442
7479
  identity: "coordinator"
7443
7480
  }));
7444
7481
  }
7445
- async bootstrapPermitAcquire(commandId, digest2) {
7482
+ async bootstrapPermitAcquire(commandId, digest) {
7446
7483
  const stub = this.bootstrapStub();
7447
7484
  if (stub === void 0) return { ok: true };
7448
7485
  try {
@@ -7451,7 +7488,7 @@ var CommitWorker = class {
7451
7488
  const response3 = await stub.fetch(new Request(url, {
7452
7489
  method: "POST",
7453
7490
  headers: { "content-type": "application/json" },
7454
- body: JSON.stringify({ commandId, digest: digest2 })
7491
+ body: JSON.stringify({ commandId, digest })
7455
7492
  }));
7456
7493
  if (response3.ok) return { ok: true };
7457
7494
  const body = await response3.clone().json().catch(() => void 0);
@@ -7460,7 +7497,7 @@ var CommitWorker = class {
7460
7497
  return { ok: false };
7461
7498
  }
7462
7499
  }
7463
- async bootstrapPermitRelease(commandId, digest2) {
7500
+ async bootstrapPermitRelease(commandId, digest) {
7464
7501
  const stub = this.bootstrapStub();
7465
7502
  if (stub === void 0) return;
7466
7503
  try {
@@ -7469,7 +7506,7 @@ var CommitWorker = class {
7469
7506
  await stub.fetch(new Request(url, {
7470
7507
  method: "POST",
7471
7508
  headers: { "content-type": "application/json" },
7472
- body: JSON.stringify({ commandId, digest: digest2 })
7509
+ body: JSON.stringify({ commandId, digest })
7473
7510
  }));
7474
7511
  } catch {
7475
7512
  }
@@ -12191,6 +12228,33 @@ function parseRequest(value) {
12191
12228
  }
12192
12229
  };
12193
12230
  }
12231
+ var QUERY_PARAMS_UNSUPPORTED_MESSAGE = "The selected query does not support these parameters.";
12232
+ var LIST_QUERY_PARAM_KEYS = /* @__PURE__ */ new Set(["PageNumber", "PageSize", "NewestFirst", "consistency"]);
12233
+ function unsupportedQueryParams(endpoint, queryParams) {
12234
+ if (endpoint === "query") {
12235
+ if (isObject9(queryParams) && Object.keys(queryParams).length === 0) {
12236
+ return void 0;
12237
+ }
12238
+ return {
12239
+ status: 400,
12240
+ code: "query_params_unsupported",
12241
+ error: QUERY_PARAMS_UNSUPPORTED_MESSAGE
12242
+ };
12243
+ }
12244
+ if (!isObject9(queryParams)) {
12245
+ return void 0;
12246
+ }
12247
+ for (const key of Object.keys(queryParams)) {
12248
+ if (!LIST_QUERY_PARAM_KEYS.has(key)) {
12249
+ return {
12250
+ status: 400,
12251
+ code: "query_params_unsupported",
12252
+ error: QUERY_PARAMS_UNSUPPORTED_MESSAGE
12253
+ };
12254
+ }
12255
+ }
12256
+ return void 0;
12257
+ }
12194
12258
  function paginationFrom(value) {
12195
12259
  if (!isObject9(value)) {
12196
12260
  return { error: "list-query queryParamsJson must contain an object" };
@@ -12371,6 +12435,10 @@ async function handleSerializedQuery(request, env, options = {}) {
12371
12435
  if (definition === void 0 || definition.endpoint !== endpoint) {
12372
12436
  return error6(400, "validation_error", "queryType is not mapped for this endpoint");
12373
12437
  }
12438
+ const unsupported = unsupportedQueryParams(endpoint, parsed.value.queryParams);
12439
+ if (unsupported !== void 0) {
12440
+ return error6(unsupported.status, unsupported.code, unsupported.error);
12441
+ }
12374
12442
  if (!definition.enabled || (options.projectors ?? DEPLOYED_PROJECTOR_REGISTRY).resolve(definition.tagProjector) === void 0) {
12375
12443
  return error6(503, "projection_unavailable", "The mapped query projection is unavailable");
12376
12444
  }
@@ -12859,11 +12927,11 @@ function normalizeCorrection(value) {
12859
12927
  throw new IncidentLifecycleError("incident_invalid_request", 400, "correction.kind must be event or receipt");
12860
12928
  }
12861
12929
  const reference = boundedString(value.reference, "correction.reference", MAX_LONG_TEXT_BYTES);
12862
- const digest2 = boundedString(value.digest, "correction.digest", MAX_IDENTITY_BYTES);
12863
- if (reference === null || digest2 === null || !SHA256_DIGEST.test(digest2)) {
12930
+ const digest = boundedString(value.digest, "correction.digest", MAX_IDENTITY_BYTES);
12931
+ if (reference === null || digest === null || !SHA256_DIGEST.test(digest)) {
12864
12932
  throw new IncidentLifecycleError("incident_invalid_request", 400, "correction.digest must be lowercase sha256");
12865
12933
  }
12866
- return { kind: value.kind, reference, digest: digest2 };
12934
+ return { kind: value.kind, reference, digest };
12867
12935
  }
12868
12936
  function normalizeRequest(value) {
12869
12937
  if (!isObject11(value) || !hasOnlyKeys(value, ["action", "incidentIdentity", "transitionKey", "expectedVersion", "reason", "ownerId", "deadlineAt", "correction", "resolution"])) {
@@ -12919,8 +12987,8 @@ function canonicalize(value) {
12919
12987
  async function digestFor(request, actorId) {
12920
12988
  const envelope = canonicalize({ action: request, actor: actorId });
12921
12989
  const encoded = new TextEncoder().encode(JSON.stringify(envelope));
12922
- const digest2 = await crypto.subtle.digest("SHA-256", encoded);
12923
- return `sha256:${Array.from(new Uint8Array(digest2), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
12990
+ const digest = await crypto.subtle.digest("SHA-256", encoded);
12991
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
12924
12992
  }
12925
12993
  function projectionFromRow(row, finding2) {
12926
12994
  if (row === null) {
@@ -13147,13 +13215,13 @@ var IncidentLifecycle = class {
13147
13215
  }
13148
13216
  if (actorId === null) throw new IncidentLifecycleError("incident_invalid_actor", 400, "x-sdt-maintainer must be non-empty");
13149
13217
  const finding2 = await this.finding(serviceId, request.incidentIdentity);
13150
- const digest2 = await digestFor(request, actorId);
13218
+ const digest = await digestFor(request, actorId);
13151
13219
  const existing = await this.database.prepare(
13152
13220
  `SELECT * FROM serialized_dcb_incident_transitions
13153
13221
  WHERE service_id = ? AND incident_identity = ? AND transition_key = ?`
13154
13222
  ).bind(serviceId, request.incidentIdentity, request.transitionKey).first();
13155
13223
  if (existing !== null && existing !== void 0) {
13156
- if (existing.request_digest !== digest2) {
13224
+ if (existing.request_digest !== digest) {
13157
13225
  throw new IncidentLifecycleError("incident_idempotency_conflict", 409, "The transition key was already used with different content");
13158
13226
  }
13159
13227
  return { projection: transitionFromRow(existing).after, transition: transitionFromRow(existing), idempotent: true };
@@ -13216,7 +13284,7 @@ var IncidentLifecycle = class {
13216
13284
  serviceId,
13217
13285
  request.incidentIdentity,
13218
13286
  request.transitionKey,
13219
- digest2,
13287
+ digest,
13220
13288
  request.action,
13221
13289
  before.lifecycleState,
13222
13290
  before.version,
@@ -13245,7 +13313,7 @@ var IncidentLifecycle = class {
13245
13313
  `SELECT * FROM serialized_dcb_incident_transitions
13246
13314
  WHERE service_id = ? AND incident_identity = ? AND transition_key = ?`
13247
13315
  ).bind(serviceId, request.incidentIdentity, request.transitionKey).first();
13248
- if (competing !== null && competing !== void 0 && competing.request_digest !== digest2) {
13316
+ if (competing !== null && competing !== void 0 && competing.request_digest !== digest) {
13249
13317
  throw new IncidentLifecycleError("incident_idempotency_conflict", 409, "The transition key was already used with different content");
13250
13318
  }
13251
13319
  if (competing !== null && competing !== void 0) {
@@ -13347,29 +13415,6 @@ function json10(body, status = 200) {
13347
13415
  function error8(status, code, message) {
13348
13416
  return json10({ error: message, code }, status);
13349
13417
  }
13350
- function configuredToken(value) {
13351
- if (typeof value !== "string") return void 0;
13352
- const token = value.trim();
13353
- return token.length === 0 ? void 0 : token;
13354
- }
13355
- async function digest(value) {
13356
- const bytes2 = new TextEncoder().encode(value);
13357
- return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes2));
13358
- }
13359
- function fixedLengthEqual(left, right) {
13360
- let difference = left.length ^ right.length;
13361
- const length = Math.max(left.length, right.length);
13362
- for (let index = 0; index < length; index += 1) {
13363
- difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
13364
- }
13365
- return difference === 0;
13366
- }
13367
- async function authenticated(request, token) {
13368
- const credential = request.headers.get("authorization") ?? "";
13369
- const suppliedToken = credential.startsWith("Bearer ") ? credential.slice("Bearer ".length) : "";
13370
- const [expectedDigest, suppliedDigest] = await Promise.all([digest(token), digest(suppliedToken)]);
13371
- return fixedLengthEqual(expectedDigest, suppliedDigest);
13372
- }
13373
13418
  function boolFilter(value, name) {
13374
13419
  if (value === null) return void 0;
13375
13420
  if (value === "true") return true;
@@ -13423,11 +13468,13 @@ function isIncidentMaintenancePath(pathname) {
13423
13468
  return pathname === "/maintenance/incidents" || pathname.startsWith("/maintenance/incidents/");
13424
13469
  }
13425
13470
  async function handleIncidentMaintenance(request, env, serviceIdentityProvider) {
13426
- const token = configuredToken(env.INCIDENT_MAINTAINER_TOKEN);
13427
- if (token === void 0) {
13471
+ const decision = await checkConfiguredBearer(request, env.INCIDENT_MAINTAINER_TOKEN, {
13472
+ tokenNormalization: "trim"
13473
+ });
13474
+ if (decision === "unconfigured") {
13428
13475
  return error8(503, "incident_maintenance_unavailable", "Incident maintenance is not configured");
13429
13476
  }
13430
- if (!await authenticated(request, token)) {
13477
+ if (decision === "missing" || decision === "invalid") {
13431
13478
  return error8(401, "incident_auth_required", "A valid incident maintenance bearer token is required");
13432
13479
  }
13433
13480
  let serviceId;
@@ -20735,6 +20782,18 @@ function createRuntimeWorker(options = {}) {
20735
20782
  if (isIncidentMaintenancePath(url.pathname)) {
20736
20783
  return handleIncidentMaintenance(request, env, options.serviceIdentityProvider ?? envServiceIdentity(env));
20737
20784
  }
20785
+ const operatorRepair = url.pathname === "/operator/repair";
20786
+ const operatorBootstrap = url.pathname.startsWith("/operator/bootstrap/");
20787
+ if (operatorRepair) {
20788
+ const methodDenied = nonPostOperatorRepairResponse(request);
20789
+ if (methodDenied !== void 0) return methodDenied;
20790
+ const denied = await authenticateOperatorRepair(request, env.REPAIR_OPERATOR_TOKEN);
20791
+ if (denied !== void 0) return denied;
20792
+ }
20793
+ if (operatorBootstrap) {
20794
+ const denied = await authenticateOperatorBootstrap(request, env.REPAIR_OPERATOR_TOKEN);
20795
+ if (denied !== void 0) return denied;
20796
+ }
20738
20797
  const serviceIdentity = options.serviceIdentityProvider ?? envServiceIdentity(env);
20739
20798
  const requestIdentityOptions = { allowG11Verification: env.G11_VERIFICATION_ENABLED === "true" };
20740
20799
  let requestServiceId;
@@ -20764,11 +20823,16 @@ function createRuntimeWorker(options = {}) {
20764
20823
  serviceIdentityProvider: serviceIdentity
20765
20824
  });
20766
20825
  }
20767
- if (url.pathname === "/operator/repair") {
20768
- return handleOperatorRepair(request, env, nativeTracingFromContext(ctx), serviceIdentity);
20826
+ if (operatorRepair) {
20827
+ return handleAuthorizedOperatorRepair(
20828
+ request,
20829
+ env,
20830
+ nativeTracingFromContext(ctx),
20831
+ serviceIdentity
20832
+ );
20769
20833
  }
20770
- if (url.pathname.startsWith("/operator/bootstrap/")) {
20771
- return handleOperatorBootstrap(request, env, storeProvider);
20834
+ if (operatorBootstrap) {
20835
+ return handleAuthorizedOperatorBootstrap(request, env, storeProvider);
20772
20836
  }
20773
20837
  if (url.pathname === "/internal/downstream/drain" && request.method === "POST") {
20774
20838
  return handleOutboxDrainRequest(request, env, { serviceIdentityProvider: serviceIdentity });
@@ -20946,6 +21010,7 @@ export {
20946
21010
  buildScopeName,
20947
21011
  calculateUnattributedRatio,
20948
21012
  canonicalEventType,
21013
+ checkConfiguredBearer,
20949
21014
  chooseQueryBacking,
20950
21015
  classifyDirectDoorbellFailure,
20951
21016
  classifyReactivationCause,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sekiban/dcb-runtime",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "description": "Cloudflare Durable Object and HTTP runtime for Serialized DCB V1.",
6
6
  "license": "Elastic-2.0",
@@ -52,7 +52,7 @@
52
52
  "provenance": true
53
53
  },
54
54
  "dependencies": {
55
- "@sekiban/dcb-core": "0.2.1"
55
+ "@sekiban/dcb-core": "0.2.2"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsc -p tsconfig.build.json && esbuild src/index.ts --bundle --format=esm --platform=neutral --external:postgres --external:cloudflare:workers --outfile=dist/index.js && esbuild src/cosmos.ts --bundle --format=esm --platform=neutral --external:postgres --external:cloudflare:workers --outfile=dist/cosmos.js && esbuild src/d1.ts --bundle --format=esm --platform=neutral --external:postgres --external:cloudflare:workers --outfile=dist/d1.js && esbuild src/d1-mv.ts --bundle --format=esm --platform=neutral --external:postgres --external:cloudflare:workers --outfile=dist/d1-mv.js && esbuild src/cloudflare.ts --bundle --format=esm --platform=neutral --external:cloudflare:workers --outfile=dist/cloudflare.js && esbuild src/mv/index.ts --bundle --format=esm --platform=neutral --external:postgres --external:cloudflare:workers --outfile=dist/mv/index.js",