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.
- package/LICENSE.txt +15 -0
- package/bin/worlds.js +53 -0
- package/dist/cli/argv.js +27 -0
- package/dist/cli/claims.js +250 -0
- package/dist/cli/commands.js +67 -0
- package/dist/cli/contract.js +142 -0
- package/dist/cli/demo.js +114 -0
- package/dist/cli/doctor.js +299 -0
- package/dist/cli/expect.js +197 -0
- package/dist/cli/home.js +18 -0
- package/dist/cli/import.js +530 -0
- package/dist/cli/index.js +1014 -0
- package/dist/cli/init.js +725 -0
- package/dist/cli/judge-command.js +184 -0
- package/dist/cli/judge.js +1495 -0
- package/dist/cli/load.js +8 -0
- package/dist/cli/packs.js +82 -0
- package/dist/cli/passk.js +1256 -0
- package/dist/cli/policy-starters.js +83 -0
- package/dist/cli/preload.js +45 -0
- package/dist/cli/report.js +579 -0
- package/dist/cli/rubric.js +727 -0
- package/dist/cli/run.js +266 -0
- package/dist/cli/scoreboard.js +432 -0
- package/dist/cli/selftest.js +157 -0
- package/dist/cli/stdio.js +25 -0
- package/dist/cli/tasks.js +1246 -0
- package/dist/cli/try.js +156 -0
- package/dist/cli/version.js +17 -0
- package/package.json +38 -0
- package/packs/cancellations.yaml +201 -0
- package/packs/credits.yaml +274 -0
- package/packs/invoices.yaml +206 -0
- package/packs/plan-changes.yaml +170 -0
- package/packs/refunds.yaml +437 -0
- package/preload/node-hooks.mjs +28 -0
- package/preload/node.mjs +19 -0
- package/preload/python/sitecustomize.py +88 -0
- package/preload/wrap.mjs +81 -0
- package/rubrics/acme-prod-library.json +1427 -0
- package/rubrics/acme-prod-shift.json +499 -0
- package/server/dist/admin/auth.js +145 -0
- package/server/dist/admin/routes.js +469 -0
- package/server/dist/admin/schemas.js +243 -0
- package/server/dist/app.js +112 -0
- package/server/dist/core/canonical.js +22 -0
- package/server/dist/core/db.js +294 -0
- package/server/dist/core/errors.js +136 -0
- package/server/dist/core/manager.js +337 -0
- package/server/dist/core/money.js +43 -0
- package/server/dist/core/nondeterministic.js +39 -0
- package/server/dist/core/paths.js +33 -0
- package/server/dist/core/prng.js +19 -0
- package/server/dist/core/protocol.js +1 -0
- package/server/dist/core/publicUrl.js +49 -0
- package/server/dist/core/seed.js +286 -0
- package/server/dist/core/seedExport.js +169 -0
- package/server/dist/core/seedLoader.js +156 -0
- package/server/dist/core/time.js +45 -0
- package/server/dist/core/world.js +361 -0
- package/server/dist/index.js +17 -0
- package/server/dist/main.js +42 -0
- package/server/dist/scenario/engine.js +172 -0
- package/server/dist/scenario/library.js +49 -0
- package/server/dist/twin/build.js +372 -0
- package/server/dist/twin/pagination.js +32 -0
- package/server/dist/twin/params.js +118 -0
- package/server/dist/twin/resources/charges.js +24 -0
- package/server/dist/twin/resources/customers.js +151 -0
- package/server/dist/twin/resources/events.js +26 -0
- package/server/dist/twin/resources/invoices.js +217 -0
- package/server/dist/twin/resources/payment_intents.js +146 -0
- package/server/dist/twin/resources/products.js +92 -0
- package/server/dist/twin/resources/refunds.js +103 -0
- package/server/dist/twin/resources/shared.js +113 -0
- package/server/dist/twin/resources/subscriptions.js +325 -0
- package/server/dist/twin/resources/tickets.js +128 -0
- package/server/dist/twin/router.js +279 -0
- package/server/dist/twin/unsupported.js +212 -0
- package/server/dist/webhooks/dispatcher.js +148 -0
- package/server/scenarios/auth-expiry.yaml +7 -0
- package/server/scenarios/intermittent-500s.yaml +9 -0
- package/server/scenarios/lock-timeouts.yaml +12 -0
- package/server/scenarios/rate-limit-storm.yaml +10 -0
- package/server/scenarios/slow-stripe.yaml +9 -0
- package/server/scenarios/stripe-storm-shift.yaml +15 -0
- package/server/scenarios/tickets-storm.yaml +10 -0
- package/server/scenarios/webhook-chaos.yaml +7 -0
- package/server/seeds/acme-prod-library.json +40470 -0
- package/server/seeds/acme-prod-shift.json +36544 -0
- package/server/seeds/acme-prod.json +35412 -0
- package/server/seeds/saas-billing-small.json +4792 -0
- package/server/seeds/saas-billing-smoke.json +160 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { WorldNotFoundError, WorldRefusedError } from "../core/manager.js";
|
|
3
|
+
import { summarizeMoney } from "../core/money.js";
|
|
4
|
+
import { PROTOCOL_VERSION } from "../core/protocol.js";
|
|
5
|
+
import { parseSeed, SeedValidationError, seedHash } from "../core/seed.js";
|
|
6
|
+
import { exportSeed } from "../core/seedExport.js";
|
|
7
|
+
import { parseDuration } from "../core/time.js";
|
|
8
|
+
import { RESOURCE_NAMES } from "../core/world.js";
|
|
9
|
+
import { parseScenario } from "../scenario/engine.js";
|
|
10
|
+
import { processRenewals, RenewalFailed } from "../twin/resources/subscriptions.js";
|
|
11
|
+
import { ApplyScenarioBody, ClockAdvanceBody, CreateWorldBody, MarkBody, SeedValidateBody, WebhookEndpointBody, } from "./schemas.js";
|
|
12
|
+
function badRequest(reply, message, details) {
|
|
13
|
+
void reply.status(400).send({ error: { message, ...(details !== undefined ? { details } : {}) } });
|
|
14
|
+
}
|
|
15
|
+
const STRIPE_NAMESPACES = new Set([
|
|
16
|
+
"apple_pay",
|
|
17
|
+
"apps",
|
|
18
|
+
"billing",
|
|
19
|
+
"billing_portal",
|
|
20
|
+
"checkout",
|
|
21
|
+
"climate",
|
|
22
|
+
"entitlements",
|
|
23
|
+
"financial_connections",
|
|
24
|
+
"forwarding",
|
|
25
|
+
"identity",
|
|
26
|
+
"issuing",
|
|
27
|
+
"radar",
|
|
28
|
+
"reporting",
|
|
29
|
+
"sigma",
|
|
30
|
+
"tax",
|
|
31
|
+
"terminal",
|
|
32
|
+
"test_helpers",
|
|
33
|
+
"treasury",
|
|
34
|
+
]);
|
|
35
|
+
export function coverageRoute(method, path) {
|
|
36
|
+
const segments = path.split("/").filter(Boolean);
|
|
37
|
+
let resource = segments[0] === "v2" ? 2 : 1;
|
|
38
|
+
while (resource < segments.length - 1 && STRIPE_NAMESPACES.has(segments[resource]))
|
|
39
|
+
resource++;
|
|
40
|
+
const collapsed = segments.map((segment, i) => {
|
|
41
|
+
const idPosition = i > resource && (i - resource) % 2 === 1;
|
|
42
|
+
const loose = /[_0-9A-Z]/.test(segment);
|
|
43
|
+
const strict = segment.includes("_") && /[0-9A-Z]/.test(segment);
|
|
44
|
+
return (idPosition && loose) || strict ? "{id}" : segment;
|
|
45
|
+
});
|
|
46
|
+
return `${method} /${collapsed.join("/")}`;
|
|
47
|
+
}
|
|
48
|
+
function notFound(reply, message) {
|
|
49
|
+
void reply.status(404).send({ error: { message } });
|
|
50
|
+
}
|
|
51
|
+
function conflict(reply, message) {
|
|
52
|
+
void reply.status(409).send({ error: { message } });
|
|
53
|
+
}
|
|
54
|
+
export function registerAdminApi(app, deps) {
|
|
55
|
+
const { manager, dispatcher, scenarios } = deps;
|
|
56
|
+
const getWorld = (id, reply) => {
|
|
57
|
+
try {
|
|
58
|
+
return manager.get(id);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof WorldNotFoundError) {
|
|
62
|
+
notFound(reply, err.message);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (err instanceof WorldRefusedError) {
|
|
66
|
+
conflict(reply, err.message);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
app.get("/admin/health", async () => ({
|
|
73
|
+
ok: true,
|
|
74
|
+
worlds: manager.count(),
|
|
75
|
+
auth: deps.auth().mode,
|
|
76
|
+
protocol: PROTOCOL_VERSION,
|
|
77
|
+
resources: RESOURCE_NAMES,
|
|
78
|
+
}));
|
|
79
|
+
app.get("/admin/seeds", async () => ({
|
|
80
|
+
seeds: manager.seedLibrary.list().map((s) => ({
|
|
81
|
+
name: s.name,
|
|
82
|
+
version: s.seed.version,
|
|
83
|
+
seed_hash: s.hash,
|
|
84
|
+
epoch: s.seed.epoch,
|
|
85
|
+
clock_mode: s.seed.clock_mode,
|
|
86
|
+
counts: {
|
|
87
|
+
customers: s.seed.data.customers.length,
|
|
88
|
+
products: s.seed.data.products.length,
|
|
89
|
+
prices: s.seed.data.prices.length,
|
|
90
|
+
invoices: s.seed.data.invoices.length,
|
|
91
|
+
subscriptions: s.seed.data.subscriptions.length,
|
|
92
|
+
tickets: s.seed.data.tickets.length,
|
|
93
|
+
},
|
|
94
|
+
})),
|
|
95
|
+
}));
|
|
96
|
+
app.get("/admin/seeds/:name", async (req, reply) => {
|
|
97
|
+
const { name } = req.params;
|
|
98
|
+
const found = manager.seedLibrary.get(name);
|
|
99
|
+
if (!found)
|
|
100
|
+
return notFound(reply, `No such seed: ${name}`);
|
|
101
|
+
return { name, seed_hash: found.hash, seed_doc: found.seed };
|
|
102
|
+
});
|
|
103
|
+
app.post("/admin/seeds/validate", async (req, reply) => {
|
|
104
|
+
const body = SeedValidateBody.safeParse(req.body ?? {});
|
|
105
|
+
if (!body.success)
|
|
106
|
+
return badRequest(reply, "invalid body", body.error.issues);
|
|
107
|
+
try {
|
|
108
|
+
const seed = parseSeed(body.data.seed_doc);
|
|
109
|
+
return { valid: true, name: seed.name, seed_hash: seedHash(seed) };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof SeedValidationError) {
|
|
113
|
+
return { valid: false, issues: err.issues };
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
app.get("/admin/scenarios", async () => ({
|
|
119
|
+
scenarios: scenarios
|
|
120
|
+
.list()
|
|
121
|
+
.map((s) => ({ name: s.name, description: s.scenario.description ?? null, rules: s.scenario.rules.length })),
|
|
122
|
+
}));
|
|
123
|
+
app.post("/admin/worlds", async (req, reply) => {
|
|
124
|
+
const body = CreateWorldBody.safeParse(req.body ?? {});
|
|
125
|
+
if (!body.success)
|
|
126
|
+
return badRequest(reply, "invalid body", body.error.issues);
|
|
127
|
+
try {
|
|
128
|
+
const { world, apiKey, adminToken } = manager.createWorld(body.data);
|
|
129
|
+
void reply.status(201);
|
|
130
|
+
return {
|
|
131
|
+
world_id: world.id,
|
|
132
|
+
base_url: deps.publicUrl(req),
|
|
133
|
+
api_key: apiKey,
|
|
134
|
+
admin_token: adminToken,
|
|
135
|
+
seed_name: world.seedName,
|
|
136
|
+
seed_hash: world.seedHash,
|
|
137
|
+
clock: world.clock,
|
|
138
|
+
clock_mode: world.clockMode,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err instanceof SeedValidationError)
|
|
143
|
+
return badRequest(reply, err.message, err.issues);
|
|
144
|
+
return badRequest(reply, err instanceof Error ? err.message : String(err));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
app.get("/admin/worlds", async () => manager.scan());
|
|
148
|
+
app.get("/admin/worlds/:id", async (req, reply) => {
|
|
149
|
+
const world = getWorld(req.params.id, reply);
|
|
150
|
+
if (!world)
|
|
151
|
+
return;
|
|
152
|
+
return {
|
|
153
|
+
...manager.info(world.id),
|
|
154
|
+
api_keys: world.listApiKeys(),
|
|
155
|
+
admin_tokens: world.countAdminTokens(),
|
|
156
|
+
webhook_endpoint: world.webhookConfig() ?? null,
|
|
157
|
+
scenario: world.getScenario() ?? null,
|
|
158
|
+
marks: world.listMarks(),
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
app.post("/admin/worlds/:id/reset", async (req, reply) => {
|
|
162
|
+
const world = getWorld(req.params.id, reply);
|
|
163
|
+
if (!world)
|
|
164
|
+
return;
|
|
165
|
+
await manager.exclusive(world.id, () => manager.reset(world.id));
|
|
166
|
+
return manager.info(world.id);
|
|
167
|
+
});
|
|
168
|
+
app.delete("/admin/worlds/:id", async (req, reply) => {
|
|
169
|
+
if (!manager.has(req.params.id))
|
|
170
|
+
return notFound(reply, `No such world: '${req.params.id}'`);
|
|
171
|
+
try {
|
|
172
|
+
await manager.exclusive(req.params.id, () => manager.destroy(req.params.id));
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err instanceof WorldNotFoundError)
|
|
176
|
+
return notFound(reply, err.message);
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
return { destroyed: true, world_id: req.params.id };
|
|
180
|
+
});
|
|
181
|
+
app.post("/admin/worlds/:id/keys", async (req, reply) => {
|
|
182
|
+
const world = getWorld(req.params.id, reply);
|
|
183
|
+
if (!world)
|
|
184
|
+
return;
|
|
185
|
+
return { api_key: manager.mintKey(world.id) };
|
|
186
|
+
});
|
|
187
|
+
app.post("/admin/worlds/:id/admin-tokens", async (req, reply) => {
|
|
188
|
+
const world = getWorld(req.params.id, reply);
|
|
189
|
+
if (!world)
|
|
190
|
+
return;
|
|
191
|
+
const token = manager.mintAdminToken(world.id);
|
|
192
|
+
return { admin_token: token, active: world.countAdminTokens() };
|
|
193
|
+
});
|
|
194
|
+
app.delete("/admin/worlds/:id/admin-tokens/:token", async (req, reply) => {
|
|
195
|
+
const world = getWorld(req.params.id, reply);
|
|
196
|
+
if (!world)
|
|
197
|
+
return;
|
|
198
|
+
if (!world.revokeAdminToken(req.params.token))
|
|
199
|
+
return notFound(reply, `No such admin token on world '${world.id}'`);
|
|
200
|
+
return { revoked: true, active: world.countAdminTokens() };
|
|
201
|
+
});
|
|
202
|
+
app.get("/admin/worlds/:id/state/:resource", async (req, reply) => {
|
|
203
|
+
const world = getWorld(req.params.id, reply);
|
|
204
|
+
if (!world)
|
|
205
|
+
return;
|
|
206
|
+
const resource = req.params.resource;
|
|
207
|
+
if (!RESOURCE_NAMES.includes(resource)) {
|
|
208
|
+
return badRequest(reply, `unknown resource '${resource}'. Known: ${RESOURCE_NAMES.join(", ")}`);
|
|
209
|
+
}
|
|
210
|
+
const query = { ...req.query };
|
|
211
|
+
const limit = query.limit ? Number(query.limit) : undefined;
|
|
212
|
+
delete query.limit;
|
|
213
|
+
let items = world.listObjects(resource).map((o) => o.data);
|
|
214
|
+
for (const [field, value] of Object.entries(query)) {
|
|
215
|
+
items = items.filter((obj) => {
|
|
216
|
+
const actual = obj[field];
|
|
217
|
+
if (typeof actual === "number")
|
|
218
|
+
return actual === Number(value);
|
|
219
|
+
if (typeof actual === "boolean")
|
|
220
|
+
return String(actual) === value;
|
|
221
|
+
return actual === value;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (limit !== undefined)
|
|
225
|
+
items = items.slice(0, limit);
|
|
226
|
+
return { resource, count: items.length, data: items };
|
|
227
|
+
});
|
|
228
|
+
app.get("/admin/worlds/:id/events", async (req, reply) => {
|
|
229
|
+
const world = getWorld(req.params.id, reply);
|
|
230
|
+
if (!world)
|
|
231
|
+
return;
|
|
232
|
+
const { type, after_seq } = req.query;
|
|
233
|
+
let afterSeq;
|
|
234
|
+
if (after_seq !== undefined) {
|
|
235
|
+
if (!/^\d+$/.test(after_seq))
|
|
236
|
+
return badRequest(reply, "after_seq must be a non-negative integer (a mark's seq)");
|
|
237
|
+
afterSeq = Number(after_seq);
|
|
238
|
+
}
|
|
239
|
+
let events = world.listEvents({ afterSeq });
|
|
240
|
+
if (type)
|
|
241
|
+
events = events.filter((e) => e.type === type);
|
|
242
|
+
return { count: events.length, data: events.map((e) => e.data) };
|
|
243
|
+
});
|
|
244
|
+
app.get("/admin/worlds/:id/requests", async (req, reply) => {
|
|
245
|
+
const world = getWorld(req.params.id, reply);
|
|
246
|
+
if (!world)
|
|
247
|
+
return;
|
|
248
|
+
const { unmatched } = req.query;
|
|
249
|
+
const data = world.listRequests({ unmatchedOnly: unmatched === "true" });
|
|
250
|
+
if (unmatched === "true") {
|
|
251
|
+
const byRoute = new Map();
|
|
252
|
+
for (const r of data) {
|
|
253
|
+
const route = coverageRoute(r.method, r.path);
|
|
254
|
+
const key = r.unsupported ? `${route} — refused ${r.unsupported}` : route;
|
|
255
|
+
byRoute.set(key, (byRoute.get(key) ?? 0) + 1);
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
count: data.length,
|
|
259
|
+
gaps: [...byRoute.entries()].map(([route, hits]) => ({ route, hits })).sort((a, b) => b.hits - a.hits),
|
|
260
|
+
data,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return { count: data.length, data };
|
|
264
|
+
});
|
|
265
|
+
app.post("/admin/worlds/:id/marks", async (req, reply) => {
|
|
266
|
+
const world = getWorld(req.params.id, reply);
|
|
267
|
+
if (!world)
|
|
268
|
+
return;
|
|
269
|
+
const body = MarkBody.safeParse(req.body ?? {});
|
|
270
|
+
if (!body.success)
|
|
271
|
+
return badRequest(reply, "invalid body", body.error.issues);
|
|
272
|
+
return world.setMark(body.data.name);
|
|
273
|
+
});
|
|
274
|
+
app.get("/admin/worlds/:id/marks", async (req, reply) => {
|
|
275
|
+
const world = getWorld(req.params.id, reply);
|
|
276
|
+
if (!world)
|
|
277
|
+
return;
|
|
278
|
+
return { marks: world.listMarks() };
|
|
279
|
+
});
|
|
280
|
+
app.get("/admin/worlds/:id/diff", async (req, reply) => {
|
|
281
|
+
const world = getWorld(req.params.id, reply);
|
|
282
|
+
if (!world)
|
|
283
|
+
return;
|
|
284
|
+
const { from } = req.query;
|
|
285
|
+
if (!from)
|
|
286
|
+
return badRequest(reply, "query param 'from' (mark name) is required");
|
|
287
|
+
const mark = world.getMark(from);
|
|
288
|
+
if (!mark)
|
|
289
|
+
return notFound(reply, `No such mark: '${from}'`);
|
|
290
|
+
const groups = world.diff(mark.seq);
|
|
291
|
+
return { from: mark, ...groups, money: summarizeMoney(groups) };
|
|
292
|
+
});
|
|
293
|
+
app.get("/admin/worlds/:id/clock", async (req, reply) => {
|
|
294
|
+
const world = getWorld(req.params.id, reply);
|
|
295
|
+
if (!world)
|
|
296
|
+
return;
|
|
297
|
+
return { clock: world.clock, clock_mode: world.clockMode, iso: new Date(world.clock * 1000).toISOString() };
|
|
298
|
+
});
|
|
299
|
+
app.post("/admin/worlds/:id/clock/advance", async (req, reply) => {
|
|
300
|
+
const world = getWorld(req.params.id, reply);
|
|
301
|
+
if (!world)
|
|
302
|
+
return;
|
|
303
|
+
const body = ClockAdvanceBody.safeParse(req.body ?? {});
|
|
304
|
+
if (!body.success)
|
|
305
|
+
return badRequest(reply, "invalid body", body.error.issues);
|
|
306
|
+
let seconds;
|
|
307
|
+
try {
|
|
308
|
+
seconds = parseDuration(body.data.duration);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
return badRequest(reply, err instanceof Error ? err.message : String(err));
|
|
312
|
+
}
|
|
313
|
+
let result;
|
|
314
|
+
try {
|
|
315
|
+
result = await manager.withLock(world.id, () => {
|
|
316
|
+
const from = world.clock;
|
|
317
|
+
const renewals = world.db.transaction(() => {
|
|
318
|
+
world.advanceClock(seconds);
|
|
319
|
+
return processRenewals(world);
|
|
320
|
+
});
|
|
321
|
+
return { from, to: world.clock, renewals };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (err instanceof RenewalFailed)
|
|
326
|
+
return conflict(reply, err.message);
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
dispatcher.drainSoon(world);
|
|
330
|
+
return {
|
|
331
|
+
clock: result.to,
|
|
332
|
+
advanced_from: result.from,
|
|
333
|
+
advanced_by: seconds,
|
|
334
|
+
renewals_billed: result.renewals.billed,
|
|
335
|
+
renewals_pending: result.renewals.pending,
|
|
336
|
+
iso: new Date(result.to * 1000).toISOString(),
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
app.post("/admin/worlds/:id/scenario", async (req, reply) => {
|
|
340
|
+
const world = getWorld(req.params.id, reply);
|
|
341
|
+
if (!world)
|
|
342
|
+
return;
|
|
343
|
+
const body = ApplyScenarioBody.safeParse(req.body ?? {});
|
|
344
|
+
if (!body.success)
|
|
345
|
+
return badRequest(reply, "invalid scenario", body.error.issues);
|
|
346
|
+
let doc;
|
|
347
|
+
if (body.data.name !== undefined) {
|
|
348
|
+
const found = scenarios.get(body.data.name);
|
|
349
|
+
if (!found) {
|
|
350
|
+
const known = scenarios
|
|
351
|
+
.list()
|
|
352
|
+
.map((s) => s.name)
|
|
353
|
+
.join(", ") || "(none)";
|
|
354
|
+
return notFound(reply, `No such scenario: '${body.data.name}'. Available: ${known}`);
|
|
355
|
+
}
|
|
356
|
+
doc = found;
|
|
357
|
+
}
|
|
358
|
+
else if (body.data.scenario_doc !== undefined) {
|
|
359
|
+
doc = body.data.scenario_doc;
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
return badRequest(reply, "either 'name' or 'scenario_doc' is required");
|
|
363
|
+
}
|
|
364
|
+
let parsed;
|
|
365
|
+
try {
|
|
366
|
+
parsed = parseScenario(doc);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if (err instanceof z.ZodError)
|
|
370
|
+
return badRequest(reply, "invalid scenario", err.issues);
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
world.setScenario(parsed);
|
|
374
|
+
return { applied: parsed.name, rules: parsed.rules.length };
|
|
375
|
+
});
|
|
376
|
+
app.get("/admin/worlds/:id/scenario", async (req, reply) => {
|
|
377
|
+
const world = getWorld(req.params.id, reply);
|
|
378
|
+
if (!world)
|
|
379
|
+
return;
|
|
380
|
+
return { scenario: world.getScenario() ?? null };
|
|
381
|
+
});
|
|
382
|
+
app.delete("/admin/worlds/:id/scenario", async (req, reply) => {
|
|
383
|
+
const world = getWorld(req.params.id, reply);
|
|
384
|
+
if (!world)
|
|
385
|
+
return;
|
|
386
|
+
world.setScenario(null);
|
|
387
|
+
return { cleared: true };
|
|
388
|
+
});
|
|
389
|
+
app.post("/admin/worlds/:id/webhook-endpoint", async (req, reply) => {
|
|
390
|
+
const world = getWorld(req.params.id, reply);
|
|
391
|
+
if (!world)
|
|
392
|
+
return;
|
|
393
|
+
const body = WebhookEndpointBody.safeParse(req.body ?? {});
|
|
394
|
+
if (!body.success)
|
|
395
|
+
return badRequest(reply, "invalid body", body.error.issues);
|
|
396
|
+
const config = world.setWebhookConfig(body.data.url, body.data.max_retries, body.data.auto_deliver, body.data.signature_time);
|
|
397
|
+
return {
|
|
398
|
+
url: config.url,
|
|
399
|
+
signing_secret: config.secret,
|
|
400
|
+
max_retries: config.max_retries,
|
|
401
|
+
auto_deliver: config.auto_deliver === 1,
|
|
402
|
+
signature_time: config.signature_time,
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
app.delete("/admin/worlds/:id/webhook-endpoint", async (req, reply) => {
|
|
406
|
+
const world = getWorld(req.params.id, reply);
|
|
407
|
+
if (!world)
|
|
408
|
+
return;
|
|
409
|
+
world.clearWebhookConfig();
|
|
410
|
+
return { cleared: true };
|
|
411
|
+
});
|
|
412
|
+
app.get("/admin/worlds/:id/webhook-deliveries", async (req, reply) => {
|
|
413
|
+
const world = getWorld(req.params.id, reply);
|
|
414
|
+
if (!world)
|
|
415
|
+
return;
|
|
416
|
+
return { deliveries: world.listDeliveries() };
|
|
417
|
+
});
|
|
418
|
+
app.post("/admin/worlds/:id/webhooks/flush", async (req, reply) => {
|
|
419
|
+
const world = getWorld(req.params.id, reply);
|
|
420
|
+
if (!world)
|
|
421
|
+
return;
|
|
422
|
+
let results;
|
|
423
|
+
try {
|
|
424
|
+
results = await dispatcher.flush(world);
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
if (!manager.has(world.id))
|
|
428
|
+
return notFound(reply, `No such world: '${world.id}'`);
|
|
429
|
+
throw err;
|
|
430
|
+
}
|
|
431
|
+
if (!manager.has(world.id))
|
|
432
|
+
return notFound(reply, `No such world: '${world.id}'`);
|
|
433
|
+
const pending = world.listDeliveries().filter((d) => d.status === "pending").length;
|
|
434
|
+
return { results, remaining_pending: pending };
|
|
435
|
+
});
|
|
436
|
+
app.get("/admin/worlds/:id/export-seed", async (req, reply) => {
|
|
437
|
+
const world = getWorld(req.params.id, reply);
|
|
438
|
+
if (!world)
|
|
439
|
+
return;
|
|
440
|
+
const { name } = req.query;
|
|
441
|
+
const seedName = name ?? `${world.seedName}-export`;
|
|
442
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(seedName)) {
|
|
443
|
+
return badRequest(reply, `invalid seed name '${seedName}' (kebab-case required: lowercase letters, digits, hyphens)`);
|
|
444
|
+
}
|
|
445
|
+
const result = exportSeed(world, seedName);
|
|
446
|
+
return {
|
|
447
|
+
name: seedName,
|
|
448
|
+
epoch: result.seed.epoch,
|
|
449
|
+
seed_hash: seedHash(result.seed),
|
|
450
|
+
counts: {
|
|
451
|
+
customers: result.seed.data.customers.length,
|
|
452
|
+
products: result.seed.data.products.length,
|
|
453
|
+
prices: result.seed.data.prices.length,
|
|
454
|
+
invoices: result.seed.data.invoices.length,
|
|
455
|
+
charges_attached: result.seed.data.invoices.reduce((n, inv) => n + inv.charges.length, 0),
|
|
456
|
+
subscriptions: result.seed.data.subscriptions.length,
|
|
457
|
+
},
|
|
458
|
+
lossy: result.lossy,
|
|
459
|
+
seed_doc: result.seedDoc,
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
app.get("/admin/worlds/:id/dump", async (req, reply) => {
|
|
463
|
+
const world = getWorld(req.params.id, reply);
|
|
464
|
+
if (!world)
|
|
465
|
+
return;
|
|
466
|
+
void reply.header("content-type", "application/json");
|
|
467
|
+
return world.dump();
|
|
468
|
+
});
|
|
469
|
+
}
|