@tokenoftrust/storefront-runner 4.12.0 → 4.12.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.
@@ -98,6 +98,7 @@ export async function authorizeInternalContentRequest(
98
98
  try {
99
99
  response = await (input.fetchImpl ?? fetch)(`${coreUrl}/api/broker/appDomains`, {
100
100
  method: "GET",
101
+ redirect: "manual",
101
102
  headers: {
102
103
  accept: "application/json",
103
104
  "X-ToT-Broker-Assertion": assertion,
@@ -298,6 +298,7 @@ export class HttpTotAccessClient implements TotAccessClient {
298
298
  } catch {
299
299
  return { ok: false, reason: "error" };
300
300
  }
301
+ if (res.status >= 300 && res.status < 400) return { ok: false, reason: "error" };
301
302
  let body: unknown;
302
303
  try {
303
304
  body = await res.json();
@@ -329,6 +330,7 @@ export class HttpTotAccessClient implements TotAccessClient {
329
330
  }
330
331
  return { ok: false, reason: "error" };
331
332
  }
333
+ if (res.status >= 300 && res.status < 400) return { ok: false, reason: "error" };
332
334
  let body: unknown = "(unread)";
333
335
  try {
334
336
  body = await res.json();
@@ -98,13 +98,14 @@ export interface HttpTransport {
98
98
  /** Default transport: JSON `fetch` GET/POST. */
99
99
  export class FetchHttpTransport implements HttpTransport {
100
100
  async get(url: string, headers: Record<string, string>) {
101
- return fetch(url, { method: "GET", headers: { accept: "application/json", ...headers } });
101
+ return fetch(url, { method: "GET", headers: { accept: "application/json", ...headers }, redirect: "manual" });
102
102
  }
103
103
  async post(url: string, headers: Record<string, string>, body: unknown) {
104
104
  return fetch(url, {
105
105
  method: "POST",
106
106
  headers: { "content-type": "application/json", accept: "application/json", ...headers },
107
107
  body: JSON.stringify(body),
108
+ redirect: "manual",
108
109
  });
109
110
  }
110
111
  }
@@ -191,6 +192,9 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
191
192
  message: "staffVendorAccess: transport failure searching vendors",
192
193
  };
193
194
  }
195
+ if (res.status >= 300 && res.status < 400) {
196
+ return { ok: false, status: res.status, reason: "error", message: "staffVendorAccess: redirects are not accepted" };
197
+ }
194
198
  let parsed: unknown;
195
199
  try {
196
200
  parsed = await res.json();
@@ -229,6 +233,9 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
229
233
  message: "staffVendorAccess: transport failure granting access",
230
234
  };
231
235
  }
236
+ if (res.status >= 300 && res.status < 400) {
237
+ return { ok: false, status: res.status, reason: "error", message: "staffVendorAccess: redirects are not accepted" };
238
+ }
232
239
  let parsed: unknown;
233
240
  try {
234
241
  parsed = await res.json();
@@ -161,10 +161,12 @@ export class FetchExplainTransport implements ExplainTransport {
161
161
  try {
162
162
  const res = await fetch(url, {
163
163
  method: "POST",
164
+ redirect: "manual",
164
165
  headers: { "content-type": "application/json", ...headers },
165
166
  body: JSON.stringify(body),
166
167
  signal: ctrl.signal,
167
168
  });
169
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
168
170
  return { status: res.status, json: () => res.json() };
169
171
  } finally {
170
172
  clearTimeout(timer);
@@ -318,8 +318,9 @@ export interface HttpTransport {
318
318
  /** Default transport: JSON `fetch` POST/GET/DELETE. */
319
319
  export class FetchHttpTransport implements HttpTransport {
320
320
  async post(url: string, headers: Record<string, string>, body: unknown) {
321
- return fetch(url, {
321
+ const res = await fetch(url, {
322
322
  method: "POST",
323
+ redirect: "manual",
323
324
  headers: {
324
325
  "content-type": "application/json",
325
326
  accept: "application/json",
@@ -327,18 +328,26 @@ export class FetchHttpTransport implements HttpTransport {
327
328
  },
328
329
  body: JSON.stringify(body),
329
330
  });
331
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
332
+ return res;
330
333
  }
331
334
  async get(url: string, headers: Record<string, string>) {
332
- return fetch(url, {
335
+ const res = await fetch(url, {
333
336
  method: "GET",
337
+ redirect: "manual",
334
338
  headers: { accept: "application/json", ...headers },
335
339
  });
340
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
341
+ return res;
336
342
  }
337
343
  async delete(url: string, headers: Record<string, string>) {
338
- return fetch(url, {
344
+ const res = await fetch(url, {
339
345
  method: "DELETE",
346
+ redirect: "manual",
340
347
  headers: { accept: "application/json", ...headers },
341
348
  });
349
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
350
+ return res;
342
351
  }
343
352
  }
344
353
 
@@ -41,7 +41,8 @@ export class FetchPreflightTransport implements PreflightTransport {
41
41
  const ctrl = new AbortController();
42
42
  const timer = setTimeout(() => ctrl.abort(), PREFLIGHT_TIMEOUT_MS);
43
43
  try {
44
- const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal });
44
+ const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal, redirect: "manual" });
45
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
45
46
  return { status: res.status, json: () => res.json() };
46
47
  } finally {
47
48
  clearTimeout(timer);
@@ -287,17 +287,24 @@ export interface HttpTransport {
287
287
 
288
288
  export class FetchHttpTransport implements HttpTransport {
289
289
  async post(url: string, headers: Record<string, string>, body: unknown) {
290
- return fetch(url, {
290
+ const res = await fetch(url, {
291
291
  method: "POST",
292
+ redirect: "manual",
292
293
  headers: { "content-type": "application/json", accept: "application/json", ...headers },
293
294
  body: JSON.stringify(body),
294
295
  });
296
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
297
+ return res;
295
298
  }
296
299
  async get(url: string, headers: Record<string, string>) {
297
- return fetch(url, { method: "GET", headers: { accept: "application/json", ...headers } });
300
+ const res = await fetch(url, { method: "GET", headers: { accept: "application/json", ...headers }, redirect: "manual" });
301
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
302
+ return res;
298
303
  }
299
304
  async delete(url: string, headers: Record<string, string>) {
300
- return fetch(url, { method: "DELETE", headers: { accept: "application/json", ...headers } });
305
+ const res = await fetch(url, { method: "DELETE", headers: { accept: "application/json", ...headers }, redirect: "manual" });
306
+ if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
307
+ return res;
301
308
  }
302
309
  }
303
310
 
@@ -39,10 +39,19 @@ export interface MatrixFault {
39
39
  mode: MatrixMode;
40
40
  /** `effect` mode: the effect after which the object is evicted. */
41
41
  effect?: string | null;
42
+ /** Test-only constructor delay after this eviction, to reproduce a late alarm. */
43
+ resumeDelayMs?: number;
42
44
  }
43
45
 
44
46
  /** What the object looked like at the instant a matrix fault evicted it. */
45
47
  export interface MatrixKill {
48
+ instanceId: string;
49
+ execution: number;
50
+ at: number;
51
+ stepStartSeq: number;
52
+ transitionSeq: number;
53
+ effectSeq: number;
54
+ resumeNotBefore: number;
46
55
  jobId: string;
47
56
  kind: string;
48
57
  step: string;
@@ -63,6 +72,10 @@ export interface MatrixKill {
63
72
  }
64
73
 
65
74
  export interface MatrixEffect {
75
+ seq: number;
76
+ at: number;
77
+ instanceId: string;
78
+ execution: number;
66
79
  jobId: string;
67
80
  kind: string;
68
81
  step: string;
@@ -102,6 +115,7 @@ export function previewServicesWithEffects(services: PreviewJobServices, landed:
102
115
  }
103
116
 
104
117
  interface Running {
118
+ execution: number;
105
119
  jobId: string;
106
120
  kind: string;
107
121
  step: string;
@@ -110,9 +124,9 @@ interface Running {
110
124
  function tables(state: DurableObjectState): void {
111
125
  const { sql } = state.storage;
112
126
  sql.exec(
113
- `CREATE TABLE IF NOT EXISTS matrix_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, step TEXT, mode TEXT, effect TEXT, remaining INTEGER)`,
127
+ `CREATE TABLE IF NOT EXISTS matrix_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, step TEXT, mode TEXT, effect TEXT, remaining INTEGER, resume_delay INTEGER)`,
114
128
  );
115
- sql.exec(`CREATE TABLE IF NOT EXISTS matrix_effects (seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, kind TEXT, step TEXT, effect TEXT)`);
129
+ sql.exec(`CREATE TABLE IF NOT EXISTS matrix_effects (seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, kind TEXT, step TEXT, effect TEXT, at INTEGER, instance_id TEXT, execution INTEGER)`);
116
130
  sql.exec(`CREATE TABLE IF NOT EXISTS matrix_execs (job_id TEXT, step TEXT, n INTEGER, PRIMARY KEY (job_id, step))`);
117
131
  sql.exec(`CREATE TABLE IF NOT EXISTS matrix_kills (seq INTEGER PRIMARY KEY AUTOINCREMENT, kill TEXT)`);
118
132
  sql.exec(`CREATE TABLE IF NOT EXISTS matrix_holds (job_id TEXT, step TEXT, released INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (job_id, step))`);
@@ -122,6 +136,7 @@ function tables(state: DurableObjectState): void {
122
136
  const HOLD_LIMIT_MS = 30_000;
123
137
 
124
138
  export class MatrixFaults {
139
+ private readonly instanceId = crypto.randomUUID();
125
140
  private running: Running | null = null;
126
141
 
127
142
  constructor(
@@ -144,7 +159,7 @@ export class MatrixFaults {
144
159
  ? {
145
160
  onTerminal: async (info: Parameters<NonNullable<JobKindDefinition<unknown>["onTerminal"]>>[0]) => {
146
161
  const previous = this.running;
147
- this.running = { jobId: info.jobId, kind: name, step: "on-terminal" };
162
+ this.running = { jobId: info.jobId, kind: name, step: "on-terminal", execution: 0 };
148
163
  try {
149
164
  await onTerminal(info);
150
165
  } finally {
@@ -171,7 +186,7 @@ export class MatrixFaults {
171
186
  step.name,
172
187
  )
173
188
  .one().n;
174
- this.running = { jobId: ctx.jobId, kind, step: step.name };
189
+ this.running = { jobId: ctx.jobId, kind, step: step.name, execution: n };
175
190
  try {
176
191
  if (n === 1) await this.maybeHold(kind, step.name, ctx);
177
192
  if (n === 1) await this.maybeKill("before", null);
@@ -190,22 +205,24 @@ export class MatrixFaults {
190
205
  readonly landed: Landed = async (effect) => {
191
206
  const running = this.running;
192
207
  this.state.storage.sql.exec(
193
- `INSERT INTO matrix_effects (job_id, kind, step, effect) VALUES (?, ?, ?, ?)`,
208
+ `INSERT INTO matrix_effects (job_id, kind, step, effect, at, instance_id, execution) VALUES (?, ?, ?, ?, ?, ?, ?)`,
194
209
  running?.jobId ?? "",
195
210
  running?.kind ?? "",
196
211
  running?.step ?? "",
197
212
  effect,
213
+ Date.now(), this.instanceId, running?.execution ?? 0,
198
214
  );
199
215
  if (running) await this.maybeKill("effect", effect);
200
216
  };
201
217
 
202
218
  arm(fault: MatrixFault): void {
203
219
  this.state.storage.sql.exec(
204
- `INSERT INTO matrix_faults (kind, step, mode, effect, remaining) VALUES (?, ?, ?, ?, 1)`,
220
+ `INSERT INTO matrix_faults (kind, step, mode, effect, remaining, resume_delay) VALUES (?, ?, ?, ?, 1, ?)`,
205
221
  fault.kind,
206
222
  fault.step,
207
223
  fault.mode,
208
224
  fault.effect ?? null,
225
+ Math.max(0, Math.min(5_000, fault.resumeDelayMs ?? 0)),
209
226
  );
210
227
  }
211
228
 
@@ -249,12 +266,12 @@ export class MatrixFaults {
249
266
  effects(jobId?: string): MatrixEffect[] {
250
267
  const { sql } = this.state.storage;
251
268
  const rows = jobId
252
- ? sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(
253
- `SELECT job_id, kind, step, effect FROM matrix_effects WHERE job_id = ? ORDER BY seq`,
269
+ ? sql.exec<{ seq: number; at: number; instance_id: string; execution: number; job_id: string; kind: string; step: string; effect: string }>(
270
+ `SELECT * FROM matrix_effects WHERE job_id = ? ORDER BY seq`,
254
271
  jobId,
255
272
  )
256
- : sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(`SELECT job_id, kind, step, effect FROM matrix_effects ORDER BY seq`);
257
- return rows.toArray().map((r) => ({ jobId: r.job_id, kind: r.kind, step: r.step, effect: r.effect }));
273
+ : sql.exec<{ seq: number; at: number; instance_id: string; execution: number; job_id: string; kind: string; step: string; effect: string }>(`SELECT * FROM matrix_effects ORDER BY seq`);
274
+ return rows.toArray().map((r) => ({ jobId: r.job_id, kind: r.kind, step: r.step, effect: r.effect, seq: r.seq, at: r.at, instanceId: r.instance_id, execution: r.execution }));
258
275
  }
259
276
 
260
277
  execs(jobId: string): { step: string; n: number }[] {
@@ -286,8 +303,8 @@ export class MatrixFaults {
286
303
  if (!running) return;
287
304
  const { sql } = this.state.storage;
288
305
  const fault = sql
289
- .exec<{ id: number }>(
290
- `SELECT id FROM matrix_faults WHERE kind = ? AND step = ? AND mode = ? AND IFNULL(effect, '') = ? AND remaining > 0 ORDER BY id LIMIT 1`,
306
+ .exec<{ id: number; resume_delay: number }>(
307
+ `SELECT id, resume_delay FROM matrix_faults WHERE kind = ? AND step = ? AND mode = ? AND IFNULL(effect, '') = ? AND remaining > 0 ORDER BY id LIMIT 1`,
291
308
  running.kind,
292
309
  running.step,
293
310
  mode,
@@ -297,6 +314,7 @@ export class MatrixFaults {
297
314
  if (!fault) return;
298
315
  sql.exec(`UPDATE matrix_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
299
316
  const kill = await this.snapshot(running, mode, effect);
317
+ kill.resumeNotBefore = fault.resume_delay ? Date.now() + fault.resume_delay : 0;
300
318
  sql.exec(`INSERT INTO matrix_kills (kill) VALUES (?)`, JSON.stringify(kill));
301
319
  // Everything written so far — the effect itself included — is durable, exactly as a real crash
302
320
  // after it would leave it.
@@ -327,6 +345,10 @@ export class MatrixFaults {
327
345
  }
328
346
  }
329
347
  return {
348
+ instanceId: this.instanceId, execution: running.execution, at: Date.now(), resumeNotBefore: 0,
349
+ stepStartSeq: sql.exec<{ seq: number }>(`SELECT MAX(seq) seq FROM transitions WHERE job_id = ? AND step = ? AND note LIKE 'step-start%'`, running.jobId, running.step).one().seq,
350
+ transitionSeq: sql.exec<{ seq: number }>(`SELECT MAX(seq) seq FROM transitions`).one().seq,
351
+ effectSeq: sql.exec<{ seq: number }>(`SELECT MAX(seq) seq FROM matrix_effects`).one().seq ?? 0,
330
352
  jobId: running.jobId,
331
353
  kind: running.kind,
332
354
  step: running.step,
@@ -80,6 +80,13 @@ function harnessSeedServices(
80
80
  const matrices = new WeakMap<DurableObjectState, { faults: MatrixFaults; registry: JobKindRegistry }>();
81
81
 
82
82
  export class TenantPipeline extends DeployedTenantPipeline {
83
+ constructor(ctx: DurableObjectState, env: unknown) {
84
+ super(ctx, env);
85
+ const resumeAt = matrices.get(ctx)?.faults.kills().at(-1)?.resumeNotBefore ?? 0;
86
+ if (resumeAt > Date.now()) void ctx.blockConcurrencyWhile(async () => {
87
+ await new Promise((resolve) => setTimeout(resolve, resumeAt - Date.now()));
88
+ });
89
+ }
83
90
  private commercePaused = false;
84
91
  private commerceResume: (() => void) | null = null;
85
92
  private commerceOutcome: string = "complete";
@@ -57,7 +57,7 @@ export const PUBLISH_HARNESS_TIMING: PublishTiming = {
57
57
  export const PUBLISH_HARNESS_TUNING: PublishKindTuning = {
58
58
  leaseMs: 3_000,
59
59
  retryBaseMs: 20,
60
- deadlineMs: { synchronous: 30_000, asynchronous: 30_000 },
60
+ deadlineMs: { synchronous: 60_000, asynchronous: 60_000 },
61
61
  hotfixExtraMs: 0,
62
62
  compensationRetryBaseMs: 300,
63
63
  };
@@ -172,6 +172,10 @@ export const SYNTHETIC_COOPERATIVE_HANG_KIND = "synthetic-cooperative-hang";
172
172
  export const LONG_STEP_CHUNKS = 12;
173
173
  export const LONG_STEP_CHUNK_MS = 100;
174
174
  export const LONG_STEP_MAX_EXECUTION_MS = 450;
175
+ export const SYNTHETIC_STEP_CLOCK_KIND = "synthetic-step-clock";
176
+ export const STEP_CLOCK_BASE_MS = 1_000;
177
+ export const STEP_CLOCK_CAPTURE_MS = 1_500;
178
+ export const STEP_CLOCK_LIMIT_MS = 4_000;
175
179
 
176
180
  function engineKinds(state: DurableObjectState): JobKindDefinition<{ mode?: string }>[] {
177
181
  const { sql } = state.storage;
@@ -180,6 +184,34 @@ function engineKinds(state: DurableObjectState): JobKindDefinition<{ mode?: stri
180
184
  const parse = (raw: unknown) => (raw && typeof raw === "object" ? (raw as { mode?: string }) : {});
181
185
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
182
186
  return [
187
+ {
188
+ ...base,
189
+ kind: SYNTHETIC_STEP_CLOCK_KIND,
190
+ parseInput: parse,
191
+ deadlineMs: STEP_CLOCK_BASE_MS,
192
+ leaseMs: 10_000,
193
+ maxAttemptsPerStep: 3,
194
+ steps: [
195
+ { name: "capture", deadlineMs: STEP_CLOCK_LIMIT_MS, run: async (ctx) => {
196
+ const deadline = sql.exec<{ deadline: number }>(`SELECT deadline FROM jobs WHERE job_id = ?`, ctx.jobId).one().deadline;
197
+ ctx.setProduces({ captureStartedAt: ctx.now(), captureDeadline: deadline });
198
+ if (ctx.input.mode === "evict" && !ctx.checkpoint) {
199
+ ctx.saveCheckpoint({ evicted: true });
200
+ await ctx.flush();
201
+ state.abort("synthetic eviction during bounded step clock");
202
+ }
203
+ if (ctx.input.mode === "hang") await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve(), { once: true }));
204
+ else await sleep(STEP_CLOCK_CAPTURE_MS);
205
+ return { kind: "done" as const };
206
+ } },
207
+ { name: "after", run: async (ctx) => {
208
+ const deadline = sql.exec<{ deadline: number }>(`SELECT deadline FROM jobs WHERE job_id = ?`, ctx.jobId).one().deadline;
209
+ ctx.setProduces({ afterStartedAt: ctx.now(), afterDeadline: deadline });
210
+ await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve(), { once: true }));
211
+ return { kind: "done" as const };
212
+ } },
213
+ ],
214
+ },
183
215
  {
184
216
  kind: SYNTHETIC_OPTIONAL_TAIL_KIND,
185
217
  parseInput: parse,
@@ -561,9 +561,15 @@ export function chainViolations(log: TransitionView[]): string[] {
561
561
  }
562
562
 
563
563
  /** Across every job of the tenant: between a step's start and its outcome, no other step starts. */
564
- export function overlapViolations(all: TransitionView[]): string[] {
564
+ export function overlapViolations(all: TransitionView[], kills: readonly MatrixKill[] = [], effects: readonly MatrixEffect[] = []): string[] {
565
565
  let open: TransitionView | null = null;
566
566
  for (const t of all) {
567
+ // A recorded abort ends only its exact execution; any later effect keeps the overlap visible.
568
+ if (open && kills.some((kill) => ["before", "after", "resume", "effect"].includes(kill.mode) && !!kill.instanceId && kill.execution > 0 &&
569
+ kill.jobId === open!.jobId && kill.step === open!.step && kill.stepStartSeq === open!.seq &&
570
+ kill.transitionSeq >= open!.seq && kill.transitionSeq < t.seq &&
571
+ !effects.some((effect) => effect.jobId === kill.jobId && effect.step === kill.step &&
572
+ effect.instanceId === kill.instanceId && effect.execution === kill.execution && effect.seq > kill.effectSeq))) open = null;
567
573
  if (t.note?.startsWith("step-start")) {
568
574
  if (open && open.jobId !== t.jobId) return [`step ${t.step} of ${t.jobId} started while ${open.step} of ${open.jobId} was running`];
569
575
  open = t;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.12.0",
3
+ "version": "4.12.2",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",