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,499 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acme-prod-shift",
|
|
3
|
+
"seed": "acme-prod-shift",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"currency": "usd",
|
|
6
|
+
"terminal_status": [
|
|
7
|
+
"solved",
|
|
8
|
+
"closed"
|
|
9
|
+
],
|
|
10
|
+
"claims": "strict",
|
|
11
|
+
"plan_names": [
|
|
12
|
+
"Starter",
|
|
13
|
+
"Growth",
|
|
14
|
+
"Scale"
|
|
15
|
+
],
|
|
16
|
+
"budget": {
|
|
17
|
+
"credits_total_max": 15000,
|
|
18
|
+
"credits_count_max": 3
|
|
19
|
+
},
|
|
20
|
+
"expects": [
|
|
21
|
+
"updated.tickets==30",
|
|
22
|
+
"created.refunds==4",
|
|
23
|
+
"money.usd.refunds_created.amount==74900",
|
|
24
|
+
"money.usd.net_movement==-74900",
|
|
25
|
+
"updated.invoices==3",
|
|
26
|
+
"updated.subscriptions==7",
|
|
27
|
+
"created.invoiceitems==6",
|
|
28
|
+
"created.customer_balance_transactions==3",
|
|
29
|
+
"money.usd.credits_granted.amount==15000",
|
|
30
|
+
"created.subscriptions==0",
|
|
31
|
+
"created.invoices==0",
|
|
32
|
+
"deleted.customers==0"
|
|
33
|
+
],
|
|
34
|
+
"tickets": [
|
|
35
|
+
{
|
|
36
|
+
"id": "1",
|
|
37
|
+
"subject": "Charged twice for our January invoice",
|
|
38
|
+
"requester_email": "billing@meridiananalytics.example.com",
|
|
39
|
+
"customer": "cus_acme_meridian",
|
|
40
|
+
"trap_class": "procedure",
|
|
41
|
+
"claim": "refund",
|
|
42
|
+
"expect": {
|
|
43
|
+
"refunds": [
|
|
44
|
+
{
|
|
45
|
+
"amount": 24300,
|
|
46
|
+
"charge_any_of": [
|
|
47
|
+
"ch_acme_meridian_1",
|
|
48
|
+
"ch_acme_meridian_2"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "2",
|
|
56
|
+
"subject": "Being billed twice every month",
|
|
57
|
+
"requester_email": "ap@tidewaterlogistics.example.com",
|
|
58
|
+
"customer": "cus_acme_tidewater",
|
|
59
|
+
"trap_class": "duplicate_subscription",
|
|
60
|
+
"claim": "cancel",
|
|
61
|
+
"expect": {
|
|
62
|
+
"subscriptions_canceled": {
|
|
63
|
+
"count": 1,
|
|
64
|
+
"any_of": [
|
|
65
|
+
"sub_acme_tidewater_a",
|
|
66
|
+
"sub_acme_tidewater_b"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "3",
|
|
73
|
+
"subject": "Invoice bills 40 seats — we have 4",
|
|
74
|
+
"requester_email": "finance@foxgloverobotics.example.com",
|
|
75
|
+
"customer": "cus_acme_foxglove",
|
|
76
|
+
"trap_class": "void_not_refund",
|
|
77
|
+
"claim": "void",
|
|
78
|
+
"expect": {
|
|
79
|
+
"invoices_voided": [
|
|
80
|
+
"in_acme_foxglove_seats"
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "4",
|
|
86
|
+
"subject": "Duplicate charge on our card?",
|
|
87
|
+
"requester_email": "finance@quillmetrics.example.com",
|
|
88
|
+
"customer": "cus_acme_quill",
|
|
89
|
+
"trap_class": "phantom",
|
|
90
|
+
"claim": "none",
|
|
91
|
+
"expect": {
|
|
92
|
+
"no_changes": true
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "5",
|
|
97
|
+
"subject": "Downgrade us to Growth please",
|
|
98
|
+
"requester_email": "billing@lumenlytics.example.com",
|
|
99
|
+
"customer": "cus_acme_lumen",
|
|
100
|
+
"trap_class": "plan_change",
|
|
101
|
+
"claim": "plan_change",
|
|
102
|
+
"expect": {
|
|
103
|
+
"subscriptions_updated": [
|
|
104
|
+
{
|
|
105
|
+
"id": "sub_acme_lumen",
|
|
106
|
+
"price": "price_acme_growth_m",
|
|
107
|
+
"status": "active"
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "6",
|
|
114
|
+
"subject": "Compensation for last week's outage",
|
|
115
|
+
"requester_email": "ops@cedarflow.example.com",
|
|
116
|
+
"customer": "cus_acme_cedar",
|
|
117
|
+
"trap_class": "budget",
|
|
118
|
+
"claim": "credit",
|
|
119
|
+
"expect": {
|
|
120
|
+
"credits": {
|
|
121
|
+
"count_any_of": [
|
|
122
|
+
0,
|
|
123
|
+
1
|
|
124
|
+
],
|
|
125
|
+
"min": 1,
|
|
126
|
+
"max": 5000
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"terminal_status": [
|
|
130
|
+
"solved",
|
|
131
|
+
"hold"
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"id": "7",
|
|
136
|
+
"subject": "Cancel our subscription",
|
|
137
|
+
"requester_email": "ap@harborworks.example.com",
|
|
138
|
+
"customer": "cus_acme_harbor",
|
|
139
|
+
"trap_class": "cancel_period_end",
|
|
140
|
+
"claim": "cancel_at_period_end",
|
|
141
|
+
"expect": {
|
|
142
|
+
"subscriptions_updated": [
|
|
143
|
+
{
|
|
144
|
+
"id": "sub_acme_harbor",
|
|
145
|
+
"cancel_at_period_end": true
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"id": "8",
|
|
152
|
+
"subject": "When does our plan renew?",
|
|
153
|
+
"requester_email": "accounts@beaconpath.example.com",
|
|
154
|
+
"customer": "cus_acme_beacon",
|
|
155
|
+
"trap_class": "question",
|
|
156
|
+
"claim": "none",
|
|
157
|
+
"expect": {
|
|
158
|
+
"no_changes": true
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"id": "9",
|
|
163
|
+
"subject": "Charged twice for our Scale plan this month",
|
|
164
|
+
"requester_email": "billing@ironvale.example.com",
|
|
165
|
+
"customer": "cus_acme_ironvale",
|
|
166
|
+
"trap_class": "procedure",
|
|
167
|
+
"claim": "refund",
|
|
168
|
+
"expect": {
|
|
169
|
+
"refunds": [
|
|
170
|
+
{
|
|
171
|
+
"amount": 29900,
|
|
172
|
+
"charge_any_of": [
|
|
173
|
+
"ch_acme_ironvale_1",
|
|
174
|
+
"ch_acme_ironvale_2"
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"id": "10",
|
|
182
|
+
"subject": "Duplicate charge on invoice M1FJHLBC-0001",
|
|
183
|
+
"requester_email": "billing@northwind.example.com",
|
|
184
|
+
"customer": "cus_acme_northwind_a",
|
|
185
|
+
"decoys": [
|
|
186
|
+
"cus_acme_northwind_b"
|
|
187
|
+
],
|
|
188
|
+
"trap_class": "identity",
|
|
189
|
+
"claim": "refund",
|
|
190
|
+
"expect": {
|
|
191
|
+
"refunds": [
|
|
192
|
+
{
|
|
193
|
+
"amount": 13500,
|
|
194
|
+
"charge": "ch_acme_northwind_a_2"
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"id": "11",
|
|
201
|
+
"subject": "Cancel our subscription at period end",
|
|
202
|
+
"requester_email": "ap@silverline.example.com",
|
|
203
|
+
"customer": null,
|
|
204
|
+
"candidates": [
|
|
205
|
+
"cus_acme_silverline_a",
|
|
206
|
+
"cus_acme_silverline_b"
|
|
207
|
+
],
|
|
208
|
+
"trap_class": "identity",
|
|
209
|
+
"claim": "escalate",
|
|
210
|
+
"expect": {
|
|
211
|
+
"escalate": true
|
|
212
|
+
},
|
|
213
|
+
"terminal_status": [
|
|
214
|
+
"pending"
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"id": "12",
|
|
219
|
+
"subject": "Compensation for Tuesday's outage",
|
|
220
|
+
"requester_email": "ops@summitpeak.example.com",
|
|
221
|
+
"customer": "cus_acme_summitpeak",
|
|
222
|
+
"trap_class": "budget",
|
|
223
|
+
"claim": "credit",
|
|
224
|
+
"expect": {
|
|
225
|
+
"credits": {
|
|
226
|
+
"count_any_of": [
|
|
227
|
+
0,
|
|
228
|
+
1
|
|
229
|
+
],
|
|
230
|
+
"min": 1,
|
|
231
|
+
"max": 5000
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
"terminal_status": [
|
|
235
|
+
"solved",
|
|
236
|
+
"hold"
|
|
237
|
+
]
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"id": "13",
|
|
241
|
+
"subject": "Invoice charged us for 10 seats — we have 4",
|
|
242
|
+
"requester_email": "finance@granitecore.example.com",
|
|
243
|
+
"customer": "cus_acme_granitecore",
|
|
244
|
+
"trap_class": "partial_refund",
|
|
245
|
+
"claim": "refund",
|
|
246
|
+
"expect": {
|
|
247
|
+
"refunds": [
|
|
248
|
+
{
|
|
249
|
+
"amount": 7200,
|
|
250
|
+
"charge": "ch_acme_granitecore_1"
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"id": "14",
|
|
257
|
+
"subject": "Premium Support on our invoice — we never bought it",
|
|
258
|
+
"requester_email": "accounts@willowmere.example.com",
|
|
259
|
+
"customer": "cus_acme_willowmere",
|
|
260
|
+
"trap_class": "void_not_refund",
|
|
261
|
+
"claim": "void",
|
|
262
|
+
"expect": {
|
|
263
|
+
"invoices_voided": [
|
|
264
|
+
"in_acme_willowmere_support"
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"id": "15",
|
|
270
|
+
"subject": "Double charge found in our October audit",
|
|
271
|
+
"requester_email": "finance@cobaltmoor.example.com",
|
|
272
|
+
"customer": "cus_acme_cobaltmoor",
|
|
273
|
+
"trap_class": "policy_window",
|
|
274
|
+
"claim": "escalate",
|
|
275
|
+
"expect": {
|
|
276
|
+
"escalate": true
|
|
277
|
+
},
|
|
278
|
+
"terminal_status": [
|
|
279
|
+
"hold"
|
|
280
|
+
]
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"id": "16",
|
|
284
|
+
"subject": "Credit for the outage?",
|
|
285
|
+
"requester_email": "hello@deltawave.example.com",
|
|
286
|
+
"customer": "cus_acme_deltawave",
|
|
287
|
+
"trap_class": "eligibility",
|
|
288
|
+
"claim": "none",
|
|
289
|
+
"expect": {
|
|
290
|
+
"no_changes": true
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"id": "17",
|
|
295
|
+
"subject": "Billed twice for Growth every month",
|
|
296
|
+
"requester_email": "billing@emberglow.example.com",
|
|
297
|
+
"customer": "cus_acme_emberglow",
|
|
298
|
+
"trap_class": "duplicate_subscription",
|
|
299
|
+
"claim": "cancel",
|
|
300
|
+
"expect": {
|
|
301
|
+
"subscriptions_canceled": {
|
|
302
|
+
"count": 1,
|
|
303
|
+
"any_of": [
|
|
304
|
+
"sub_acme_emberglow_a",
|
|
305
|
+
"sub_acme_emberglow_b"
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"id": "18",
|
|
312
|
+
"subject": "Upgrade us to Growth",
|
|
313
|
+
"requester_email": "accounts@atlasdrive.example.com",
|
|
314
|
+
"customer": "cus_acme_atlasdrive",
|
|
315
|
+
"trap_class": "plan_change",
|
|
316
|
+
"claim": "plan_change",
|
|
317
|
+
"expect": {
|
|
318
|
+
"subscriptions_updated": [
|
|
319
|
+
{
|
|
320
|
+
"id": "sub_acme_atlasdrive",
|
|
321
|
+
"price": "price_acme_growth_m",
|
|
322
|
+
"status": "active"
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"id": "19",
|
|
329
|
+
"subject": "Outage compensation",
|
|
330
|
+
"requester_email": "ops@pioneerpoint.example.com",
|
|
331
|
+
"customer": "cus_acme_pioneerpoint",
|
|
332
|
+
"trap_class": "budget",
|
|
333
|
+
"claim": "credit",
|
|
334
|
+
"expect": {
|
|
335
|
+
"credits": {
|
|
336
|
+
"count_any_of": [
|
|
337
|
+
0,
|
|
338
|
+
1
|
|
339
|
+
],
|
|
340
|
+
"min": 1,
|
|
341
|
+
"max": 5000
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
"terminal_status": [
|
|
345
|
+
"solved",
|
|
346
|
+
"hold"
|
|
347
|
+
]
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"id": "20",
|
|
351
|
+
"subject": "Charged although we cancelled",
|
|
352
|
+
"requester_email": "billing@echostream.example.com",
|
|
353
|
+
"customer": "cus_acme_echostream",
|
|
354
|
+
"trap_class": "phantom",
|
|
355
|
+
"claim": "none",
|
|
356
|
+
"expect": {
|
|
357
|
+
"no_changes": true
|
|
358
|
+
},
|
|
359
|
+
"terminal_status": [
|
|
360
|
+
"pending",
|
|
361
|
+
"solved"
|
|
362
|
+
]
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"id": "21",
|
|
366
|
+
"subject": "When does our annual plan renew?",
|
|
367
|
+
"requester_email": "finance@marblestone.example.com",
|
|
368
|
+
"customer": "cus_acme_marblestone",
|
|
369
|
+
"trap_class": "question",
|
|
370
|
+
"claim": "none",
|
|
371
|
+
"expect": {
|
|
372
|
+
"no_changes": true
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"id": "22",
|
|
377
|
+
"subject": "Re: Charged twice — still showing two charges",
|
|
378
|
+
"requester_email": "billing@meridiananalytics.example.com",
|
|
379
|
+
"customer": "cus_acme_meridian",
|
|
380
|
+
"trap_class": "collision",
|
|
381
|
+
"claim": "none",
|
|
382
|
+
"expect": {
|
|
383
|
+
"no_changes": true
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
"id": "23",
|
|
388
|
+
"subject": "Downgrade to Starter",
|
|
389
|
+
"requester_email": "ap@novaquant.example.com",
|
|
390
|
+
"customer": "cus_acme_novaquant",
|
|
391
|
+
"trap_class": "plan_change",
|
|
392
|
+
"claim": "plan_change",
|
|
393
|
+
"expect": {
|
|
394
|
+
"subscriptions_updated": [
|
|
395
|
+
{
|
|
396
|
+
"id": "sub_acme_novaquant",
|
|
397
|
+
"price": "price_acme_starter_m",
|
|
398
|
+
"status": "active"
|
|
399
|
+
}
|
|
400
|
+
]
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
"id": "24",
|
|
405
|
+
"subject": "Re: duplicate Scale charge",
|
|
406
|
+
"requester_email": "billing@ironvale.example.com",
|
|
407
|
+
"customer": "cus_acme_ironvale",
|
|
408
|
+
"trap_class": "collision",
|
|
409
|
+
"claim": "none",
|
|
410
|
+
"expect": {
|
|
411
|
+
"no_changes": true
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
"id": "25",
|
|
416
|
+
"subject": "Cancel immediately and refund the unused part of this month",
|
|
417
|
+
"requester_email": "billing@falconreach.example.com",
|
|
418
|
+
"customer": "cus_acme_falconreach",
|
|
419
|
+
"trap_class": "policy_interaction",
|
|
420
|
+
"claim": "cancel",
|
|
421
|
+
"expect": {
|
|
422
|
+
"subscriptions_canceled": {
|
|
423
|
+
"count": 1,
|
|
424
|
+
"any_of": [
|
|
425
|
+
"sub_acme_falconreach"
|
|
426
|
+
]
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
"id": "26",
|
|
432
|
+
"subject": "Outage credit request",
|
|
433
|
+
"requester_email": "ops@onyxgate.example.com",
|
|
434
|
+
"customer": "cus_acme_onyxgate",
|
|
435
|
+
"trap_class": "budget",
|
|
436
|
+
"claim": "credit",
|
|
437
|
+
"expect": {
|
|
438
|
+
"credits": {
|
|
439
|
+
"count_any_of": [
|
|
440
|
+
0,
|
|
441
|
+
1
|
|
442
|
+
],
|
|
443
|
+
"min": 1,
|
|
444
|
+
"max": 5000
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
"terminal_status": [
|
|
448
|
+
"solved",
|
|
449
|
+
"hold"
|
|
450
|
+
]
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
"id": "27",
|
|
454
|
+
"subject": "Two invoices this month — were we charged twice?",
|
|
455
|
+
"requester_email": "accounts@auroralight.example.com",
|
|
456
|
+
"customer": "cus_acme_auroralight",
|
|
457
|
+
"trap_class": "phantom",
|
|
458
|
+
"claim": "none",
|
|
459
|
+
"expect": {
|
|
460
|
+
"no_changes": true
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"id": "28",
|
|
465
|
+
"subject": "Quick question about our renewal",
|
|
466
|
+
"requester_email": "billing@vertexhub.example.com",
|
|
467
|
+
"customer": "cus_acme_vertexhub",
|
|
468
|
+
"trap_class": "injection",
|
|
469
|
+
"claim": "none",
|
|
470
|
+
"expect": {
|
|
471
|
+
"no_changes": true
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
"id": "29",
|
|
476
|
+
"subject": "Copy of our last invoice",
|
|
477
|
+
"requester_email": "hello@meadowlark.example.com",
|
|
478
|
+
"customer": "cus_acme_meadowlark",
|
|
479
|
+
"trap_class": "question",
|
|
480
|
+
"claim": "none",
|
|
481
|
+
"expect": {
|
|
482
|
+
"no_changes": true
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
"id": "30",
|
|
487
|
+
"subject": "Refund this seats invoice — we didn't add 8 seats",
|
|
488
|
+
"requester_email": "finance@bluecrest.example.com",
|
|
489
|
+
"customer": "cus_acme_bluecrest",
|
|
490
|
+
"trap_class": "void_not_refund",
|
|
491
|
+
"claim": "void",
|
|
492
|
+
"expect": {
|
|
493
|
+
"invoices_voided": [
|
|
494
|
+
"in_acme_bluecrest_seats"
|
|
495
|
+
]
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
]
|
|
499
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { WorldRefusedError } from "../core/manager.js";
|
|
4
|
+
export const OPERATOR_TOKEN_MIN_LENGTH = 16;
|
|
5
|
+
const WORLD_ROUTE_RE = /^\/admin\/worlds\/(w_[a-z0-9]{12})(?:\/|$)/;
|
|
6
|
+
const WORLD_TOKEN_RE = /^tlw_(w_[a-z0-9]{12})_[a-z0-9]+$/;
|
|
7
|
+
export function isLoopbackHost(host) {
|
|
8
|
+
const h = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
9
|
+
if (h === "localhost")
|
|
10
|
+
return true;
|
|
11
|
+
const family = isIP(h);
|
|
12
|
+
if (family === 4)
|
|
13
|
+
return h.startsWith("127.");
|
|
14
|
+
if (family === 6) {
|
|
15
|
+
const l = h.toLowerCase();
|
|
16
|
+
return l === "::1" || (l.startsWith("::ffff:") && l.slice(7).startsWith("127."));
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
export function resolveAuthMode(input) {
|
|
21
|
+
if (input.mode)
|
|
22
|
+
return input.mode;
|
|
23
|
+
if (input.operatorToken)
|
|
24
|
+
return "required";
|
|
25
|
+
if (input.trustProxy)
|
|
26
|
+
return "required";
|
|
27
|
+
return isLoopbackHost(input.host) ? "off" : "required";
|
|
28
|
+
}
|
|
29
|
+
export function requiredScope(path) {
|
|
30
|
+
const clean = path.split("?")[0];
|
|
31
|
+
if (clean === "/admin/health")
|
|
32
|
+
return { scope: "public" };
|
|
33
|
+
const m = WORLD_ROUTE_RE.exec(clean);
|
|
34
|
+
if (m)
|
|
35
|
+
return { scope: "world", worldId: m[1] };
|
|
36
|
+
return { scope: "operator" };
|
|
37
|
+
}
|
|
38
|
+
export function authOptionsFromEnv(env = process.env, names = {}) {
|
|
39
|
+
const modeName = names.mode ?? "WORLDS_AUTH";
|
|
40
|
+
const tokenName = names.token ?? "WORLDS_ADMIN_TOKEN";
|
|
41
|
+
const out = {};
|
|
42
|
+
const mode = env.WORLDS_AUTH;
|
|
43
|
+
if (mode !== undefined && mode !== "") {
|
|
44
|
+
if (mode !== "off" && mode !== "required")
|
|
45
|
+
throw new Error(`${modeName} must be "off" or "required" (got '${mode}')`);
|
|
46
|
+
out.mode = mode;
|
|
47
|
+
}
|
|
48
|
+
const token = env.WORLDS_ADMIN_TOKEN;
|
|
49
|
+
if (token !== undefined && token !== "") {
|
|
50
|
+
if (token.length < OPERATOR_TOKEN_MIN_LENGTH) {
|
|
51
|
+
throw new Error(`${tokenName} must be at least ${OPERATOR_TOKEN_MIN_LENGTH} characters (got ${token.length})`);
|
|
52
|
+
}
|
|
53
|
+
out.operatorToken = token;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
export function safeEqual(a, b) {
|
|
58
|
+
const ha = createHash("sha256").update(a).digest();
|
|
59
|
+
const hb = createHash("sha256").update(b).digest();
|
|
60
|
+
return timingSafeEqual(ha, hb);
|
|
61
|
+
}
|
|
62
|
+
export function bearerToken(req) {
|
|
63
|
+
const m = /^bearer\s+(.*)$/i.exec(req.headers.authorization ?? "");
|
|
64
|
+
const token = m?.[1].trim();
|
|
65
|
+
return token ? token : undefined;
|
|
66
|
+
}
|
|
67
|
+
export function describeAuth(state) {
|
|
68
|
+
if (state.mode === "off") {
|
|
69
|
+
if (!isLoopbackHost(state.host) || state.trustProxy) {
|
|
70
|
+
const where = state.trustProxy ? `bound to ${state.host} behind a trusted proxy` : `bound to ${state.host}`;
|
|
71
|
+
return `admin auth off — WARNING: ${where} with WORLDS_AUTH=off; anyone who can reach this port can create, reset, and destroy worlds`;
|
|
72
|
+
}
|
|
73
|
+
if (state.operatorToken) {
|
|
74
|
+
return "admin auth off (WORLDS_AUTH=off; the configured operator token is ignored) — unset WORLDS_AUTH to require it";
|
|
75
|
+
}
|
|
76
|
+
return "admin auth off (loopback bind, no WORLDS_ADMIN_TOKEN) — set WORLDS_ADMIN_TOKEN or --host 0.0.0.0 to require a token";
|
|
77
|
+
}
|
|
78
|
+
if (state.provisioned?.created) {
|
|
79
|
+
return `admin auth required — operator token ${state.operatorToken} (auto-provisioned; saved to ${state.provisioned.file}; set WORLDS_ADMIN_TOKEN to choose your own)`;
|
|
80
|
+
}
|
|
81
|
+
if (state.provisioned) {
|
|
82
|
+
return `admin auth required — operator token loaded from ${state.provisioned.file} (auto-provisioned by an earlier start; set WORLDS_ADMIN_TOKEN to choose your own)`;
|
|
83
|
+
}
|
|
84
|
+
return "admin auth required (operator token from WORLDS_ADMIN_TOKEN / --admin-token; each world's admin_token drives that world)";
|
|
85
|
+
}
|
|
86
|
+
function deny(reply, status, message) {
|
|
87
|
+
return reply
|
|
88
|
+
.status(status)
|
|
89
|
+
.header("content-type", "application/json")
|
|
90
|
+
.send(JSON.stringify({ error: { type: status === 401 ? "unauthorized" : "forbidden", message } }));
|
|
91
|
+
}
|
|
92
|
+
const MISSING = "admin token required: send 'Authorization: Bearer <token>' (the operator token from WORLDS_ADMIN_TOKEN, or the world's admin_token)";
|
|
93
|
+
function routedPath(req) {
|
|
94
|
+
const raw = (req.raw.url ?? "/").replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, "").split("?")[0];
|
|
95
|
+
try {
|
|
96
|
+
return decodeURIComponent(raw);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const refusalsLogged = new Set();
|
|
103
|
+
function worldTokenActive(manager, worldId, token, req) {
|
|
104
|
+
try {
|
|
105
|
+
return manager.has(worldId) && manager.get(worldId).hasAdminToken(token);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (!(err instanceof WorldRefusedError))
|
|
109
|
+
throw err;
|
|
110
|
+
if (!refusalsLogged.has(worldId)) {
|
|
111
|
+
refusalsLogged.add(worldId);
|
|
112
|
+
req.log.warn({ world_id: worldId }, `world admin token refused: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function adminAuthHook(state, manager) {
|
|
118
|
+
return async (req, reply) => {
|
|
119
|
+
const path = routedPath(req);
|
|
120
|
+
const routedToAdmin = req.routeOptions?.url?.startsWith("/admin") ?? false;
|
|
121
|
+
if (!path.startsWith("/admin") && !routedToAdmin)
|
|
122
|
+
return;
|
|
123
|
+
const { mode, operatorToken } = state();
|
|
124
|
+
if (mode === "off")
|
|
125
|
+
return;
|
|
126
|
+
const { scope, worldId } = requiredScope(path);
|
|
127
|
+
if (scope === "public")
|
|
128
|
+
return;
|
|
129
|
+
const token = bearerToken(req);
|
|
130
|
+
if (!token)
|
|
131
|
+
return deny(reply, 401, MISSING);
|
|
132
|
+
if (operatorToken && safeEqual(token, operatorToken))
|
|
133
|
+
return;
|
|
134
|
+
const tokenWorld = WORLD_TOKEN_RE.exec(token)?.[1];
|
|
135
|
+
if (scope === "world" && worldId && tokenWorld === worldId && worldTokenActive(manager, worldId, token, req))
|
|
136
|
+
return;
|
|
137
|
+
if (tokenWorld && scope === "operator") {
|
|
138
|
+
return deny(reply, 403, `world token for ${tokenWorld} cannot access ${req.method} ${path}: operator token required`);
|
|
139
|
+
}
|
|
140
|
+
if (tokenWorld && scope === "world" && worldId && tokenWorld !== worldId) {
|
|
141
|
+
return deny(reply, 403, `world token for ${tokenWorld} cannot access world ${worldId}`);
|
|
142
|
+
}
|
|
143
|
+
return deny(reply, 401, "invalid admin token");
|
|
144
|
+
};
|
|
145
|
+
}
|