@byollm/relay 0.1.0-alpha.24 → 0.1.0-alpha.26

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.js CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ AWAITING_PAYLOAD_MS,
3
+ RelayState,
4
+ routeKey
5
+ } from "./chunk-ZH2GB6FY.js";
6
+
1
7
  // src/daemon-plane.ts
2
8
  import {
3
9
  ClaimRequest,
@@ -84,17 +90,10 @@ var DaemonPlane = class {
84
90
  if (!verifyPublicIdentity(parsed.data.device)) {
85
91
  return fail(400, "bad-request", "the device identity is not consistent");
86
92
  }
87
- const consent = this.#deps.projection.consentFor(
88
- parsed.data.owner,
89
- this.#deps.siteId
90
- );
91
- if (!consent) {
93
+ const sites = this.#deps.projection.sitesFor(parsed.data.owner);
94
+ if (sites.length === 0) {
92
95
  return fail(403, "forbidden", "no consent record for this user");
93
96
  }
94
- const site = this.#deps.projection.siteFor(this.#deps.siteId);
95
- if (!site) {
96
- return fail(403, "forbidden", "this site is not registered");
97
- }
98
97
  const approved = this.#deps.projection.deviceByFingerprint(
99
98
  parsed.data.device.identity
100
99
  );
@@ -117,8 +116,17 @@ var DaemonPlane = class {
117
116
  return ok({
118
117
  protocolVersion: PROTOCOL_VERSION,
119
118
  runnerId,
120
- /** The *site's* key. See the note above — this is load-bearing. */
121
- site: site.site
119
+ /**
120
+ * The *sites'* keys. See the note above — this is load-bearing.
121
+ *
122
+ * The set this owner has consented to, keyed by each site's identity
123
+ * key id (cloud_009 §5). Paused sites are here: a paused consent keeps
124
+ * its pin so re-consenting never costs a re-pair, and what it does not
125
+ * do is route.
126
+ */
127
+ sites: Object.fromEntries(
128
+ sites.map((record) => [keyId(record.site.identity), record.site])
129
+ )
122
130
  });
123
131
  }
124
132
  /** Every authenticated call: signature first, then consent, then work. */
@@ -140,7 +148,7 @@ var DaemonPlane = class {
140
148
  });
141
149
  if (failure === "stale") return this.#clockSkew();
142
150
  if (failure) return fail(401, "unauthorized", "signature check failed");
143
- const revoked = this.#deps.projection.consentFor(known.owner, this.#deps.siteId) === null;
151
+ const revoked = this.#deps.projection.sitesFor(known.owner).length === 0;
144
152
  if (revoked && options.allowRevoked !== true) {
145
153
  return fail(403, "revoked", "routing for this runner has been revoked");
146
154
  }
@@ -176,19 +184,16 @@ var DaemonPlane = class {
176
184
  runnerId: device.runnerId,
177
185
  owner: device.owner,
178
186
  device: device.device,
179
- siteId: this.#deps.siteId,
180
187
  kinds: new Set(request.capabilities.map((c) => c.kind)),
181
188
  // The projection, collapsed to data the store can match on — a
182
- // predicate does not travel.
189
+ // predicate does not travel, and a set of (site, owner) pairs is
190
+ // what a route is (cloud_009 §3).
183
191
  //
184
- // `routableOwners`, not `ownersRunnableBy`: the roster says whose
185
- // work *may* land here and consent says whether it moves at all. A
186
- // paused consent (cloud_008 finding 48) leaves this set empty rather
187
- // than refusing the call, which is the whole difference between "we
188
- // are waiting for you to read something" and "a human cut you off".
189
- owners: new Set(
190
- this.#deps.projection.routableOwners(device.owner, this.#deps.siteId)
191
- ),
192
+ // A paused consent (cloud_008 finding 48) drops its routes and
193
+ // leaves the rest, which is the whole difference between "we are
194
+ // waiting for you to read something about one site" and "a human cut
195
+ // you off from everything".
196
+ routes: this.#deps.projection.routesFor(device.owner),
192
197
  max: request.max,
193
198
  leaseMs: this.#deps.leaseMs
194
199
  });
