sparta_worlds 0.6.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.
Files changed (93) hide show
  1. package/LICENSE.txt +15 -0
  2. package/bin/worlds.js +53 -0
  3. package/dist/cli/argv.js +27 -0
  4. package/dist/cli/claims.js +250 -0
  5. package/dist/cli/commands.js +67 -0
  6. package/dist/cli/contract.js +142 -0
  7. package/dist/cli/demo.js +114 -0
  8. package/dist/cli/doctor.js +299 -0
  9. package/dist/cli/expect.js +197 -0
  10. package/dist/cli/home.js +18 -0
  11. package/dist/cli/import.js +530 -0
  12. package/dist/cli/index.js +1014 -0
  13. package/dist/cli/init.js +725 -0
  14. package/dist/cli/judge-command.js +184 -0
  15. package/dist/cli/judge.js +1495 -0
  16. package/dist/cli/load.js +8 -0
  17. package/dist/cli/packs.js +82 -0
  18. package/dist/cli/passk.js +1256 -0
  19. package/dist/cli/policy-starters.js +83 -0
  20. package/dist/cli/preload.js +45 -0
  21. package/dist/cli/report.js +579 -0
  22. package/dist/cli/rubric.js +727 -0
  23. package/dist/cli/run.js +266 -0
  24. package/dist/cli/scoreboard.js +432 -0
  25. package/dist/cli/selftest.js +157 -0
  26. package/dist/cli/stdio.js +25 -0
  27. package/dist/cli/tasks.js +1246 -0
  28. package/dist/cli/try.js +156 -0
  29. package/dist/cli/version.js +17 -0
  30. package/package.json +38 -0
  31. package/packs/cancellations.yaml +201 -0
  32. package/packs/credits.yaml +274 -0
  33. package/packs/invoices.yaml +206 -0
  34. package/packs/plan-changes.yaml +170 -0
  35. package/packs/refunds.yaml +437 -0
  36. package/preload/node-hooks.mjs +28 -0
  37. package/preload/node.mjs +19 -0
  38. package/preload/python/sitecustomize.py +88 -0
  39. package/preload/wrap.mjs +81 -0
  40. package/rubrics/acme-prod-library.json +1427 -0
  41. package/rubrics/acme-prod-shift.json +499 -0
  42. package/server/dist/admin/auth.js +145 -0
  43. package/server/dist/admin/routes.js +469 -0
  44. package/server/dist/admin/schemas.js +243 -0
  45. package/server/dist/app.js +112 -0
  46. package/server/dist/core/canonical.js +22 -0
  47. package/server/dist/core/db.js +294 -0
  48. package/server/dist/core/errors.js +136 -0
  49. package/server/dist/core/manager.js +337 -0
  50. package/server/dist/core/money.js +43 -0
  51. package/server/dist/core/nondeterministic.js +39 -0
  52. package/server/dist/core/paths.js +33 -0
  53. package/server/dist/core/prng.js +19 -0
  54. package/server/dist/core/protocol.js +1 -0
  55. package/server/dist/core/publicUrl.js +49 -0
  56. package/server/dist/core/seed.js +286 -0
  57. package/server/dist/core/seedExport.js +169 -0
  58. package/server/dist/core/seedLoader.js +156 -0
  59. package/server/dist/core/time.js +45 -0
  60. package/server/dist/core/world.js +361 -0
  61. package/server/dist/index.js +17 -0
  62. package/server/dist/main.js +42 -0
  63. package/server/dist/scenario/engine.js +172 -0
  64. package/server/dist/scenario/library.js +49 -0
  65. package/server/dist/twin/build.js +372 -0
  66. package/server/dist/twin/pagination.js +32 -0
  67. package/server/dist/twin/params.js +118 -0
  68. package/server/dist/twin/resources/charges.js +24 -0
  69. package/server/dist/twin/resources/customers.js +151 -0
  70. package/server/dist/twin/resources/events.js +26 -0
  71. package/server/dist/twin/resources/invoices.js +217 -0
  72. package/server/dist/twin/resources/payment_intents.js +146 -0
  73. package/server/dist/twin/resources/products.js +92 -0
  74. package/server/dist/twin/resources/refunds.js +103 -0
  75. package/server/dist/twin/resources/shared.js +113 -0
  76. package/server/dist/twin/resources/subscriptions.js +325 -0
  77. package/server/dist/twin/resources/tickets.js +128 -0
  78. package/server/dist/twin/router.js +279 -0
  79. package/server/dist/twin/unsupported.js +212 -0
  80. package/server/dist/webhooks/dispatcher.js +148 -0
  81. package/server/scenarios/auth-expiry.yaml +7 -0
  82. package/server/scenarios/intermittent-500s.yaml +9 -0
  83. package/server/scenarios/lock-timeouts.yaml +12 -0
  84. package/server/scenarios/rate-limit-storm.yaml +10 -0
  85. package/server/scenarios/slow-stripe.yaml +9 -0
  86. package/server/scenarios/stripe-storm-shift.yaml +15 -0
  87. package/server/scenarios/tickets-storm.yaml +10 -0
  88. package/server/scenarios/webhook-chaos.yaml +7 -0
  89. package/server/seeds/acme-prod-library.json +40470 -0
  90. package/server/seeds/acme-prod-shift.json +36544 -0
  91. package/server/seeds/acme-prod.json +35412 -0
  92. package/server/seeds/saas-billing-small.json +4792 -0
  93. package/server/seeds/saas-billing-smoke.json +160 -0
