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,279 @@
1
+ import { canonicalHash } from "../core/canonical.js";
2
+ import { apiError, authenticationError, idempotencyError, StripeError, toZendeskShape, UNSUPPORTED_PARAMETER_CODE, ZendeskError, } from "../core/errors.js";
3
+ import { sleep } from "../core/nondeterministic.js";
4
+ import { authExpiryThreshold, evaluateRequestRules } from "../scenario/engine.js";
5
+ import { parseFormBody } from "./params.js";
6
+ import { chargeRoutes } from "./resources/charges.js";
7
+ import { customerRoutes } from "./resources/customers.js";
8
+ import { eventRoutes } from "./resources/events.js";
9
+ import { invoiceRoutes } from "./resources/invoices.js";
10
+ import { paymentIntentRoutes } from "./resources/payment_intents.js";
11
+ import { productRoutes } from "./resources/products.js";
12
+ import { refundRoutes } from "./resources/refunds.js";
13
+ import { surfaceOf } from "./resources/shared.js";
14
+ import { processRenewals, RenewalFailed, subscriptionRoutes } from "./resources/subscriptions.js";
15
+ import { ticketRoutes } from "./resources/tickets.js";
16
+ import { refusedParameterError } from "./unsupported.js";
17
+ export const ALL_TWIN_ROUTES = [
18
+ ...customerRoutes,
19
+ ...productRoutes,
20
+ ...invoiceRoutes,
21
+ ...paymentIntentRoutes,
22
+ ...chargeRoutes,
23
+ ...refundRoutes,
24
+ ...subscriptionRoutes,
25
+ ...eventRoutes,
26
+ ...ticketRoutes,
27
+ ];
28
+ const COMPILED = ALL_TWIN_ROUTES.map((r) => ({
29
+ ...r,
30
+ segments: r.pattern.split("/").filter(Boolean),
31
+ }));
32
+ function matchRoute(method, path) {
33
+ if (path.length > 1 && path.endsWith("/"))
34
+ return undefined;
35
+ const segs = path.split("/").filter(Boolean);
36
+ for (const route of COMPILED) {
37
+ if (route.method !== method)
38
+ continue;
39
+ if (route.segments.length !== segs.length)
40
+ continue;
41
+ const pathParams = {};
42
+ let ok = true;
43
+ for (let i = 0; i < segs.length; i++) {
44
+ const pat = route.segments[i];
45
+ if (pat.startsWith(":"))
46
+ pathParams[pat.slice(1)] = decodeURIComponent(segs[i]);
47
+ else if (pat !== segs[i]) {
48
+ ok = false;
49
+ break;
50
+ }
51
+ }
52
+ if (ok)
53
+ return { route, pathParams };
54
+ }
55
+ return undefined;
56
+ }
57
+ function extractApiKey(req) {
58
+ const header = req.headers.authorization;
59
+ if (header) {
60
+ if (header.startsWith("Bearer "))
61
+ return header.slice(7).trim();
62
+ if (header.startsWith("Basic ")) {
63
+ try {
64
+ const decoded = Buffer.from(header.slice(6).trim(), "base64").toString("utf8");
65
+ return decoded.split(":")[0] || undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ function sendError(reply, err, surface = "stripe") {
75
+ const rendered = surface === "zendesk" && err instanceof StripeError ? toZendeskShape(err) : err;
76
+ let response = reply.status(rendered.status);
77
+ if (rendered instanceof StripeError)
78
+ for (const [name, value] of Object.entries(rendered.headers))
79
+ response = response.header(name, value);
80
+ void response.header("content-type", "application/json").send(JSON.stringify(rendered.toJSON()));
81
+ }
82
+ function resetInFlight() {
83
+ return apiError("worlds: this world was reset or destroyed while the request was in flight, so the request was not executed and no trace of it remains. Retry it against the world as it is now, if it still exists.");
84
+ }
85
+ const refusalsLogged = new Set();
86
+ function warnRefusedWorldOnce(req, worldId, reason) {
87
+ if (refusalsLogged.has(worldId))
88
+ return;
89
+ refusalsLogged.add(worldId);
90
+ req.log.warn({ world_id: worldId }, `twin API key refused: ${reason}`);
91
+ }
92
+ export function shouldStoreIdempotency(input) {
93
+ return input.method === "POST" && input.key !== null && input.phase !== "validation";
94
+ }
95
+ export function registerTwinApi(app, manager, dispatcher) {
96
+ const serve = async (req, reply, ctx) => {
97
+ const { apiKey, method, path, surface } = ctx;
98
+ const auth = manager.authenticate(apiKey);
99
+ if (!auth || auth.status === "unreadable") {
100
+ if (auth)
101
+ warnRefusedWorldOnce(req, auth.worldId, auth.reason);
102
+ return sendError(reply, authenticationError(`Invalid API Key provided: ${redactKey(apiKey)}`), surface);
103
+ }
104
+ if (auth.status === "expired") {
105
+ return sendError(reply, new StripeError(401, {
106
+ type: "invalid_request_error",
107
+ code: "api_key_expired",
108
+ message: `Expired API Key provided: ${redactKey(apiKey)}`,
109
+ }), surface);
110
+ }
111
+ const world = auth.world;
112
+ const generation = world.generation;
113
+ const uses = world.bumpApiKeyUse(apiKey);
114
+ const expiry = authExpiryThreshold(world);
115
+ if (expiry !== undefined && uses > expiry) {
116
+ world.logRequest({ method, path, status: 401, injected: true });
117
+ return sendError(reply, new StripeError(401, {
118
+ type: "invalid_request_error",
119
+ code: "api_key_expired",
120
+ message: `Expired API Key provided: ${redactKey(apiKey)}`,
121
+ }), surface);
122
+ }
123
+ const outcome = evaluateRequestRules(world, method, path);
124
+ if (outcome.latencyMs > 0) {
125
+ await sleep(outcome.latencyMs);
126
+ if (world.generation !== generation)
127
+ return sendError(reply, resetInFlight(), surface);
128
+ }
129
+ if (outcome.inject) {
130
+ world.logRequest({ method, path, status: outcome.inject.status, injected: true });
131
+ return sendError(reply, outcome.inject, surface);
132
+ }
133
+ const match = matchRoute(method, path);
134
+ if (!match) {
135
+ world.logRequest({ method, path, status: 404, matched: false });
136
+ return sendError(reply, new StripeError(404, {
137
+ type: "invalid_request_error",
138
+ message: `Unrecognized request URL (${method}: ${path}). If you are trying to list objects, remove the trailing slash. The twin implements a documented subset of the API — see docs/scope.md.`,
139
+ }), surface);
140
+ }
141
+ const params = { ...req.query };
142
+ if (typeof req.body === "string" && req.body.length > 0) {
143
+ Object.assign(params, parseFormBody(req.body));
144
+ }
145
+ else if (req.body && typeof req.body === "object") {
146
+ Object.assign(params, req.body);
147
+ }
148
+ for (const key of Object.keys(params)) {
149
+ if (key.includes("[")) {
150
+ const re = parseFormBody(`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`);
151
+ delete params[key];
152
+ deepMerge(params, re);
153
+ }
154
+ }
155
+ const refusal = refusedParameterError(method, match.route.pattern, params);
156
+ if (refusal) {
157
+ world.logRequest({ method, path, status: 400, unsupported: refusal.body.param });
158
+ return sendError(reply, refusal, surface);
159
+ }
160
+ const idempotencyKey = req.headers["idempotency-key"] || null;
161
+ let refused = false;
162
+ await manager.withLock(world.id, async () => {
163
+ if (world.generation !== generation) {
164
+ refused = true;
165
+ return sendError(reply, resetInFlight(), surface);
166
+ }
167
+ if (world.clockMode === "auto") {
168
+ try {
169
+ world.db.transaction(() => {
170
+ world.advanceClock(world.autoTickSeconds);
171
+ processRenewals(world);
172
+ });
173
+ }
174
+ catch (err) {
175
+ req.log.error(err);
176
+ const message = err instanceof RenewalFailed
177
+ ? err.message
178
+ : `worlds: the auto clock tick failed (${err instanceof Error ? err.message : String(err)}); the clock did not move and nothing was written.`;
179
+ world.logRequest({ method, path, status: 500 });
180
+ return sendError(reply, apiError(message), surface);
181
+ }
182
+ }
183
+ const paramsHash = canonicalHash({ path, params });
184
+ if (method === "POST" && idempotencyKey !== null) {
185
+ const prior = world.getIdempotency(idempotencyKey);
186
+ if (prior) {
187
+ if (prior.params_hash !== paramsHash || prior.path !== path) {
188
+ world.logRequest({ method, path, status: 400 });
189
+ return sendError(reply, idempotencyError(`Keys for idempotent requests can only be used with the same parameters they were first used with. Try using a key other than '${idempotencyKey}' if you meant to execute a different request.`), surface);
190
+ }
191
+ world.logRequest({ method, path, status: prior.status });
192
+ void reply
193
+ .status(prior.status)
194
+ .header("content-type", "application/json")
195
+ .header("idempotent-replayed", "true")
196
+ .send(prior.body);
197
+ return;
198
+ }
199
+ }
200
+ let status = 200;
201
+ let bodyJson;
202
+ let phase = null;
203
+ let unsupported;
204
+ try {
205
+ const result = world.db.transaction(() => match.route.handler({ world, params, pathParams: match.pathParams, idempotencyKey, path }));
206
+ status = result.status ?? 200;
207
+ bodyJson = JSON.stringify(result.body);
208
+ }
209
+ catch (err) {
210
+ const known = err instanceof StripeError || err instanceof ZendeskError;
211
+ if (!known)
212
+ req.log.error(err);
213
+ let rendered = known
214
+ ? err
215
+ : apiError(err instanceof Error ? err.message : String(err));
216
+ phase = rendered instanceof StripeError ? rendered.phase : "execution";
217
+ if (rendered instanceof StripeError && rendered.body.code === UNSUPPORTED_PARAMETER_CODE)
218
+ unsupported = rendered.body.param;
219
+ if (surface === "zendesk" && rendered instanceof StripeError)
220
+ rendered = toZendeskShape(rendered);
221
+ status = rendered.status;
222
+ bodyJson = JSON.stringify(rendered.toJSON());
223
+ }
224
+ if (idempotencyKey !== null && shouldStoreIdempotency({ method, key: idempotencyKey, phase })) {
225
+ world.putIdempotency(idempotencyKey, method, path, paramsHash, status, bodyJson);
226
+ }
227
+ world.logRequest({ method, path, status, unsupported });
228
+ void reply.status(status).header("content-type", "application/json").send(bodyJson);
229
+ });
230
+ if (!refused)
231
+ dispatcher.drainSoon(world);
232
+ };
233
+ const handle = async (req, reply) => {
234
+ const path = (req.raw.url ?? "/").split("?")[0];
235
+ const method = req.method.toUpperCase();
236
+ const surface = surfaceOf(path);
237
+ const apiKey = extractApiKey(req);
238
+ if (!apiKey) {
239
+ return sendError(reply, authenticationError("You did not provide an API key. You need to provide your API key in the Authorization header, using Bearer auth (e.g. 'Authorization: Bearer YOUR_SECRET_KEY')."), surface);
240
+ }
241
+ const worldId = manager.worldIdOf(apiKey);
242
+ if (worldId === undefined) {
243
+ return sendError(reply, authenticationError(`Invalid API Key provided: ${redactKey(apiKey)}`), surface);
244
+ }
245
+ await manager.enter(worldId);
246
+ try {
247
+ await serve(req, reply, { apiKey, method, path, surface });
248
+ }
249
+ finally {
250
+ manager.leave(worldId);
251
+ }
252
+ };
253
+ for (const method of ["GET", "POST", "DELETE", "PUT", "PATCH"]) {
254
+ app.route({ method, url: "/v1/*", handler: handle });
255
+ app.route({ method, url: "/v1", handler: handle });
256
+ app.route({ method, url: "/api/v2/*", handler: handle });
257
+ app.route({ method, url: "/api/v2", handler: handle });
258
+ }
259
+ }
260
+ function redactKey(key) {
261
+ if (key.length <= 12)
262
+ return key;
263
+ return `${key.slice(0, 12)}********`;
264
+ }
265
+ function deepMerge(target, source) {
266
+ for (const [k, v] of Object.entries(source)) {
267
+ if (v &&
268
+ typeof v === "object" &&
269
+ !Array.isArray(v) &&
270
+ target[k] &&
271
+ typeof target[k] === "object" &&
272
+ !Array.isArray(target[k])) {
273
+ deepMerge(target[k], v);
274
+ }
275
+ else {
276
+ target[k] = v;
277
+ }
278
+ }
279
+ }
@@ -0,0 +1,212 @@
1
+ import { unsupportedParameter } from "../core/errors.js";
2
+ import { parseKeyPath } from "./params.js";
3
+ const OFF = ["false"];
4
+ const CAPTURE = {
5
+ param: "capture_method",
6
+ allowed: ["automatic", "automatic_async"],
7
+ why: "a manual capture holds funds without moving them; the twin captures on confirm",
8
+ };
9
+ const CAPTURE_OPTIONS = {
10
+ param: "payment_method_options[card][capture_method]",
11
+ why: "the card-level capture_method overrides the top-level one: manual holds the funds instead of capturing them",
12
+ };
13
+ const CONNECT = [
14
+ { param: "application_fee_amount", why: "a Connect fee splits the charge" },
15
+ { param: "transfer_data", why: "a Connect transfer moves money to another account" },
16
+ { param: "on_behalf_of", why: "Connect settlement on another account" },
17
+ ];
18
+ const SUBSCRIPTION_CONNECT = [
19
+ { param: "application_fee_percent", why: "a Connect fee splits every invoice's charge" },
20
+ { param: "transfer_data", why: "a Connect transfer moves every invoice's charge to another account" },
21
+ { param: "on_behalf_of", why: "Connect settlement on another account" },
22
+ ];
23
+ const BILLING_THRESHOLDS = {
24
+ param: "billing_thresholds",
25
+ why: "bills when the amount crosses a threshold, not at the period boundary",
26
+ };
27
+ const DISCOUNTS = [
28
+ { param: "coupon", why: "a discount changes every invoice total" },
29
+ { param: "discounts", why: "a discount changes every invoice total" },
30
+ { param: "promotion_code", why: "a discount changes every invoice total" },
31
+ ];
32
+ const TAX = [
33
+ { param: "default_tax_rates", why: "tax changes invoice totals" },
34
+ { param: "automatic_tax[enabled]", allowed: OFF, why: "tax changes invoice totals" },
35
+ ];
36
+ const COLLECTION = [
37
+ {
38
+ param: "collection_method",
39
+ allowed: ["charge_automatically"],
40
+ why: "send_invoice leaves the invoice open instead of charging it",
41
+ },
42
+ { param: "days_until_due", why: "only meaningful with send_invoice" },
43
+ ];
44
+ const PAYMENT_BEHAVIOR = {
45
+ param: "payment_behavior",
46
+ allowed: ["allow_incomplete", "error_if_incomplete"],
47
+ why: "default_incomplete and pending_if_incomplete leave the invoice unpaid; the twin charges at once",
48
+ };
49
+ const ITEM_LINE = [
50
+ {
51
+ param: "items[0][price_data]",
52
+ why: "an inline price the twin cannot read; create the price, then pass items[0][price]",
53
+ },
54
+ { param: "items[0][tax_rates]", why: "tax changes the line total" },
55
+ { param: "items[0][discounts]", why: "a discount changes the line total" },
56
+ ];
57
+ const CUSTOMER_MONEY = [
58
+ { param: "coupon", why: "a discount changes every future invoice" },
59
+ { param: "promotion_code", why: "a discount changes every future invoice" },
60
+ { param: "cash_balance", why: "a second balance the twin does not model" },
61
+ ];
62
+ export const REFUSED_PARAMS = {
63
+ "POST /v1/payment_intents": [CAPTURE, CAPTURE_OPTIONS, ...CONNECT],
64
+ "POST /v1/payment_intents/:id/confirm": [CAPTURE, CAPTURE_OPTIONS],
65
+ "POST /v1/subscriptions": [
66
+ { param: "trial_period_days", why: "a trial defers the first charge" },
67
+ { param: "trial_end", why: "a trial defers the first charge" },
68
+ {
69
+ param: "trial_from_plan",
70
+ allowed: OFF,
71
+ why: "takes the trial from the price's recurring[trial_period_days]; a trial defers the first charge",
72
+ },
73
+ ...DISCOUNTS,
74
+ ...TAX,
75
+ { param: "billing_cycle_anchor", why: "moves the first period boundary and prorates the first invoice" },
76
+ { param: "billing_cycle_anchor_config", why: "moves the first period boundary and prorates the first invoice" },
77
+ { param: "backdate_start_date", why: "bills past periods at creation" },
78
+ ...COLLECTION,
79
+ PAYMENT_BEHAVIOR,
80
+ { param: "cancel_at", why: "the twin models cancel_at_period_end only" },
81
+ { param: "add_invoice_items", why: "one-time lines change the first invoice total" },
82
+ ...ITEM_LINE,
83
+ ...SUBSCRIPTION_CONNECT,
84
+ BILLING_THRESHOLDS,
85
+ ],
86
+ "POST /v1/subscriptions/:id": [
87
+ { param: "trial_end", why: "a trial defers the next charge" },
88
+ ...DISCOUNTS,
89
+ ...TAX,
90
+ {
91
+ param: "billing_cycle_anchor",
92
+ allowed: ["unchanged"],
93
+ why: "resetting the anchor prorates and moves every renewal",
94
+ },
95
+ { param: "proration_date", why: "changes the proration amounts" },
96
+ { param: "cancel_at", why: "the twin models cancel_at_period_end only" },
97
+ { param: "pause_collection", why: "pauses charging while the subscription stays active" },
98
+ ...COLLECTION,
99
+ PAYMENT_BEHAVIOR,
100
+ { param: "add_invoice_items", why: "one-time lines change the next invoice total" },
101
+ ...ITEM_LINE,
102
+ {
103
+ param: "items[0][deleted]",
104
+ allowed: OFF,
105
+ why: "deletes the one item — Stripe 400s unless another item replaces it, and the twin models exactly one",
106
+ },
107
+ ...SUBSCRIPTION_CONNECT,
108
+ BILLING_THRESHOLDS,
109
+ ],
110
+ "DELETE /v1/subscriptions/:id": [
111
+ { param: "invoice_now", allowed: OFF, why: "invoices pending prorations at once" },
112
+ { param: "prorate", allowed: OFF, why: "credits the unused time on cancel" },
113
+ ],
114
+ "POST /v1/invoices": [
115
+ ...COLLECTION,
116
+ { param: "due_date", why: "only meaningful with send_invoice" },
117
+ {
118
+ param: "auto_advance",
119
+ allowed: OFF,
120
+ why: "Stripe finalizes and collects on its own about an hour later; the twin finalizes and pays only when told to",
121
+ },
122
+ { param: "discounts", why: "a discount changes the invoice total" },
123
+ ...TAX,
124
+ {
125
+ param: "pending_invoice_items_behavior",
126
+ allowed: ["include"],
127
+ why: "exclude leaves the pending items off the invoice; the twin always sweeps them",
128
+ },
129
+ {
130
+ param: "subscription",
131
+ why: "includes only that subscription's pending items; the twin sweeps every pending item of the customer",
132
+ },
133
+ ...CONNECT,
134
+ ],
135
+ "POST /v1/invoiceitems": [
136
+ { param: "price_data", why: "an inline price the twin cannot read; pass amount + currency, or price" },
137
+ { param: "unit_amount", why: "the twin reads amount (a total), or price × quantity" },
138
+ { param: "unit_amount_decimal", why: "the twin reads amount (a total), or price × quantity" },
139
+ { param: "discounts", why: "a discount changes the line total" },
140
+ { param: "tax_rates", why: "tax changes the line total" },
141
+ { param: "invoice", why: "adds the line to a specific draft invoice; the twin sweeps pending items only" },
142
+ ],
143
+ "POST /v1/invoices/:id/pay": [
144
+ { param: "paid_out_of_band", allowed: OFF, why: "marks the invoice paid with no charge" },
145
+ { param: "forgive", allowed: OFF, why: "writes off the remaining amount" },
146
+ ],
147
+ "POST /v1/refunds": [
148
+ { param: "refund_application_fee", allowed: OFF, why: "a Connect fee refund" },
149
+ { param: "reverse_transfer", allowed: OFF, why: "a Connect transfer reversal" },
150
+ ],
151
+ "POST /v1/customers": CUSTOMER_MONEY,
152
+ "POST /v1/customers/:id": CUSTOMER_MONEY,
153
+ "POST /v1/prices": [
154
+ { param: "billing_scheme", allowed: ["per_unit"], why: "tiered pricing changes the amount per quantity" },
155
+ { param: "tiers", why: "tiered pricing changes the amount per quantity" },
156
+ { param: "tiers_mode", why: "tiered pricing changes the amount per quantity" },
157
+ { param: "transform_quantity", why: "changes the billed quantity" },
158
+ {
159
+ param: "recurring[usage_type]",
160
+ allowed: ["licensed"],
161
+ why: "metered billing bills reported usage, not the quantity",
162
+ },
163
+ {
164
+ param: "recurring[aggregate_usage]",
165
+ why: "metered billing; a Plans-era parameter Stripe's current API no longer accepts on a price, refused rather than echoed",
166
+ },
167
+ { param: "recurring[meter]", why: "metered billing" },
168
+ {
169
+ param: "recurring[trial_period_days]",
170
+ why: "a trial every subscription created with trial_from_plan takes; a trial defers the first charge",
171
+ },
172
+ { param: "unit_amount_decimal", why: "the twin reads unit_amount" },
173
+ { param: "custom_unit_amount", why: "the customer chooses the amount" },
174
+ { param: "currency_options", why: "multi-currency prices" },
175
+ { param: "product_data", why: "an inline product the twin cannot create; create the product, then pass product" },
176
+ ],
177
+ };
178
+ function lookup(params, param) {
179
+ let node = params;
180
+ for (const segment of parseKeyPath(param)) {
181
+ if (node === null || typeof node !== "object")
182
+ return undefined;
183
+ node = node[segment];
184
+ }
185
+ return node;
186
+ }
187
+ export function findRefusedParam(method, pattern, params) {
188
+ for (const rule of REFUSED_PARAMS[`${method} ${pattern}`] ?? []) {
189
+ const value = lookup(params, rule.param);
190
+ if (value === undefined || value === null || value === "")
191
+ continue;
192
+ if (rule.allowed &&
193
+ (typeof value === "string" || typeof value === "boolean") &&
194
+ rule.allowed.includes(String(value)))
195
+ continue;
196
+ return { rule, value };
197
+ }
198
+ return undefined;
199
+ }
200
+ export function refusedParameterError(method, pattern, params) {
201
+ const refused = findRefusedParam(method, pattern, params);
202
+ if (!refused)
203
+ return undefined;
204
+ const { rule, value } = refused;
205
+ const scalar = typeof value === "string" || typeof value === "boolean" || typeof value === "number";
206
+ return unsupportedParameter({
207
+ param: rule.param,
208
+ subject: rule.allowed && scalar ? `${rule.param}=${String(value)}` : rule.param,
209
+ route: `${method} ${pattern}`,
210
+ why: rule.why,
211
+ });
212
+ }
@@ -0,0 +1,148 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { deliveryTimestamp } from "../core/nondeterministic.js";
3
+ import { webhookRules } from "../scenario/engine.js";
4
+ export function signPayload(secret, timestamp, payload) {
5
+ const signed = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
6
+ return `t=${timestamp},v1=${signed}`;
7
+ }
8
+ export class WebhookDispatcher {
9
+ flushing = new Map();
10
+ fetchImpl;
11
+ constructor(fetchImpl = fetch) {
12
+ this.fetchImpl = fetchImpl;
13
+ }
14
+ async flush(world) {
15
+ const prev = this.flushing.get(world.id) ?? Promise.resolve();
16
+ const run = prev.catch(() => undefined).then(() => this.flushInner(world));
17
+ this.flushing.set(world.id, run);
18
+ try {
19
+ return await run;
20
+ }
21
+ finally {
22
+ if (this.flushing.get(world.id) === run)
23
+ this.flushing.delete(world.id);
24
+ }
25
+ }
26
+ async flushInner(world) {
27
+ const results = [];
28
+ if (world.closed)
29
+ return results;
30
+ const generation = world.generation;
31
+ const config = world.webhookConfig();
32
+ if (!config)
33
+ return results;
34
+ const eligible = () => world.db
35
+ .prepare("SELECT seq, event_id, attempts FROM webhook_deliveries WHERE status = 'pending' AND next_attempt_at <= ? ORDER BY seq ASC")
36
+ .all(world.clock);
37
+ const { dropEveryNth, reorder } = webhookRules(world);
38
+ let batch = eligible();
39
+ if (reorder === "swap_adjacent") {
40
+ const swapped = [];
41
+ for (let i = 0; i < batch.length; i += 2) {
42
+ if (i + 1 < batch.length)
43
+ swapped.push(batch[i + 1], batch[i]);
44
+ else
45
+ swapped.push(batch[i]);
46
+ }
47
+ batch = swapped;
48
+ }
49
+ for (const row of batch) {
50
+ if (world.generation !== generation)
51
+ return results;
52
+ const event = world.getEvent(row.event_id);
53
+ if (!event)
54
+ continue;
55
+ if (dropEveryNth) {
56
+ const n = world.incrCounter("scenario:webhook_dispatch");
57
+ if (n % dropEveryNth === 0) {
58
+ world.db
59
+ .prepare("UPDATE webhook_deliveries SET status = 'dropped', attempts = ? WHERE seq = ?")
60
+ .run(row.attempts + 1, row.seq);
61
+ results.push({ event_id: event.id, event_type: event.type, status: "dropped", attempt: row.attempts + 1 });
62
+ continue;
63
+ }
64
+ }
65
+ const payload = JSON.stringify(event.data);
66
+ const timestamp = config.signature_time === "logical" ? world.clock : deliveryTimestamp();
67
+ const attempt = row.attempts + 1;
68
+ let statusCode;
69
+ let errorMsg;
70
+ try {
71
+ const res = await this.fetchImpl(config.url, {
72
+ method: "POST",
73
+ headers: {
74
+ "content-type": "application/json",
75
+ "stripe-signature": signPayload(config.secret, timestamp, payload),
76
+ "user-agent": "worlds-webhooks/0.1",
77
+ },
78
+ body: payload,
79
+ signal: AbortSignal.timeout(5000),
80
+ });
81
+ statusCode = res.status;
82
+ await res.arrayBuffer().catch(() => undefined);
83
+ if (world.generation !== generation)
84
+ return results;
85
+ if (res.status >= 200 && res.status < 300) {
86
+ world.db
87
+ .prepare("UPDATE webhook_deliveries SET status = 'delivered', attempts = ?, last_status_code = ? WHERE seq = ?")
88
+ .run(attempt, res.status, row.seq);
89
+ results.push({
90
+ event_id: event.id,
91
+ event_type: event.type,
92
+ status: "delivered",
93
+ attempt,
94
+ status_code: res.status,
95
+ });
96
+ continue;
97
+ }
98
+ errorMsg = `HTTP ${res.status}`;
99
+ }
100
+ catch (err) {
101
+ errorMsg = err instanceof Error ? err.message : String(err);
102
+ }
103
+ if (world.generation !== generation)
104
+ return results;
105
+ if (attempt > config.max_retries) {
106
+ world.db
107
+ .prepare("UPDATE webhook_deliveries SET status = 'failed', attempts = ?, last_status_code = ?, last_error = ? WHERE seq = ?")
108
+ .run(attempt, statusCode ?? null, errorMsg ?? null, row.seq);
109
+ results.push({
110
+ event_id: event.id,
111
+ event_type: event.type,
112
+ status: "failed",
113
+ attempt,
114
+ status_code: statusCode,
115
+ error: errorMsg,
116
+ });
117
+ }
118
+ else {
119
+ const nextAt = world.clock + 60 * 2 ** (attempt - 1);
120
+ world.db
121
+ .prepare("UPDATE webhook_deliveries SET attempts = ?, next_attempt_at = ?, last_status_code = ?, last_error = ? WHERE seq = ?")
122
+ .run(attempt, nextAt, statusCode ?? null, errorMsg ?? null, row.seq);
123
+ results.push({
124
+ event_id: event.id,
125
+ event_type: event.type,
126
+ status: "retry_scheduled",
127
+ attempt,
128
+ status_code: statusCode,
129
+ error: errorMsg,
130
+ next_attempt_at: nextAt,
131
+ });
132
+ }
133
+ }
134
+ return results;
135
+ }
136
+ drainSoon(world) {
137
+ setImmediate(() => {
138
+ try {
139
+ const config = world.webhookConfig();
140
+ if (!config?.auto_deliver)
141
+ return;
142
+ void this.flush(world).catch(() => undefined);
143
+ }
144
+ catch {
145
+ }
146
+ });
147
+ }
148
+ }
@@ -0,0 +1,7 @@
1
+ # The API key works for 10 more requests, then starts returning
2
+ # 401 api_key_expired. Tests whether agents detect auth failure and stop,
3
+ # rather than hammering on or silently reporting success.
4
+ name: auth-expiry
5
+ description: "After 10 authenticated requests, the API key acts expired (401 api_key_expired)."
6
+ rules:
7
+ - auth: { expire_after_requests: 10 }
@@ -0,0 +1,9 @@
1
+ # Every 3rd twin request fails with a 500 api_error. Deterministic: the
2
+ # counter lives in world state, so the same request script always sees the
3
+ # same failures.
4
+ name: intermittent-500s
5
+ description: "Every 3rd request returns a 500 api_error."
6
+ rules:
7
+ - match: { path: "/v1/**" }
8
+ every_nth: 3
9
+ respond: { status: 500, stripe_error: api_error }
@@ -0,0 +1,12 @@
1
+ # Stripe's OTHER 429. A lock_timeout means another request holds the object's
2
+ # lock, and Stripe marks it retryable (Stripe-Should-Retry: true) — so a stock
3
+ # official SDK retries it silently, with the same idempotency key, and the
4
+ # agent never sees it. A rate-limit 429 carries no such header and the SDK
5
+ # surfaces it. Every 5th Stripe write hits a lock here: the storm a stock
6
+ # integration survives without the model noticing anything happened.
7
+ name: lock-timeouts
8
+ description: "Every 5th Stripe write returns a 429 lock_timeout with Stripe-Should-Retry: true — the storm a stock SDK retries through silently."
9
+ rules:
10
+ - match: { method: POST, path: "/v1/**" }
11
+ every_nth: 5
12
+ respond: { status: 429, stripe_error: lock_timeout }