@questpie/sandbox 3.16.0 → 3.17.0

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.
@@ -45,13 +45,187 @@ export interface RunOutcome {
45
45
  timedOut?: boolean;
46
46
  }
47
47
 
48
+ const MAX_RESULT_DEPTH = 32;
49
+ const MAX_RESULT_NODES = 10_000;
50
+ const MAX_RESULT_JSON_BYTES = 1_048_576;
51
+ const MAX_RESULT_LOGS = 1_000;
52
+ const MAX_RESULT_LOG_BYTES = 4_000;
53
+ const MAX_TOTAL_LOG_BYTES = 1_048_576;
54
+
55
+ function exactKeys(
56
+ value: Record<string, unknown>,
57
+ required: readonly string[],
58
+ optional: readonly string[],
59
+ ): boolean {
60
+ const keys = Object.keys(value);
61
+ return (
62
+ required.every((key) => keys.includes(key)) &&
63
+ keys.every((key) => required.includes(key) || optional.includes(key))
64
+ );
65
+ }
66
+
67
+ function isBoundedJson(
68
+ value: unknown,
69
+ budget = { nodes: 0, bytes: 0 },
70
+ depth = 0,
71
+ maximumBytes = MAX_RESULT_JSON_BYTES,
72
+ ): boolean {
73
+ if (depth > MAX_RESULT_DEPTH) return false;
74
+ budget.nodes += 1;
75
+ if (budget.nodes > MAX_RESULT_NODES) return false;
76
+ if (value === null || typeof value === "boolean") {
77
+ budget.bytes += 5;
78
+ } else if (typeof value === "number") {
79
+ if (!Number.isFinite(value)) return false;
80
+ budget.bytes += 24;
81
+ } else if (typeof value === "string") {
82
+ budget.bytes += new TextEncoder().encode(value).byteLength;
83
+ } else if (Array.isArray(value)) {
84
+ budget.bytes += 2;
85
+ for (const item of value) {
86
+ if (!isBoundedJson(item, budget, depth + 1, maximumBytes)) return false;
87
+ }
88
+ } else if (typeof value === "object" && value !== null) {
89
+ budget.bytes += 2;
90
+ for (const [key, item] of Object.entries(value)) {
91
+ budget.bytes += new TextEncoder().encode(key).byteLength;
92
+ if (!isBoundedJson(item, budget, depth + 1, maximumBytes)) return false;
93
+ }
94
+ } else {
95
+ return false;
96
+ }
97
+ return budget.bytes <= maximumBytes;
98
+ }
99
+
100
+ export function parseSandboxRunResult(
101
+ value: unknown,
102
+ ): (RunOutcome & { ms?: number }) | null {
103
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
104
+ const result = value as Record<string, unknown>;
105
+ if (
106
+ !exactKeys(result, ["ok", "logs"], ["output", "error", "timedOut", "ms"]) ||
107
+ typeof result.ok !== "boolean" ||
108
+ !Array.isArray(result.logs) ||
109
+ result.logs.length > MAX_RESULT_LOGS ||
110
+ (result.error !== undefined && typeof result.error !== "string") ||
111
+ (result.timedOut !== undefined && typeof result.timedOut !== "boolean") ||
112
+ (result.ms !== undefined &&
113
+ (typeof result.ms !== "number" ||
114
+ !Number.isFinite(result.ms) ||
115
+ result.ms < 0))
116
+ ) {
117
+ return null;
118
+ }
119
+ if (
120
+ (result.ok === true &&
121
+ (Object.hasOwn(result, "error") || Object.hasOwn(result, "timedOut"))) ||
122
+ (result.ok === false &&
123
+ (typeof result.error !== "string" ||
124
+ Object.hasOwn(result, "output") ||
125
+ (Object.hasOwn(result, "timedOut") && result.timedOut !== true)))
126
+ ) {
127
+ return null;
128
+ }
129
+ let logBytes = 0;
130
+ for (const log of result.logs) {
131
+ if (typeof log !== "string") return null;
132
+ const bytes = new TextEncoder().encode(log).byteLength;
133
+ logBytes += bytes;
134
+ if (bytes > MAX_RESULT_LOG_BYTES || logBytes > MAX_TOTAL_LOG_BYTES) {
135
+ return null;
136
+ }
137
+ }
138
+ if (result.output !== undefined && !isBoundedJson(result.output)) return null;
139
+ return {
140
+ ok: result.ok,
141
+ ...(result.output !== undefined ? { output: result.output } : {}),
142
+ ...(typeof result.error === "string" ? { error: result.error } : {}),
143
+ logs: result.logs as string[],
144
+ ...(typeof result.timedOut === "boolean"
145
+ ? { timedOut: result.timedOut }
146
+ : {}),
147
+ ...(typeof result.ms === "number" ? { ms: result.ms } : {}),
148
+ };
149
+ }
150
+
151
+ export type BrokerRelayResult =
152
+ | { ok: true; value?: unknown }
153
+ | {
154
+ ok: false;
155
+ error: { code: string; message: string; correlationId?: string };
156
+ };
157
+
158
+ const BROKER_ERROR_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
159
+ bad_method: "invalid sandbox binding method",
160
+ bad_args: "invalid sandbox binding arguments",
161
+ unauthorized: "sandbox binding authorization failed",
162
+ forbidden: "sandbox binding operation forbidden",
163
+ not_implemented: "sandbox binding operation unavailable",
164
+ execution_error: "sandbox binding operation failed",
165
+ });
166
+
167
+ export function parseBrokerResponse(
168
+ value: unknown,
169
+ maxValueBytes = MAX_RESULT_JSON_BYTES,
170
+ ): BrokerRelayResult | null {
171
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
172
+ const response = value as Record<string, unknown>;
173
+ if (response.ok === true) {
174
+ if (!exactKeys(response, ["ok", "value"], [])) return null;
175
+ if (!isBoundedJson(response.value, undefined, 0, maxValueBytes))
176
+ return null;
177
+ return { ok: true, value: response.value };
178
+ }
179
+ if (
180
+ response.ok !== false ||
181
+ !exactKeys(response, ["ok", "error"], []) ||
182
+ !response.error ||
183
+ typeof response.error !== "object" ||
184
+ Array.isArray(response.error)
185
+ ) {
186
+ return null;
187
+ }
188
+ const error = response.error as Record<string, unknown>;
189
+ if (
190
+ !exactKeys(error, ["code", "message"], ["correlationId"]) ||
191
+ typeof error.code !== "string" ||
192
+ typeof error.message !== "string" ||
193
+ (error.correlationId !== undefined &&
194
+ (typeof error.correlationId !== "string" ||
195
+ !/^[\w-]{1,128}$/.test(error.correlationId)))
196
+ ) {
197
+ return null;
198
+ }
199
+ const code = Object.hasOwn(BROKER_ERROR_MESSAGES, error.code)
200
+ ? error.code
201
+ : "execution_error";
202
+ return {
203
+ ok: false,
204
+ error: {
205
+ code,
206
+ message: BROKER_ERROR_MESSAGES[code]!,
207
+ ...(typeof error.correlationId === "string"
208
+ ? { correlationId: error.correlationId }
209
+ : {}),
210
+ },
211
+ };
212
+ }
213
+
48
214
  /** Normalize a broker URL for comparison (drop a trailing slash; ignore case of origin). */