@@ -246,14 +251,17 @@ var DaemonPlane = class {
246
251
  async (request, device) => {
247
252
  const now = this.#deps.now();
248
253
  await this.#deps.state.sweep();
249
- const consent = this.#deps.projection.consentFor(
250
- device.owner,
251
- this.#deps.siteId
254
+ const pinned = this.#deps.projection.sitesFor(device.owner);
255
+ const sites = Object.fromEntries(
256
+ pinned.map((record) => [keyId(record.site.identity), record.site])
252
257
  );
253
- const revoked = consent === null;
254
- if (revoked) {
258
+ const awaitingConsent = pinned.filter(
259
+ (record) => !this.#deps.projection.mayRouteFor(device.owner, record.siteId)
260
+ ).map((record) => keyId(record.site.identity));
261
+ if (pinned.length === 0) {
255
262
  return ok({
256
- revoked,
263
+ sites,
264
+ awaitingConsent,
257
265
  cancel: [],
258
266
  lost: request.activeLeases.map((lease) => lease.jobId),
259
267
  serverTime: now
@@ -266,7 +274,8 @@ var DaemonPlane = class {
266
274
  leaseMs: this.#deps.leaseMs
267
275
  });
268
276
  return ok({
269
- revoked,
277
+ sites,
278
+ awaitingConsent,
270
279
  cancel,
271
280
  lost,
272
281
  serverTime: now
@@ -510,6 +519,35 @@ var Projection = class {
510
519
  (c) => c.owner === owner && c.siteId === siteId
511
520
  ) ?? null;
512
521
  }
522
+ /**
523
+ * Every site this owner may route with — cloud_009 §3.
524
+ *
525
+ * The set a pairing covers, and the set a claim will filter on. Consent
526
+ * decides it, which is the sentence the whole design rests on: a site
527
+ * appears here because a human clicked, never because a site asked to be
528
+ * here and never because a daemon named it.
529
+ *
530
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
531
+ * as ratified. A paused consent routes nothing and keeps its pin: the
532
+ * relationship stands, the key the daemon compared a fingerprint of stays
533
+ * pinned, and re-consenting never costs a re-pair. Written the other way
534
+ * round first, and three of the paused tests failed by refusing to pair at
535
+ * all — which is the trap the finding is about, arriving through the door
536
+ * marked "be stricter".
537
+ *
538
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
539
+ * questions with different answers, kept apart for the same reason
540
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
541
+ * paused user quietly starts routing again, or quietly loses their machine.
542
+ *
543
+ * Sorted by site id so two calls with the same projection produce the same
544
+ * answer: this ends up in a pairings file and in a fingerprint list a human
545
+ * compares by eye, and an order that drifts between polls is a diff nobody
546
+ * can read.
547
+ */
548
+ sitesFor(owner) {
549
+ return this.#fixture.sites.filter((site) => this.consentFor(owner, site.siteId) !== null).sort((a, b) => a.siteId < b.siteId ? -1 : 1);
550
+ }
513
551
  /**
514
552
  * May this owner's work move for this site, right now?
515
553
  *
@@ -526,29 +564,38 @@ var Projection = class {
526
564
  return this.consentFor(owner, siteId)?.paused === true;
527
565
  }
528
566
  /**
529
- * Whose work this device may run for this site — the set a claim carries.
567
+ * Every (site, owner) route this device may run — cloud_009 §3.
568
+ *
569
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
570
+ * was this for one site; the hub needs it for the set, and the shape had to
571
+ * change rather than repeat, because **a set of sites and a set of owners
572
+ * multiply**. A device whose owner consented to site A, serving a roster
573
+ * member who consented to site B, appears in both sets and has no consented
574
+ * route between them. Pairs cannot express a route nobody agreed to.
530
575
  *
531
- * {@link Projection.ownersRunnableBy} collapsed and then filtered by
532
- * consent, which is two things this relay was doing in one place and one
533
- * place respectively:
576
+ * Both halves of the rule are here, and neither was enforced before finding
577
+ * 48's work:
534
578
  *
535
- * - **The device's own owner is checked first**, and an unroutable one
536
- * empties the whole set. A machine whose owner has not agreed to this
537
- * site's current terms runs nothing for it, including a roster member's
538
- * work: the roster says whose jobs may land here, and consent says
539
- * whether this machine is available to the site at all.
540
- * - **Every job owner is checked too.** That check did not exist. Consent
541
- * was enforced by the daemon plane's blanket revoked guard, which asks
542
- * only about the *claiming* device's owner — so a roster member who never
543
- * consented to a site could have their work claimed by their admin's
544
- * machine, which is `CONSENT_BEFORE_ROUTE` read the other way round. The
545
- * site plane does not check consent at enqueue either, so nothing did.
579
+ * - **This machine's owner** must have a live consent for the site, or
580
+ * nothing of that site's runs here at all — including a roster member's
581
+ * work. The roster says whose jobs may land on this machine; consent says
582
+ * whether this machine is available to that site.
583
+ * - **Each job's owner** must have one too. That check did not exist:
584
+ * consent was enforced by the daemon plane's blanket revoked guard, which
585
+ * asks only about the claiming device's owner, so a roster member who
586
+ * never consented to a site could have their work claimed by their admin's
587
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
546
588
  */
547
- routableOwners(deviceOwner, siteId) {
548
- if (!this.mayRouteFor(deviceOwner, siteId)) return [];
549
- return this.ownersRunnableBy(deviceOwner).filter(
550
- (owner) => this.mayRouteFor(owner, siteId)
551
- );
589
+ routesFor(deviceOwner) {
590
+ const routes = /* @__PURE__ */ new Set();
591
+ for (const site of this.#fixture.sites) {
592
+ if (!this.mayRouteFor(deviceOwner, site.siteId)) continue;
593
+ for (const owner of this.ownersRunnableBy(deviceOwner)) {
594
+ if (!this.mayRouteFor(owner, site.siteId)) continue;
595
+ routes.add(routeKey(site.siteId, owner));
596
+ }
597
+ }
598
+ return routes;
552
599
  }
553
600
  /**
554
601
  * Every owner whose work this device's owner may run, as a list.
@@ -676,7 +723,7 @@ var SitePlane = class {
676
723
  if (siteIdOf(parsed.data) !== siteId) {
677
724
  return fail2(403, "forbidden", "that is not your site");
678
725
  }
679
- if (siteId !== this.#deps.routesFor) {
726
+ if (this.#deps.projection.siteFor(siteId) === null) {
680
727
  return fail2(403, "forbidden", "this relay does not route for you");
681
728
  }
682
729
  return run(parsed.data, siteId);
@@ -702,7 +749,7 @@ var SitePlane = class {
702
749
  stub: request.stub
703
750
  });
704
751
  if ("refused" in job) {
705
- return fail2(403, "forbidden", "that job id belongs to another site");
752
+ return fail2(400, "bad-request", "that stub was not accepted");
706
753
  }
707
754
  return ok2({ jobId: job.id, state: job.state });
708
755
  }
@@ -835,296 +882,6 @@ var SitePlane = class {
835
882
  }
836
883
  };
837
884
 
838
- // src/state.ts
839
- import { randomUUID } from "crypto";
840
- var AWAITING_PAYLOAD_MS = 1e4;
841
- var RelayState = class {
842
- #jobs = /* @__PURE__ */ new Map();
843
- #presence = /* @__PURE__ */ new Map();
844
- #now;
845
- constructor(options = {}) {
846
- this.#now = options.now ?? Date.now;
847
- }
848
- /** The one clock every deadline in this store is stamped from. */
849
- async now() {
850
- return this.#now();
851
- }
852
- /**
853
- * Take a stub for routing. The payload is not here and will not be.
854
- *
855
- * **Idempotent by job id, and that is a security property rather than a
856
- * convenience.** Site-plane calls are authenticated by signature, and
857
- * byollm_009 §4.2's argument for signing the request instead of a
858
- * server-issued nonce rests entirely on every write being idempotent per the
859
- * instance it names. This one was not: re-enqueueing a known id built a
860
- * fresh `queued` job over the top of the old one, discarding a live claim,
861
- * its lease and any payload the site had already sealed to a device. A
862
- * replayed enqueue inside the two-minute freshness window was therefore a
863
- * way to yank a job back from the machine running it — the `release` bug of
864
- * §4.2, rediscovered on the other plane.
865
- *
866
- * So a known id returns what is already routing, unchanged. A site that
867
- * restarts and republishes its queue is the normal case, and it must not
868
- * disturb work in flight.
869
- */
870
- enqueue(input) {
871
- const existing = this.#jobs.get(input.id);
872
- if (existing) {
873
- return Promise.resolve(
874
- existing.siteId === input.siteId ? existing : { refused: "id-taken" }
875
- );
876
- }
877
- const job = {
878
- id: input.id,
879
- siteId: input.siteId,
880
- stub: input.stub,
881
- state: "queued",
882
- refusedBy: []
883
- };
884
- this.#jobs.set(job.id, job);
885
- return Promise.resolve(job);
886
- }
887
- job(jobId) {
888
- return Promise.resolve(this.#jobs.get(jobId));
889
- }
890
- jobs() {
891
- return Promise.resolve([...this.#jobs.values()]);
892
- }
893
- /** Jobs a site must seal for, right now. */
894
- async awaiting(siteId) {
895
- return (await this.jobs()).filter(
896
- (j) => j.siteId === siteId && j.state === "awaiting-payload"
897
- );
898
- }
899
- /** Sealed results waiting to go home. */
900
- async finished(siteId) {
901
- return (await this.jobs()).filter(
902
- (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
903
- );
904
- }
905
- /**
906
- * Claim work — one operation, because it has to be.
907
- *
908
- * Moved here wholesale from `DaemonPlane`, where it was a scan followed by
909
- * per-job mutation. Nothing about the *decision* changed; what changed is
910
- * that a store can now implement it, because the filter and the write are
911
- * one call rather than a loop the caller drives.
912
- *
913
- * The order of the guards is worth preserving as-is when this becomes a Lua
914
- * script: cheapest first, and `owners` last because it is the only one that
915
- * needed the projection.
916
- */
917
- async claim(input) {
918
- const now = await this.now();
919
- await this.sweep();
920
- const granted = [];
921
- for (const job of this.#jobs.values()) {
922
- if (granted.length >= input.max) break;
923
- if (job.state !== "queued") continue;
924
- if (job.siteId !== input.siteId) continue;
925
- if (!input.kinds.has(job.stub.kind)) continue;
926
- if (job.refusedBy.includes(input.runnerId)) continue;
927
- if (job.cancelled) continue;
928
- if (!input.owners.has(job.stub.owner)) continue;
929
- if (job.stub.audience === "self" && job.stub.owner !== input.owner) {
930
- continue;
931
- }
932
- const leaseId = randomUUID();
933
- job.state = "awaiting-payload";
934
- job.claimedBy = {
935
- runnerId: input.runnerId,
936
- owner: input.owner,
937
- device: input.device,
938
- leaseId,
939
- leaseExpiresAt: now + input.leaseMs
940
- };
941
- job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
942
- granted.push({
943
- ...job.stub,
944
- lease: {
945
- id: leaseId,
946
- runnerId: input.runnerId,
947
- expiresAt: job.claimedBy.leaseExpiresAt
948
- }
949
- });
950
- }
951
- return granted;
952
- }
953
- /**
954
- * Hand over the sealed payload to the device that holds the lease.
955
- *
956
- * The read and the state transition are one operation for the same reason
957
- * `claim` is: `running` must be set by whoever was told the envelope, or two
958
- * replicas can both hand out the same work and both believe they were first.
959
- */
960
- takePayload(input) {
961
- const job = this.#jobs.get(input.jobId);
962
- if (!job) return Promise.resolve({ refused: "not-found" });
963
- if (job.claimedBy?.runnerId !== input.runnerId) {
964
- return Promise.resolve({ refused: "not-holder" });
965
- }
966
- if (job.claimedBy.leaseId !== input.leaseId) {
967
- return Promise.resolve({ refused: "stale-lease" });
968
- }
969
- if (!job.payload) return Promise.resolve({ refused: "not-ready" });
970
- job.state = "running";
971
- return Promise.resolve({ envelope: job.payload });
972
- }
973
- /**
974
- * Record a finished job.
975
- *
976
- * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
977
- * result must be a no-op decided by the same operation that would have
978
- * written it, or two replicas can both decide they were the first.
979
- */
980
- complete(input) {
981
- const job = this.#jobs.get(input.jobId);
982
- if (!job) return Promise.resolve({ refused: "not-found" });
983
- if (job.claimedBy?.runnerId !== input.runnerId) {
984
- return Promise.resolve({ refused: "not-holder" });
985
- }
986
- if (job.state === "done") {
987
- const sameGrant = job.claimedBy.leaseId === input.leaseId;
988
- return Promise.resolve(
989
- sameGrant ? { accepted: false, duplicate: true, state: job.state } : { refused: "stale-lease" }
990
- );
991
- }
992
- if (job.claimedBy.leaseId !== input.leaseId) {
993
- return Promise.resolve({ refused: "stale-lease" });
994
- }
995
- job.result = input.envelope;
996
- job.disposition = input.disposition;
997
- job.state = "done";
998
- return Promise.resolve({ accepted: true, state: job.state });
999
- }
1000
- /** Give back leases this runner holds, naming each grant it means. */
1001
- releaseLeases(input) {
1002
- const released = [];
1003
- for (const { jobId, leaseId } of input.leases) {
1004
- const job = this.#jobs.get(jobId);
1005
- if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;
1006
- if (job.claimedBy.leaseId !== leaseId) continue;
1007
- if (input.reason === "refused" && !job.refusedBy.includes(input.runnerId)) {
1008
- job.refusedBy.push(input.runnerId);
1009
- }
1010
- this.#requeue(job);
1011
- released.push(jobId);
1012
- }
1013
- return Promise.resolve(released);
1014
- }
1015
- /**
1016
- * Take a site's sealed payload for a claimed job.
1017
- *
1018
- * Refuses anything not `awaiting-payload`, which is what makes the timeout
1019
- * mean something: a late seal must not land on a claim that has moved.
1020
- */
1021
- seal(input) {
1022
- const job = this.#jobs.get(input.jobId);
1023
- if (job?.siteId !== input.siteId) {
1024
- return Promise.resolve({ refused: "not-found" });
1025
- }
1026
- if (job.state !== "awaiting-payload") {
1027
- return Promise.resolve({ refused: "too-late", was: job.state });
1028
- }
1029
- job.payload = input.envelope;
1030
- job.state = "ready";
1031
- delete job.awaitingUntil;
1032
- return Promise.resolve({ state: job.state });
1033
- }
1034
- /** {@link RoutingStore.cancel} — the site withdraws a job. */
1035
- cancel(input) {
1036
- const job = this.#jobs.get(input.jobId);
1037
- if (job?.siteId !== input.siteId) return Promise.resolve(false);
1038
- job.cancelled = true;
1039
- return Promise.resolve(true);
1040
- }
1041
- /** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */
1042
- cancelRequests(runnerId) {
1043
- return Promise.resolve(
1044
- [...this.#jobs.values()].filter(
1045
- (job) => job.cancelled === true && job.claimedBy?.runnerId === runnerId
1046
- ).map((job) => job.id)
1047
- );
1048
- }
1049
- /** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */
1050
- async renewLeases(input) {
1051
- const now = await this.now();
1052
- const renewed = [];
1053
- const lost = [];
1054
- for (const { jobId, leaseId } of input.leases) {
1055
- const job = this.#jobs.get(jobId);
1056
- const held = job?.claimedBy;
1057
- if (!job || held?.leaseId !== leaseId || held.runnerId !== input.runnerId) {
1058
- lost.push(jobId);
1059
- continue;
1060
- }
1061
- const expiresAt = now + input.leaseMs;
1062
- job.claimedBy = { ...held, leaseExpiresAt: expiresAt };
1063
- renewed.push({ jobId, expiresAt });
1064
- }
1065
- return { renewed, lost };
1066
- }
1067
- async seen(presence) {
1068
- const lastSeenAt = await this.now();
1069
- const existing = this.#presence.get(presence.runnerId);
1070
- if (existing) {
1071
- existing.lastSeenAt = lastSeenAt;
1072
- return existing;
1073
- }
1074
- const fresh = { ...presence, lastSeenAt };
1075
- this.#presence.set(presence.runnerId, fresh);
1076
- return fresh;
1077
- }
1078
- presence(runnerId) {
1079
- return Promise.resolve(this.#presence.get(runnerId));
1080
- }
1081
- everyone() {
1082
- return Promise.resolve([...this.#presence.values()]);
1083
- }
1084
- /**
1085
- * Return a job to the queue, forgetting the claim.
1086
- *
1087
- * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
1088
- * why the awaiting-payload timeout is cheap to fire: the worst case is that
1089
- * a device did nothing for ten seconds and another one gets a turn.
1090
- */
1091
- #requeue(job) {
1092
- job.state = "queued";
1093
- delete job.claimedBy;
1094
- delete job.awaitingUntil;
1095
- delete job.payload;
1096
- }
1097
- /**
1098
- * Fire whatever the clock says is due, and report it.
1099
- *
1100
- * Returns the jobs it requeued so a caller can log or surface them — a
1101
- * timeout that fires invisibly is indistinguishable from a job that was
1102
- * never claimed, and those want very different debugging.
1103
- */
1104
- async sweep() {
1105
- const now = await this.now();
1106
- const requeued = [];
1107
- const expired = [];
1108
- for (const job of this.#jobs.values()) {
1109
- if (job.stub.deadlineAt <= now) {
1110
- this.#jobs.delete(job.id);
1111
- expired.push(job);
1112
- continue;
1113
- }
1114
- if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
1115
- this.#requeue(job);
1116
- requeued.push(job);
1117
- }
1118
- const lease = job.claimedBy;
1119
- if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
1120
- this.#requeue(job);
1121
- requeued.push(job);
1122
- }
1123
- }
1124
- return [...requeued, ...expired];
1125
- }
1126
- };
1127
-
1128
885
  // src/index.ts
1129
886
  var Relay = class {
1130
887
  state;
@@ -1133,25 +890,23 @@ var Relay = class {
1133
890
  #site;
1134
891
  #now;
1135
892
  #basePath;
1136
- #siteId;
893
+ #debug;
1137
894
  constructor(options) {
1138
895
  this.state = options.store ?? new RelayState({ now: options.now ?? Date.now });
1139
896
  this.projection = new Projection(options.fixture);
1140
897
  this.#now = options.now ?? Date.now;
1141
898
  this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
1142
- this.#siteId = options.siteId;
899
+ this.#debug = options.debug ?? false;
1143
900
  this.#daemon = new DaemonPlane({
1144
901
  state: this.state,
1145
902
  projection: this.projection,
1146
903
  now: this.#now,
1147
- leaseMs: options.leaseMs ?? 6e4,
1148
- siteId: options.siteId
904
+ leaseMs: options.leaseMs ?? 6e4
1149
905
  });
1150
906
  this.#site = new SitePlane({
1151
907
  state: this.state,
1152
908
  projection: this.projection,
1153
- now: this.#now,
1154
- routesFor: options.siteId
909
+ now: this.#now
1155
910
  });
1156
911
  }
1157
912
  /** Replace the projection — a control-plane push, or a fixture edit. */
@@ -1176,10 +931,23 @@ var Relay = class {
1176
931
  const url = new URL(request.url);
1177
932
  const path = url.pathname;
1178
933
  if (path === "/debug" || path === `${this.#basePath}/debug`) {
934
+ const siteId = url.searchParams.get("site");
935
+ if (!this.#debug) {
936
+ return new Response(JSON.stringify({ error: "not-found" }), {
937
+ status: 404,
938
+ headers: { "content-type": "application/json" }
939
+ });
940
+ }
941
+ if (siteId === null || this.projection.siteFor(siteId) === null) {
942
+ return new Response(JSON.stringify({ error: "bad-request" }), {
943
+ status: 400,
944
+ headers: { "content-type": "application/json" }
945
+ });
946
+ }
1179
947
  return new Response(
1180
948
  await debugPage(this.state, this.#now(), {
1181
- siteId: this.#siteId,
1182
- consents: (owner) => this.projection.consentFor(owner, this.#siteId) !== null
949
+ siteId,
950
+ consents: (owner) => this.projection.consentFor(owner, siteId) !== null
1183
951
  }),
1184
952
  { headers: { "content-type": "text/html; charset=utf-8" } }
1185
953
  );
@@ -1275,6 +1043,7 @@ export {
1275
1043
  RevocationRecord,
1276
1044
  RosterRecord,
1277
1045
  SiteRecord,
1278
- debugPage
1046
+ debugPage,
1047
+ routeKey
1279
1048
  };
1280
1049
  //# sourceMappingURL=index.js.map