@@ -0,0 +1,361 @@
1
+ import { canonicalHash, canonicalJson } from "./canonical.js";
2
+ import { deterministicToken, stripeId } from "./prng.js";
3
+ export const RESOURCE_NAMES = [
4
+ "customers",
5
+ "products",
6
+ "prices",
7
+ "invoices",
8
+ "invoiceitems",
9
+ "payment_intents",
10
+ "charges",
11
+ "refunds",
12
+ "subscriptions",
13
+ "customer_balance_transactions",
14
+ "tickets",
15
+ ];
16
+ export const TWIN_API_VERSION = "2024-06-20";
17
+ export class World {
18
+ id;
19
+ db;
20
+ generationCounter = 0;
21
+ closedFlag = false;
22
+ constructor(id, db) {
23
+ this.id = id;
24
+ this.db = db;
25
+ }
26
+ get generation() {
27
+ return this.generationCounter;
28
+ }
29
+ bumpGeneration() {
30
+ this.generationCounter++;
31
+ }
32
+ get closed() {
33
+ return this.closedFlag;
34
+ }
35
+ close() {
36
+ if (this.closedFlag)
37
+ return;
38
+ this.closedFlag = true;
39
+ this.bumpGeneration();
40
+ this.db.close();
41
+ }
42
+ getMeta(key) {
43
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
44
+ return row?.value;
45
+ }
46
+ setMeta(key, value) {
47
+ this.db
48
+ .prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
49
+ .run(key, value);
50
+ }
51
+ get seedHash() {
52
+ return this.getMeta("seed_hash") ?? "";
53
+ }
54
+ get seedName() {
55
+ return this.getMeta("seed_name") ?? "";
56
+ }
57
+ get seed() {
58
+ return JSON.parse(this.getMeta("seed_json") ?? "{}");
59
+ }
60
+ get clockMode() {
61
+ return this.getMeta("clock_mode") ?? "manual";
62
+ }
63
+ get autoTickSeconds() {
64
+ return Number(this.getMeta("auto_tick_seconds") ?? "1");
65
+ }
66
+ get clock() {
67
+ return Number(this.getMeta("clock") ?? "0");
68
+ }
69
+ setClock(seconds) {
70
+ this.setMeta("clock", String(seconds));
71
+ }
72
+ advanceClock(seconds) {
73
+ const next = this.clock + seconds;
74
+ this.setClock(next);
75
+ return next;
76
+ }
77
+ bumpCounter(metaKey) {
78
+ const next = Number(this.getMeta(metaKey) ?? "0") + 1;
79
+ this.setMeta(metaKey, String(next));
80
+ return next;
81
+ }
82
+ nextId(prefix) {
83
+ return stripeId(prefix, this.seedHash, this.bumpCounter("id_counter"));
84
+ }
85
+ nextToken(length = 16) {
86
+ return deterministicToken(this.seedHash, this.bumpCounter("id_counter"), length);
87
+ }
88
+ nextSeq() {
89
+ return this.bumpCounter("seq_counter");
90
+ }
91
+ get currentSeq() {
92
+ return Number(this.getMeta("seq_counter") ?? "0");
93
+ }
94
+ incrCounter(name) {
95
+ const row = this.db
96
+ .prepare("INSERT INTO counters(name, value) VALUES(?, 1) ON CONFLICT(name) DO UPDATE SET value = value + 1 RETURNING value")
97
+ .get(name);
98
+ return row.value;
99
+ }
100
+ getCounter(name) {
101
+ const row = this.db.prepare("SELECT value FROM counters WHERE name = ?").get(name);
102
+ return row?.value ?? 0;
103
+ }
104
+ insertObject(resource, data) {
105
+ const id = data.id;
106
+ if (typeof id !== "string" || id === "")
107
+ throw new Error(`insertObject: object has no id (resource ${resource})`);
108
+ const seq = this.nextSeq();
109
+ const created = typeof data.created === "number" ? data.created : this.clock;
110
+ this.db
111
+ .prepare("INSERT INTO objects(id, resource, data, created, created_seq, updated_seq, deleted_seq) VALUES(?, ?, ?, ?, ?, ?, NULL)")
112
+ .run(id, resource, JSON.stringify(data), created, seq, seq);
113
+ }
114
+ getObject(resource, id, opts = {}) {
115
+ const row = this.db.prepare("SELECT * FROM objects WHERE id = ? AND resource = ?").get(id, resource);
116
+ if (!row)
117
+ return undefined;
118
+ if (row.deleted_seq !== null && !opts.includeDeleted)
119
+ return undefined;
120
+ return { ...row, data: JSON.parse(row.data) };
121
+ }
122
+ archiveVersion(resource, id, replacedAtSeq) {
123
+ const row = this.db
124
+ .prepare("SELECT data, updated_seq FROM objects WHERE id = ? AND resource = ? AND deleted_seq IS NULL")
125
+ .get(id, resource);
126
+ if (!row)
127
+ return;
128
+ this.db
129
+ .prepare("INSERT INTO object_versions(id, resource, data, valid_from_seq, valid_to_seq) VALUES(?, ?, ?, ?, ?)")
130
+ .run(id, resource, row.data, row.updated_seq, replacedAtSeq);
131
+ }
132
+ objectAt(id, seq) {
133
+ const row = this.db
134
+ .prepare("SELECT data FROM object_versions WHERE id = ? AND valid_from_seq <= ? AND valid_to_seq > ? ORDER BY valid_to_seq ASC LIMIT 1")
135
+ .get(id, seq, seq);
136
+ return row ? JSON.parse(row.data) : undefined;
137
+ }
138
+ updateObject(resource, id, data) {
139
+ const seq = this.nextSeq();
140
+ this.archiveVersion(resource, id, seq);
141
+ const res = this.db
142
+ .prepare("UPDATE objects SET data = ?, updated_seq = ? WHERE id = ? AND resource = ? AND deleted_seq IS NULL")
143
+ .run(JSON.stringify(data), seq, id, resource);
144
+ if (res.changes === 0)
145
+ throw new Error(`updateObject: no live ${resource} with id ${id}`);
146
+ }
147
+ deleteObject(resource, id) {
148
+ const seq = this.nextSeq();
149
+ this.archiveVersion(resource, id, seq);
150
+ const res = this.db
151
+ .prepare("UPDATE objects SET deleted_seq = ?, updated_seq = ? WHERE id = ? AND resource = ? AND deleted_seq IS NULL")
152
+ .run(seq, seq, id, resource);
153
+ if (res.changes === 0)
154
+ throw new Error(`deleteObject: no live ${resource} with id ${id}`);
155
+ }
156
+ listObjects(resource) {
157
+ const rows = this.db
158
+ .prepare("SELECT * FROM objects WHERE resource = ? AND deleted_seq IS NULL ORDER BY created_seq DESC")
159
+ .all(resource);
160
+ return rows.map((r) => ({ ...r, data: JSON.parse(r.data) }));
161
+ }
162
+ emitEvent(type, object, opts = {}) {
163
+ const id = this.nextId("evt");
164
+ const seq = this.nextSeq();
165
+ const created = this.clock;
166
+ const data = {
167
+ id,
168
+ object: "event",
169
+ api_version: TWIN_API_VERSION,
170
+ created,
171
+ data: { object },
172
+ livemode: false,
173
+ request: { id: null, idempotency_key: opts.idempotencyKey ?? null },
174
+ type,
175
+ };
176
+ this.db
177
+ .prepare("INSERT INTO events(id, seq, type, created, data) VALUES(?, ?, ?, ?, ?)")
178
+ .run(id, seq, type, created, JSON.stringify(data));
179
+ const config = this.webhookConfig();
180
+ if (config) {
181
+ this.db
182
+ .prepare("INSERT INTO webhook_deliveries(seq, event_id, url, status, attempts, next_attempt_at) VALUES(?, ?, ?, 'pending', 0, ?)")
183
+ .run(seq, id, config.url, created);
184
+ }
185
+ return { id, seq, type, created, data };
186
+ }
187
+ listEvents(opts = {}) {
188
+ const rows = this.db
189
+ .prepare("SELECT * FROM events WHERE seq > ? ORDER BY seq ASC")
190
+ .all(opts.afterSeq ?? -1);
191
+ return rows.map((r) => ({ ...r, data: JSON.parse(r.data) }));
192
+ }
193
+ getEvent(id) {
194
+ const row = this.db.prepare("SELECT * FROM events WHERE id = ?").get(id);
195
+ return row ? { ...row, data: JSON.parse(row.data) } : undefined;
196
+ }
197
+ pendingWebhooksFor(eventId) {
198
+ const row = this.db
199
+ .prepare("SELECT COUNT(*) AS n FROM webhook_deliveries WHERE event_id = ? AND status = 'pending'")
200
+ .get(eventId);
201
+ return row.n;
202
+ }
203
+ logRequest(entry) {
204
+ this.db
205
+ .prepare("INSERT INTO request_log(method, path, status, injected, matched, unsupported, clock) VALUES(?, ?, ?, ?, ?, ?, ?)")
206
+ .run(entry.method, entry.path, entry.status, entry.injected ? 1 : 0, entry.matched === false ? 0 : 1, entry.unsupported ?? null, this.clock);
207
+ }
208
+ listRequests(opts = {}) {
209
+ const where = opts.unmatchedOnly ? "WHERE matched = 0 OR unsupported IS NOT NULL" : "";
210
+ return this.db.prepare(`SELECT * FROM request_log ${where} ORDER BY seq ASC`).all();
211
+ }
212
+ webhookConfig() {
213
+ const row = this.db
214
+ .prepare("SELECT url, secret, max_retries, auto_deliver, signature_time FROM webhook_config WHERE id = 1")
215
+ .get();
216
+ return row;
217
+ }
218
+ setWebhookConfig(url, maxRetries = 3, autoDeliver = true, signatureTime = "wall") {
219
+ const secret = `whsec_twin_${deterministicToken(this.seedHash, 0, 24)}`;
220
+ this.db
221
+ .prepare("INSERT INTO webhook_config(id, url, secret, max_retries, auto_deliver, signature_time) VALUES(1, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET url = excluded.url, max_retries = excluded.max_retries, auto_deliver = excluded.auto_deliver, signature_time = excluded.signature_time")
222
+ .run(url, secret, maxRetries, autoDeliver ? 1 : 0, signatureTime);
223
+ return this.webhookConfig();
224
+ }
225
+ clearWebhookConfig() {
226
+ this.db.prepare("DELETE FROM webhook_config WHERE id = 1").run();
227
+ }
228
+ listDeliveries() {
229
+ return this.db.prepare("SELECT * FROM webhook_deliveries ORDER BY seq ASC").all();
230
+ }
231
+ getIdempotency(key) {
232
+ return this.db.prepare("SELECT method, path, params_hash, status, body FROM idempotency WHERE key = ?").get(key);
233
+ }
234
+ putIdempotency(key, method, path, paramsHash, status, body) {
235
+ this.db
236
+ .prepare("INSERT OR IGNORE INTO idempotency(key, method, path, params_hash, status, body, created) VALUES(?, ?, ?, ?, ?, ?, ?)")
237
+ .run(key, method, path, paramsHash, status, body, this.clock);
238
+ }
239
+ addApiKey(key) {
240
+ this.db.prepare("INSERT OR IGNORE INTO api_keys(key) VALUES(?)").run(key);
241
+ }
242
+ checkApiKey(key) {
243
+ const row = this.db.prepare("SELECT expired FROM api_keys WHERE key = ?").get(key);
244
+ if (!row)
245
+ return "unknown";
246
+ return row.expired ? "expired" : "ok";
247
+ }
248
+ bumpApiKeyUse(key) {
249
+ const row = this.db
250
+ .prepare("UPDATE api_keys SET requests_used = requests_used + 1 WHERE key = ? RETURNING requests_used")
251
+ .get(key);
252
+ return row?.requests_used ?? 0;
253
+ }
254
+ expireApiKey(key) {
255
+ this.db.prepare("UPDATE api_keys SET expired = 1 WHERE key = ?").run(key);
256
+ }
257
+ listApiKeys() {
258
+ return this.db.prepare("SELECT key FROM api_keys ORDER BY key").all().map((r) => r.key);
259
+ }
260
+ addAdminToken(token) {
261
+ this.db.prepare("INSERT OR IGNORE INTO admin_tokens(token) VALUES(?)").run(token);
262
+ }
263
+ hasAdminToken(token) {
264
+ return this.db.prepare("SELECT 1 AS one FROM admin_tokens WHERE token = ?").get(token) !== undefined;
265
+ }
266
+ revokeAdminToken(token) {
267
+ return this.db.prepare("DELETE FROM admin_tokens WHERE token = ?").run(token).changes > 0;
268
+ }
269
+ countAdminTokens() {
270
+ return this.db.prepare("SELECT COUNT(*) AS n FROM admin_tokens").get().n;
271
+ }
272
+ setScenario(doc) {
273
+ if (doc === null) {
274
+ this.db.prepare("DELETE FROM scenario WHERE id = 1").run();
275
+ this.db.prepare("DELETE FROM counters WHERE name LIKE 'scenario:%'").run();
276
+ }
277
+ else {
278
+ this.db
279
+ .prepare("INSERT INTO scenario(id, doc) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc")
280
+ .run(JSON.stringify(doc));
281
+ this.db.prepare("DELETE FROM counters WHERE name LIKE 'scenario:%'").run();
282
+ }
283
+ }
284
+ getScenario() {
285
+ const row = this.db.prepare("SELECT doc FROM scenario WHERE id = 1").get();
286
+ return row ? JSON.parse(row.doc) : undefined;
287
+ }
288
+ setMark(name) {
289
+ const seq = this.currentSeq;
290
+ const clock = this.clock;
291
+ this.db
292
+ .prepare("INSERT INTO marks(name, seq, clock) VALUES(?, ?, ?) ON CONFLICT(name) DO UPDATE SET seq = excluded.seq, clock = excluded.clock")
293
+ .run(name, seq, clock);
294
+ return { name, seq, clock };
295
+ }
296
+ getMark(name) {
297
+ return this.db.prepare("SELECT name, seq, clock FROM marks WHERE name = ?").get(name);
298
+ }
299
+ listMarks() {
300
+ return this.db.prepare("SELECT name, seq, clock FROM marks ORDER BY seq ASC, name ASC").all();
301
+ }
302
+ diff(fromSeq) {
303
+ const created = {};
304
+ const updated = {};
305
+ const deleted = {};
306
+ const before = {};
307
+ const rows = this.db
308
+ .prepare("SELECT * FROM objects WHERE updated_seq > ? ORDER BY resource ASC, created_seq ASC")
309
+ .all(fromSeq);
310
+ for (const row of rows) {
311
+ const data = JSON.parse(row.data);
312
+ const existedAtMark = row.created_seq <= fromSeq;
313
+ const isDeleted = row.deleted_seq !== null && row.deleted_seq > fromSeq;
314
+ if (!existedAtMark && isDeleted)
315
+ continue;
316
+ const bucket = !existedAtMark ? created : isDeleted ? deleted : updated;
317
+ (bucket[row.resource] ??= []).push(data);
318
+ if (existedAtMark) {
319
+ const image = this.objectAt(row.id, fromSeq);
320
+ if (!image)
321
+ throw new Error(`diff: no version of ${row.id} covers seq ${fromSeq}`);
322
+ (before[row.resource] ??= {})[row.id] = image;
323
+ }
324
+ }
325
+ return { created, updated, deleted, before };
326
+ }
327
+ dump() {
328
+ const objects = this.db
329
+ .prepare("SELECT id, resource, data, created, created_seq, updated_seq, deleted_seq FROM objects ORDER BY created_seq ASC")
330
+ .all();
331
+ const events = this.db.prepare("SELECT id, seq, type, created, data FROM events ORDER BY seq ASC").all();
332
+ const idempotency = this.db
333
+ .prepare("SELECT key, method, path, params_hash, status, body, created FROM idempotency ORDER BY key ASC")
334
+ .all();
335
+ const counters = this.db.prepare("SELECT name, value FROM counters ORDER BY name ASC").all();
336
+ const marks = this.listMarks();
337
+ const deliveries = this.db
338
+ .prepare("SELECT seq, event_id, url, status, attempts, next_attempt_at, last_status_code FROM webhook_deliveries ORDER BY seq ASC")
339
+ .all();
340
+ const requests = this.db
341
+ .prepare("SELECT seq, method, path, status, injected, matched, unsupported, clock FROM request_log ORDER BY seq ASC")
342
+ .all();
343
+ return canonicalJson({
344
+ seed_hash: this.seedHash,
345
+ seed_name: this.seedName,
346
+ clock: this.clock,
347
+ id_counter: Number(this.getMeta("id_counter") ?? "0"),
348
+ seq_counter: this.currentSeq,
349
+ objects: objects.map((o) => ({ ...o, data: JSON.parse(o.data) })),
350
+ events: events.map((e) => ({ ...e, data: JSON.parse(e.data) })),
351
+ idempotency,
352
+ counters,
353
+ marks,
354
+ webhook_deliveries: deliveries,
355
+ request_log: requests,
356
+ });
357
+ }
358
+ dumpHash() {
359
+ return canonicalHash(this.dump());
360
+ }
361
+ }
@@ -0,0 +1,17 @@
1
+ export { authOptionsFromEnv, describeAuth, isLoopbackHost, OPERATOR_TOKEN_MIN_LENGTH, requiredScope, resolveAuthMode, } from "./admin/auth.js";
2
+ export { ADMIN_ENDPOINTS } from "./admin/schemas.js";
3
+ export { buildServer } from "./app.js";
4
+ export { canonicalHash, canonicalJson, sha256Hex } from "./core/canonical.js";
5
+ export { StripeError } from "./core/errors.js";
6
+ export { WorldManager, WorldNotFoundError, WorldRefusedError, WorldSchemaMismatchError, WorldUnreadableError, } from "./core/manager.js";
7
+ export { summarizeMoney } from "./core/money.js";
8
+ export { resolveBundledDir, resolveDataDir, resolveLibraryDirs, resolveUserDir, } from "./core/paths.js";
9
+ export { PROTOCOL_VERSION } from "./core/protocol.js";
10
+ export { normalizePublicUrl, resolvePublicUrl } from "./core/publicUrl.js";
11
+ export { createLayeredSeedLibrary, createSeedLibrary, parseSeed, SeedSchema, SeedValidationError, seedHash, } from "./core/seed.js";
12
+ export { exportSeed } from "./core/seedExport.js";
13
+ export { addInterval, parseDuration } from "./core/time.js";
14
+ export { RESOURCE_NAMES, TWIN_API_VERSION, World } from "./core/world.js";
15
+ export { parseScenario, ScenarioSchema } from "./scenario/engine.js";
16
+ export { createLayeredScenarioLibrary, createScenarioLibrary, } from "./scenario/library.js";
17
+ export { signPayload } from "./webhooks/dispatcher.js";
@@ -0,0 +1,42 @@
1
+ import { authOptionsFromEnv, describeAuth } from "./admin/auth.js";
2
+ import { buildServer } from "./app.js";
3
+ const port = Number(process.env.PORT ?? process.env.WORLDS_PORT ?? 4242);
4
+ const host = process.env.WORLDS_HOST ?? "127.0.0.1";
5
+ const publicUrl = process.env.WORLDS_PUBLIC_URL || undefined;
6
+ let server;
7
+ try {
8
+ server = buildServer({
9
+ dataDir: process.env.WORLDS_DATA_DIR,
10
+ seedsDir: process.env.WORLDS_SEEDS_DIR,
11
+ scenariosDir: process.env.WORLDS_SCENARIOS_DIR,
12
+ publicUrl,
13
+ trustProxy: process.env.WORLDS_TRUST_PROXY === "1",
14
+ auth: authOptionsFromEnv(),
15
+ logger: process.env.WORLDS_LOG === "1",
16
+ });
17
+ }
18
+ catch (err) {
19
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
20
+ process.exit(1);
21
+ }
22
+ server
23
+ .listen(port, host)
24
+ .then((address) => {
25
+ console.log(`worlds server listening at ${address}`);
26
+ console.log(` twin API ${address}/v1/* (Stripe-compatible; auth with a world API key)`);
27
+ console.log(` admin API ${address}/admin/*`);
28
+ console.log(` public URL ${server.publicUrl()}${publicUrl ? " (WORLDS_PUBLIC_URL)" : ""}`);
29
+ console.log(` ${describeAuth(server.auth())}`);
30
+ const refused = server.manager.refusedFiles().length;
31
+ if (refused > 0)
32
+ console.log(` ${refused} world file(s) this runtime refuses — run: worlds world ls`);
33
+ })
34
+ .catch((err) => {
35
+ console.error(err);
36
+ process.exit(1);
37
+ });
38
+ for (const signal of ["SIGINT", "SIGTERM"]) {
39
+ process.on(signal, () => {
40
+ void server.close().then(() => process.exit(0));
41
+ });
42
+ }
@@ -0,0 +1,172 @@
1
+ import { z } from "zod";
2
+ import { scenarioError } from "../core/errors.js";
3
+ export const RequestMatchSchema = z
4
+ .object({
5
+ method: z
6
+ .enum(["GET", "POST", "DELETE", "PUT", "PATCH"])
7
+ .optional()
8
+ .describe("HTTP method to match. Omitted = any method."),
9
+ path: z
10
+ .string()
11
+ .optional()
12
+ .describe('Path glob, e.g. "/v1/customers/*". "*" matches exactly one path segment, "**" the rest. Omitted = any path.'),
13
+ })
14
+ .strict();
15
+ export const RequestRuleSchema = z
16
+ .object({
17
+ match: RequestMatchSchema.describe("Which twin requests this rule applies to."),
18
+ after_requests: z
19
+ .number()
20
+ .int()
21
+ .min(0)
22
+ .default(0)
23
+ .describe("This many matched requests pass through untouched before the rule activates."),
24
+ duration_requests: z
25
+ .number()
26
+ .int()
27
+ .min(1)
28
+ .optional()
29
+ .describe("How many matched requests the rule applies to once active. Omitted = forever."),
30
+ every_nth: z
31
+ .number()
32
+ .int()
33
+ .min(2)
34
+ .optional()
35
+ .describe("Instead of a contiguous window: apply to every nth matched request (counted after after_requests)."),
36
+ respond: z
37
+ .object({
38
+ status: z.number().int().min(400).max(599).describe("HTTP status to inject."),
39
+ stripe_error: z
40
+ .string()
41
+ .default("api_error")
42
+ .describe("Stripe error kind: rate_limit (a 429 official SDKs surface), lock_timeout (a 429 with Stripe-Should-Retry: true, which official SDKs retry), api_error, invalid_api_key, api_key_expired."),
43
+ })
44
+ .strict()
45
+ .optional()
46
+ .describe("Inject this error response instead of executing the request. Injection happens BEFORE the handler: state is never half-mutated."),
47
+ latency_ms: z
48
+ .number()
49
+ .int()
50
+ .min(0)
51
+ .max(30_000)
52
+ .optional()
53
+ .describe("Delay the request by this many wall-clock milliseconds (the logical clock is unaffected)."),
54
+ })
55
+ .strict();
56
+ export const WebhookRuleSchema = z
57
+ .object({
58
+ webhooks: z
59
+ .object({
60
+ drop_every_nth: z
61
+ .number()
62
+ .int()
63
+ .min(2)
64
+ .optional()
65
+ .describe("Every nth delivery is silently dropped (never sent, no retries)."),
66
+ reorder: z
67
+ .enum(["swap_adjacent"])
68
+ .optional()
69
+ .describe("swap_adjacent: within each flush batch, deliveries are dispatched pairwise swapped."),
70
+ })
71
+ .strict(),
72
+ })
73
+ .strict();
74
+ export const AuthRuleSchema = z
75
+ .object({
76
+ auth: z
77
+ .object({
78
+ expire_after_requests: z
79
+ .number()
80
+ .int()
81
+ .min(0)
82
+ .describe("After this many authenticated twin requests, the API key acts expired (401 api_key_expired)."),
83
+ })
84
+ .strict(),
85
+ })
86
+ .strict();
87
+ export const ScenarioSchema = z
88
+ .object({
89
+ name: z
90
+ .string()
91
+ .regex(/^[a-z0-9][a-z0-9-]*$/)
92
+ .describe("Scenario name (kebab-case)."),
93
+ description: z.string().optional().describe("Human-readable summary, shown by `worlds scenario ls`."),
94
+ rules: z
95
+ .array(z.union([RequestRuleSchema, WebhookRuleSchema, AuthRuleSchema]))
96
+ .min(1)
97
+ .describe("Rules evaluated deterministically per request; counters live in world state."),
98
+ })
99
+ .strict();
100
+ export function parseScenario(doc) {
101
+ return ScenarioSchema.parse(doc);
102
+ }
103
+ export function matchPath(pattern, path) {
104
+ const pat = pattern.split("/").filter((s) => s !== "");
105
+ const segs = path
106
+ .split("?")[0]
107
+ .split("/")
108
+ .filter((s) => s !== "");
109
+ let i = 0;
110
+ for (; i < pat.length; i++) {
111
+ if (pat[i] === "**")
112
+ return true;
113
+ if (i >= segs.length)
114
+ return false;
115
+ if (pat[i] !== "*" && pat[i] !== segs[i])
116
+ return false;
117
+ }
118
+ return i === segs.length;
119
+ }
120
+ export function evaluateRequestRules(world, method, path) {
121
+ const doc = world.getScenario();
122
+ const outcome = { latencyMs: 0 };
123
+ if (!doc)
124
+ return outcome;
125
+ const scenario = doc;
126
+ scenario.rules.forEach((rule, idx) => {
127
+ if (!("match" in rule))
128
+ return;
129
+ const r = rule;
130
+ if (r.match.method && r.match.method !== method)
131
+ return;
132
+ if (r.match.path && !matchPath(r.match.path, path))
133
+ return;
134
+ const count = world.incrCounter(`scenario:rule:${idx}`);
135
+ const past = count - r.after_requests;
136
+ const active = past > 0 &&
137
+ (r.every_nth !== undefined
138
+ ? past % r.every_nth === 0
139
+ : r.duration_requests === undefined || past <= r.duration_requests);
140
+ if (!active)
141
+ return;
142
+ if (r.latency_ms)
143
+ outcome.latencyMs = Math.max(outcome.latencyMs, r.latency_ms);
144
+ if (r.respond && !outcome.inject) {
145
+ outcome.inject = scenarioError(r.respond.status, r.respond.stripe_error);
146
+ }
147
+ });
148
+ return outcome;
149
+ }
150
+ export function authExpiryThreshold(world) {
151
+ const doc = world.getScenario();
152
+ if (!doc)
153
+ return undefined;
154
+ const scenario = doc;
155
+ for (const rule of scenario.rules) {
156
+ if ("auth" in rule)
157
+ return rule.auth.expire_after_requests;
158
+ }
159
+ return undefined;
160
+ }
161
+ export function webhookRules(world) {
162
+ const doc = world.getScenario();
163
+ if (!doc)
164
+ return {};
165
+ const scenario = doc;
166
+ for (const rule of scenario.rules) {
167
+ if ("webhooks" in rule) {
168
+ return { dropEveryNth: rule.webhooks.drop_every_nth, reorder: rule.webhooks.reorder };
169
+ }
170
+ }
171
+ return {};
172
+ }
@@ -0,0 +1,49 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { parseScenario } from "./engine.js";
5
+ export function createScenarioLibrary(dir) {
6
+ function load() {
7
+ const out = new Map();
8
+ if (!existsSync(dir))
9
+ return out;
10
+ for (const file of readdirSync(dir)
11
+ .filter((f) => /\.(ya?ml|json)$/.test(f))
12
+ .sort()) {
13
+ try {
14
+ const raw = readFileSync(join(dir, file), "utf8");
15
+ const doc = file.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
16
+ const scenario = parseScenario(doc);
17
+ out.set(scenario.name, { name: scenario.name, file: join(dir, file), scenario });
18
+ }
19
+ catch {
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+ return {
25
+ list: () => [...load().values()],
26
+ get: (name) => load().get(name)?.scenario,
27
+ };
28
+ }
29
+ export function createLayeredScenarioLibrary(dirs) {
30
+ const layers = dirs.map((dir) => createScenarioLibrary(dir));
31
+ return {
32
+ dirs,
33
+ list() {
34
+ const merged = new Map();
35
+ for (const layer of layers)
36
+ for (const entry of layer.list())
37
+ merged.set(entry.name, entry);
38
+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
39
+ },
40
+ get(name) {
41
+ for (let i = layers.length - 1; i >= 0; i--) {
42
+ const hit = layers[i].get(name);
43
+ if (hit)
44
+ return hit;
45
+ }
46
+ return undefined;
47
+ },
48
+ };
49
+ }