49
215
  export function normalizeBrokerUrl(raw: string): string | null {
50
216
  try {
51
217
  const u = new URL(raw);
218
+ if (
219
+ (u.protocol !== "http:" && u.protocol !== "https:") ||
220
+ u.username.length > 0 ||
221
+ u.password.length > 0 ||
222
+ u.hash.length > 0
223
+ ) {
224
+ return null;
225
+ }
52
226
  // Compare origin (lowercased by URL) + pathname without a trailing slash.
53
227
  const path = u.pathname.replace(/\/$/, "");
54
- return `${u.origin}${path}`;
228
+ return `${u.origin}${path}${u.search}`;
55
229
  } catch {
56
230
  return null;
57
231
  }
@@ -60,10 +234,16 @@ export function normalizeBrokerUrl(raw: string): string | null {
60
234
  /**
61
235
  * Reject a guest's bindings target URL unless it matches the supervisor's
62
236
  * configured expected broker URL. Returns an error string to surface, or
63
- * `null` when the URL is acceptable (or no expectation is configured).
237
+ * `null` only when the URL is acceptable. Bindings fail closed when the
238
+ * supervisor has no canonical broker target configured.
64
239
  */
65
- export function brokerUrlRejection(url: unknown, expected: string | undefined): string | null {
66
- if (!expected) return null; // not configured → skip (back-compat)
240
+ export function brokerUrlRejection(
241
+ url: unknown,
242
+ expected: string | undefined,
243
+ ): string | null {
244
+ if (!expected || normalizeBrokerUrl(expected) === null) {
245
+ return "sandbox broker URL is not configured";
246
+ }
67
247
  if (typeof url !== "string" || url.length === 0) {
68
248
  return "bindings.url is missing";
69
249
  }
@@ -76,7 +256,12 @@ export function brokerUrlRejection(url: unknown, expected: string | undefined):
76
256
  return null;
77
257
  }
78
258
 
79
- export function clampInt(v: unknown, def: number, min: number, max: number): number {
259
+ export function clampInt(
260
+ v: unknown,
261
+ def: number,
262
+ min: number,
263
+ max: number,
264
+ ): number {
80
265
  const n = Number(v);
81
266
  if (!Number.isFinite(n)) return def;
82
267
  return Math.min(Math.max(Math.round(n), min), max);
@@ -107,7 +292,10 @@ export function importFlags(hosts: string[]): string[] {
107
292
  }
108
293
 
109
294
  /** Pull the marked JSON result line out of the guest's stdout. */
110
- export function extractResult(stdout: string, marker: string): RunOutcome | null {
295
+ export function extractResult(
296
+ stdout: string,
297
+ marker: string,
298
+ ): RunOutcome | null {
111
299
  const idx = stdout.lastIndexOf(marker);
112
300
  if (idx === -1) return null;
113
301
  const after = stdout.slice(idx + marker.length);
@@ -115,12 +303,7 @@ export function extractResult(stdout: string, marker: string): RunOutcome | null
115
303
  const json = newline === -1 ? after : after.slice(0, newline);
116
304
  try {
117
305
  const parsed = JSON.parse(json);
118
- return {
119
- ok: !!parsed.ok,
120
- output: parsed.output,
121
- error: parsed.error,
122
- logs: Array.isArray(parsed.logs) ? parsed.logs : [],
123
- };
306
+ return parseSandboxRunResult(parsed);
124
307
  } catch {
125
308
  return null;
126
309
  }
package/src/types.ts CHANGED
@@ -46,6 +46,8 @@ export interface SandboxBindings {
46
46
 
47
47
  /** POST /run request body. */
48
48
  export interface SandboxRunRequest {
49
+ /** Explicit transport admission. The supervisor rejects omitted/unknown modes. */
50
+ mode: "workload" | "host";
49
51
  /** Guest TypeScript source. Must `export default` a `function(input)`. */
50
52
  source: string;
51
53
  /** Payload passed to the guest entry function. */
@@ -65,16 +67,35 @@ export interface SandboxRunRequest {
65
67
  }
66
68
 
67
69
  /** POST /run response body — also the `ExecutorAdapter.run` result shape. */
68
- export interface SandboxRunResult {
69
- ok: boolean;
70
- output?: unknown;
71
- logs: string[];
72
- error?: string;
73
- /** True when the run was killed by the wall-clock timeout. */
74
- timedOut?: boolean;
75
- /** Wall-clock duration in ms (server-measured). */
76
- ms?: number;
77
- }
70
+ export type SandboxRunResult =
71
+ | {
72
+ ok: true;
73
+ output?: unknown;
74
+ logs: string[];
75
+ error?: never;
76
+ timedOut?: never;
77
+ /** Wall-clock duration in ms (server-measured). */
78
+ ms?: number;
79
+ }
80
+ | {
81
+ ok: false;
82
+ error: string;
83
+ logs: string[];
84
+ output?: never;
85
+ timedOut?: never;
86
+ /** Wall-clock duration in ms (server-measured). */
87
+ ms?: number;
88
+ }
89
+ | {
90
+ ok: false;
91
+ error: string;
92
+ logs: string[];
93
+ output?: never;
94
+ /** True when the run was killed by the wall-clock timeout. */
95
+ timedOut: true;
96
+ /** Wall-clock duration in ms (server-measured). */
97
+ ms?: number;
98
+ };
78
99
 
79
100
  // ──────────────────────────────────────────────────────────────────────────
80
101
  // FRAMED stdio protocol (guest subprocess ⇄ supervisor) for the bindings path.
@@ -96,10 +117,17 @@ export const FRAME_MARKER = "__QP_SANDBOX_MSG__";
96
117
  */
97
118
  export const BINDINGS_TOKEN_HEADER = "x-questpie-sandbox-token";
98
119
 
120
+ /** Host-issued admission binding an authorized workload to one exact request. */
121
+ export const WORKLOAD_ADMISSION_HEADER = "x-questpie-workload-admission";
122
+
123
+ /** Trusted service identity for the structurally separate direct-host path. */
124
+ export const HOST_ADMISSION_HEADER = "x-questpie-sandbox-host-admission";
125
+
99
126
  /** Structured error returned by the broker (mirrors `questpie` `BindingError`). */
100
127
  export interface SandboxBindingError {
101
128
  code: string;
102
129
  message: string;
130
+ correlationId?: string;
103
131
  }
104
132
 
105
133
  /** Guest → supervisor: a binding RPC awaiting a brokered result. */
@@ -0,0 +1,289 @@
1
+ const ADMISSION_VERSION = "qpsw1";
2
+ const MINIMUM_SECRET_BYTES = 32;
3
+ const MAXIMUM_ADMISSION_TTL_MS = 5_000;
4
+
5
+ export interface SandboxWorkloadAdmissionClaims {
6
+ readonly kind: "sandbox_workload_admission";
7
+ readonly version: 1;
8
+ readonly admissionId: string;
9
+ readonly supervisorInstanceId: string;
10
+ readonly expiresAt: string;
11
+ readonly requestSha256: string;
12
+ }
13
+
14
+ export interface SandboxWorkloadAdmissionKey {
15
+ readonly keyId: string;
16
+ readonly secret: Uint8Array;
17
+ /** Unique per supervisor process; rotate on every restart. */
18
+ readonly instanceId: string;
19
+ }
20
+
21
+ export type SandboxWorkloadAdmissionDenialReason =
22
+ | "invalid"
23
+ | "wrong_instance"
24
+ | "expired"
25
+ | "body_mismatch";
26
+
27
+ export type SandboxWorkloadAdmissionVerification =
28
+ | {
29
+ readonly ok: true;
30
+ readonly claims: SandboxWorkloadAdmissionClaims;
31
+ }
32
+ | {
33
+ readonly ok: false;
34
+ readonly reason: SandboxWorkloadAdmissionDenialReason;
35
+ readonly claims?: SandboxWorkloadAdmissionClaims;
36
+ };
37
+
38
+ interface ReplayExpiry {
39
+ readonly admissionId: string;
40
+ readonly expiresAt: number;
41
+ }
42
+
43
+ export class SandboxWorkloadReplayCache {
44
+ private readonly consumed = new Map<string, number>();
45
+ private readonly expiries: ReplayExpiry[] = [];
46
+
47
+ constructor(private readonly maximumEntries: number) {
48
+ if (!Number.isInteger(maximumEntries) || maximumEntries < 1) {
49
+ throw new Error("Invalid sandbox workload replay cache size.");
50
+ }
51
+ }
52
+
53
+ consume(
54
+ admission: SandboxWorkloadAdmissionClaims,
55
+ now = Date.now(),
56
+ ): boolean {
57
+ this.deleteExpired(now);
58
+ if (this.consumed.has(admission.admissionId)) return false;
59
+ if (this.consumed.size >= this.maximumEntries) return false;
60
+ const expiresAt = Date.parse(admission.expiresAt);
61
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) return false;
62
+ this.consumed.set(admission.admissionId, expiresAt);
63
+ this.push({ admissionId: admission.admissionId, expiresAt });
64
+ return true;
65
+ }
66
+
67
+ private deleteExpired(now: number): void {
68
+ while (this.expiries[0] && this.expiries[0].expiresAt <= now) {
69
+ const expired = this.pop()!;
70
+ if (this.consumed.get(expired.admissionId) === expired.expiresAt) {
71
+ this.consumed.delete(expired.admissionId);
72
+ }
73
+ }
74
+ }
75
+
76
+ private push(value: ReplayExpiry): void {
77
+ let index = this.expiries.push(value) - 1;
78
+ while (index > 0) {
79
+ const parent = Math.floor((index - 1) / 2);
80
+ if (this.expiries[parent]!.expiresAt <= value.expiresAt) break;
81
+ this.expiries[index] = this.expiries[parent]!;
82
+ index = parent;
83
+ }
84
+ this.expiries[index] = value;
85
+ }
86
+
87
+ private pop(): ReplayExpiry | undefined {
88
+ const root = this.expiries[0];
89
+ const last = this.expiries.pop();
90
+ if (!root || !last || this.expiries.length === 0) return root;
91
+ let index = 0;
92
+ while (true) {
93
+ const left = index * 2 + 1;
94
+ if (left >= this.expiries.length) break;
95
+ const right = left + 1;
96
+ const child =
97
+ right < this.expiries.length &&
98
+ this.expiries[right]!.expiresAt < this.expiries[left]!.expiresAt
99
+ ? right
100
+ : left;
101
+ if (this.expiries[child]!.expiresAt >= last.expiresAt) break;
102
+ this.expiries[index] = this.expiries[child]!;
103
+ index = child;
104
+ }
105
+ this.expiries[index] = last;
106
+ return root;
107
+ }
108
+ }
109
+
110
+ const encoder = new TextEncoder();
111
+ const decoder = new TextDecoder();
112
+
113
+ function encodeBase64Url(value: Uint8Array): string {
114
+ let binary = "";
115
+ for (const byte of value) binary += String.fromCharCode(byte);
116
+ return btoa(binary)
117
+ .replaceAll("+", "-")
118
+ .replaceAll("/", "_")
119
+ .replace(/=+$/, "");
120
+ }
121
+
122
+ function decodeBase64Url(value: string): Uint8Array | null {
123
+ try {
124
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
125
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
126
+ const binary = atob(padded);
127
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function isIdentity(value: unknown): value is string {
134
+ return (
135
+ typeof value === "string" &&
136
+ value.length <= 128 &&
137
+ /^[A-Za-z0-9_-]+$/.test(value)
138
+ );
139
+ }
140
+
141
+ export function assertSandboxWorkloadAdmissionKey(
142
+ key: SandboxWorkloadAdmissionKey,
143
+ ): void {
144
+ if (
145
+ !isIdentity(key.keyId) ||
146
+ !isIdentity(key.instanceId) ||
147
+ key.secret.byteLength < MINIMUM_SECRET_BYTES
148
+ ) {
149
+ throw new Error("Invalid sandbox workload admission configuration.");
150
+ }
151
+ }
152
+
153
+ async function importHmacKey(
154
+ key: SandboxWorkloadAdmissionKey,
155
+ usage: "sign" | "verify",
156
+ ): Promise<CryptoKey> {
157
+ assertSandboxWorkloadAdmissionKey(key);
158
+ return crypto.subtle.importKey(
159
+ "raw",
160
+ Uint8Array.from(key.secret),
161
+ { name: "HMAC", hash: "SHA-256" },
162
+ false,
163
+ [usage],
164
+ );
165
+ }
166
+
167
+ async function sha256(value: string): Promise<string> {
168
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
169
+ return [...new Uint8Array(digest)]
170
+ .map((byte) => byte.toString(16).padStart(2, "0"))
171
+ .join("");
172
+ }
173
+
174
+ function exactKeys(value: object, keys: readonly string[]): boolean {
175
+ const actual = Object.keys(value).sort();
176
+ const expected = [...keys].sort();
177
+ return (
178
+ actual.length === expected.length &&
179
+ actual.every((key, index) => key === expected[index])
180
+ );
181
+ }
182
+
183
+ function parseClaims(value: unknown): SandboxWorkloadAdmissionClaims | null {
184
+ if (
185
+ typeof value !== "object" ||
186
+ value === null ||
187
+ Array.isArray(value) ||
188
+ !exactKeys(value, [
189
+ "kind",
190
+ "version",
191
+ "admissionId",
192
+ "supervisorInstanceId",
193
+ "expiresAt",
194
+ "requestSha256",
195
+ ])
196
+ ) {
197
+ return null;
198
+ }
199
+ const claims = value as Record<string, unknown>;
200
+ if (
201
+ claims.kind !== "sandbox_workload_admission" ||
202
+ claims.version !== 1 ||
203
+ !isIdentity(claims.admissionId) ||
204
+ !isIdentity(claims.supervisorInstanceId) ||
205
+ typeof claims.expiresAt !== "string" ||
206
+ !Number.isFinite(Date.parse(claims.expiresAt)) ||
207
+ typeof claims.requestSha256 !== "string" ||
208
+ !/^[a-f0-9]{64}$/.test(claims.requestSha256)
209
+ ) {
210
+ return null;
211
+ }
212
+ return claims as unknown as SandboxWorkloadAdmissionClaims;
213
+ }
214
+
215
+ export async function sealSandboxWorkloadAdmission(
216
+ key: SandboxWorkloadAdmissionKey,
217
+ claims: Omit<SandboxWorkloadAdmissionClaims, "requestSha256">,
218
+ requestBody: string,
219
+ ): Promise<string> {
220
+ const payload: SandboxWorkloadAdmissionClaims = {
221
+ ...claims,
222
+ requestSha256: await sha256(requestBody),
223
+ };
224
+ const encodedKeyId = encodeBase64Url(encoder.encode(key.keyId));
225
+ const encodedPayload = encodeBase64Url(
226
+ encoder.encode(JSON.stringify(payload)),
227
+ );
228
+ const signed = `${ADMISSION_VERSION}.${encodedKeyId}.${encodedPayload}`;
229
+ const cryptoKey = await importHmacKey(key, "sign");
230
+ const signature = await crypto.subtle.sign(
231
+ "HMAC",
232
+ cryptoKey,
233
+ encoder.encode(signed),
234
+ );
235
+ return `${signed}.${encodeBase64Url(new Uint8Array(signature))}`;
236
+ }
237
+
238
+ export async function verifySandboxWorkloadAdmission(
239
+ key: SandboxWorkloadAdmissionKey,
240
+ envelope: string,
241
+ requestBody: string,
242
+ now = new Date(),
243
+ ): Promise<SandboxWorkloadAdmissionVerification> {
244
+ const parts = envelope.split(".");
245
+ if (parts.length !== 4 || parts[0] !== ADMISSION_VERSION) {
246
+ return { ok: false, reason: "invalid" };
247
+ }
248
+ const encodedKeyId = encodeBase64Url(encoder.encode(key.keyId));
249
+ if (parts[1] !== encodedKeyId) return { ok: false, reason: "invalid" };
250
+ const signature = decodeBase64Url(parts[3]!);
251
+ if (!signature) return { ok: false, reason: "invalid" };
252
+ const signed = `${parts[0]}.${parts[1]}.${parts[2]}`;
253
+ const cryptoKey = await importHmacKey(key, "verify");
254
+ if (
255
+ !(await crypto.subtle.verify(
256
+ "HMAC",
257
+ cryptoKey,
258
+ Uint8Array.from(signature),
259
+ encoder.encode(signed),
260
+ ))
261
+ ) {
262
+ return { ok: false, reason: "invalid" };
263
+ }
264
+ const encodedPayload = decodeBase64Url(parts[2]!);
265
+ if (!encodedPayload) return { ok: false, reason: "invalid" };
266
+ let decoded: unknown;
267
+ try {
268
+ decoded = JSON.parse(decoder.decode(encodedPayload));
269
+ } catch {
270
+ return { ok: false, reason: "invalid" };
271
+ }
272
+ const claims = parseClaims(decoded);
273
+ if (!claims) return { ok: false, reason: "invalid" };
274
+ const frozenClaims = Object.freeze(claims);
275
+ if (claims.supervisorInstanceId !== key.instanceId) {
276
+ return { ok: false, reason: "wrong_instance", claims: frozenClaims };
277
+ }
278
+ const expiresAt = Date.parse(claims.expiresAt);
279
+ if (
280
+ expiresAt <= now.getTime() ||
281
+ expiresAt > now.getTime() + MAXIMUM_ADMISSION_TTL_MS
282
+ ) {
283
+ return { ok: false, reason: "expired", claims: frozenClaims };
284
+ }
285
+ if (claims.requestSha256 !== (await sha256(requestBody))) {
286
+ return { ok: false, reason: "body_mismatch", claims: frozenClaims };
287
+ }
288
+ return { ok: true, claims: frozenClaims };
289
+ }