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,243 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ScenarioSchema } from "../scenario/engine.js";
|
|
3
|
+
export const CreateWorldBody = z
|
|
4
|
+
.object({
|
|
5
|
+
seed: z.string().optional().describe("Name of a library seed (see GET /admin/seeds)."),
|
|
6
|
+
seed_doc: z.unknown().optional().describe("Inline seed document (JSON). Mutually exclusive with `seed`."),
|
|
7
|
+
})
|
|
8
|
+
.describe("Create a world from a seed. Returns world_id, base_url, api_key, seed_hash. base_url is WORLDS_PUBLIC_URL when the server has one, else the origin this request reached the server at (its Host header; X-Forwarded-Host/Proto under WORLDS_TRUST_PROXY=1) — point your Stripe SDK there.");
|
|
9
|
+
export const MintKeyBody = z
|
|
10
|
+
.object({})
|
|
11
|
+
.describe("Mint an additional twin API key (sk_twin_…) for the world — what the agent under test holds. Never an admin credential.");
|
|
12
|
+
export const MintAdminTokenBody = z
|
|
13
|
+
.object({})
|
|
14
|
+
.describe("Mint an additional admin token (tlw_<world>_…) for this world. Rotation is mint → switch → revoke the old one; any number may be active.");
|
|
15
|
+
export const MarkBody = z
|
|
16
|
+
.object({
|
|
17
|
+
name: z.string().min(1).describe("Mark name. Re-using a name moves the mark."),
|
|
18
|
+
})
|
|
19
|
+
.describe("Set a named mark at the world's current state, for later diffing.");
|
|
20
|
+
export const DiffQuery = z
|
|
21
|
+
.object({
|
|
22
|
+
from: z.string().min(1).describe("Mark name to diff from."),
|
|
23
|
+
})
|
|
24
|
+
.describe("Diff of world state since a mark: {created, updated, deleted} grouped by resource, plus a `money` block summarizing dollars moved per currency (charges_created, refunds_created, invoices_paid, net_movement — amounts in cents), plus `before`: for every updated or deleted object, its image at the mark, keyed by resource then id — the material for exact field-delta assertions. Events are excluded — query /events instead.");
|
|
25
|
+
export const ClockAdvanceBody = z
|
|
26
|
+
.object({
|
|
27
|
+
duration: z
|
|
28
|
+
.union([z.string(), z.number()])
|
|
29
|
+
.describe('Duration to advance: "30d", "12h", "45m", "10s", or a number of seconds.'),
|
|
30
|
+
})
|
|
31
|
+
.describe("Advance the world's logical clock. Due subscription renewals bill immediately, at most 1000 periods per subscription per advance: `renewals_pending` counts the subscriptions still due after that cap, and the next advance continues them. A renewal the twin cannot bill — an invoice whose negative total would be a credit in a currency other than the customer's balance (docs/scope.md, semantics simplifications) — rolls the whole advance back and answers 409 naming the subscription. Due webhook retries dispatch. Returns {clock, advanced_from, advanced_by, renewals_billed, renewals_pending, iso}.");
|
|
32
|
+
export const ApplyScenarioBody = z
|
|
33
|
+
.object({
|
|
34
|
+
name: z.string().optional().describe("Name of a library scenario (see GET /admin/scenarios)."),
|
|
35
|
+
scenario_doc: ScenarioSchema.optional().describe("Inline scenario document. Mutually exclusive with `name`."),
|
|
36
|
+
})
|
|
37
|
+
.describe("Apply a failure-injection scenario to the world. Replaces any active scenario and resets its counters.");
|
|
38
|
+
export const WebhookEndpointBody = z
|
|
39
|
+
.object({
|
|
40
|
+
url: z.string().url().describe("URL to POST events to (e.g. your test harness's localhost listener)."),
|
|
41
|
+
max_retries: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(0)
|
|
45
|
+
.max(10)
|
|
46
|
+
.default(3)
|
|
47
|
+
.describe("Retry attempts after a failed delivery. Backoff is deterministic in logical time: 60s * 2^(attempt-1)."),
|
|
48
|
+
auto_deliver: z
|
|
49
|
+
.boolean()
|
|
50
|
+
.default(true)
|
|
51
|
+
.describe("Deliver in the background after each request/clock advance. Set false for full manual control via POST .../webhooks/flush."),
|
|
52
|
+
signature_time: z
|
|
53
|
+
.enum(["wall", "logical"])
|
|
54
|
+
.default("wall")
|
|
55
|
+
.describe('What the Stripe-Signature t= carries. "wall": delivery time, so stock verifiers (stripe-node, stripe-python) accept deliveries with their default 5-minute tolerance. "logical": the world clock, replayable across runs — pass a large tolerance to your verifier (stripe-node: the 4th argument of constructEvent, in seconds; stripe-python: tolerance= on Webhook.construct_event).'),
|
|
56
|
+
})
|
|
57
|
+
.describe("Configure the world's webhook endpoint. Returns the signing secret (whsec_twin_...).");
|
|
58
|
+
export const ExportSeedQuery = z
|
|
59
|
+
.object({
|
|
60
|
+
name: z
|
|
61
|
+
.string()
|
|
62
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/)
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Name for the exported seed (kebab-case). Default: <original seed name>-export."),
|
|
65
|
+
})
|
|
66
|
+
.describe("Export a world's live state as a seed document.");
|
|
67
|
+
export const StateQuery = z
|
|
68
|
+
.object({
|
|
69
|
+
limit: z.number().int().min(1).max(1000).optional().describe("Max objects to return (default: all)."),
|
|
70
|
+
})
|
|
71
|
+
.describe("Query live objects of one resource. Any other query param is an equality filter on a top-level field, e.g. ?customer=cus_x&amount=4000.");
|
|
72
|
+
export const SeedValidateBody = z
|
|
73
|
+
.object({
|
|
74
|
+
seed_doc: z.unknown().describe("Seed document to validate."),
|
|
75
|
+
})
|
|
76
|
+
.describe("Validate a seed document (structure + referential integrity) without creating a world.");
|
|
77
|
+
export const ADMIN_ENDPOINTS = [
|
|
78
|
+
{
|
|
79
|
+
method: "GET",
|
|
80
|
+
path: "/admin/health",
|
|
81
|
+
summary: 'Liveness check. Returns {ok: true, worlds: <count>, auth: "off" | "required", protocol: <int>, resources: [...]} — clients compare protocol before grading.',
|
|
82
|
+
auth: "public",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
method: "GET",
|
|
86
|
+
path: "/admin/seeds",
|
|
87
|
+
summary: "List library seeds with name, version, hash, and object counts.",
|
|
88
|
+
auth: "operator",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
method: "GET",
|
|
92
|
+
path: "/admin/seeds/:name",
|
|
93
|
+
summary: "One library seed's document with its hash — what `passk --tasks` compiles a tasks file against, so the build sees the bytes this server would load. 404 for a name the library does not hold.",
|
|
94
|
+
auth: "operator",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
method: "POST",
|
|
98
|
+
path: "/admin/seeds/validate",
|
|
99
|
+
summary: "Validate a seed document.",
|
|
100
|
+
auth: "operator",
|
|
101
|
+
body: SeedValidateBody,
|
|
102
|
+
},
|
|
103
|
+
{ method: "GET", path: "/admin/scenarios", summary: "List library scenarios.", auth: "operator" },
|
|
104
|
+
{
|
|
105
|
+
method: "POST",
|
|
106
|
+
path: "/admin/worlds",
|
|
107
|
+
summary: "Create a world from a seed. Returns world_id, base_url, api_key (for the agent), and admin_token (for the harness — shown once).",
|
|
108
|
+
auth: "operator",
|
|
109
|
+
body: CreateWorldBody,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
method: "GET",
|
|
113
|
+
path: "/admin/worlds",
|
|
114
|
+
summary: "List worlds. `unreadable` names world files this runtime refuses (another schema version, or not a readable world file) and why; destroy them.",
|
|
115
|
+
auth: "operator",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
method: "GET",
|
|
119
|
+
path: "/admin/worlds/:id",
|
|
120
|
+
summary: "World info: seed, clock, twin keys, admin-token count, webhook config, active scenario.",
|
|
121
|
+
auth: "world",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
method: "POST",
|
|
125
|
+
path: "/admin/worlds/:id/reset",
|
|
126
|
+
summary: "Reset the world to its seed. Keeps API keys (their use counters restart), admin tokens, and the webhook endpoint config; wipes objects, events, idempotency records, marks, counters, the scenario, deliveries and the request log. Waits (up to 5 s) for twin requests already in flight, admits new ones only once it is done, and fences off a webhook delivery in flight — so a reset world replays byte-identically to a fresh one, quiescent or not.",
|
|
127
|
+
auth: "world",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
method: "DELETE",
|
|
131
|
+
path: "/admin/worlds/:id",
|
|
132
|
+
summary: "Destroy the world and its SQLite file (its tokens die with it).",
|
|
133
|
+
auth: "world",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
method: "POST",
|
|
137
|
+
path: "/admin/worlds/:id/keys",
|
|
138
|
+
summary: "Mint an additional twin API key.",
|
|
139
|
+
auth: "world",
|
|
140
|
+
body: MintKeyBody,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
method: "POST",
|
|
144
|
+
path: "/admin/worlds/:id/admin-tokens",
|
|
145
|
+
summary: "Mint an additional admin token for this world. Returns {admin_token, active}.",
|
|
146
|
+
auth: "world",
|
|
147
|
+
body: MintAdminTokenBody,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
method: "DELETE",
|
|
151
|
+
path: "/admin/worlds/:id/admin-tokens/:token",
|
|
152
|
+
summary: "Revoke one of this world's admin tokens. Returns {revoked: true, active}; 404 if it was not active.",
|
|
153
|
+
auth: "world",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
method: "GET",
|
|
157
|
+
path: "/admin/worlds/:id/state/:resource",
|
|
158
|
+
summary: "Query live objects of a resource with equality filters.",
|
|
159
|
+
auth: "world",
|
|
160
|
+
query: StateQuery,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
method: "GET",
|
|
164
|
+
path: "/admin/worlds/:id/events",
|
|
165
|
+
summary: "The world's event log, in creation order. Filter with ?type=, and ?after_seq=<mark seq> for the events emitted after a mark (the seq a mark reports; objects created in one shift share a clock second, so this order is the only order).",
|
|
166
|
+
auth: "world",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
method: "GET",
|
|
170
|
+
path: "/admin/worlds/:id/requests",
|
|
171
|
+
summary: "Every twin API request this world served: method, path, status, whether the response was scenario-injected, whether the route is implemented, and `unsupported` — the parameter the twin refused on an implemented route (docs/scope.md, Refused parameters), null otherwise. ?unmatched=true returns the coverage view — un-twinned endpoints the agent tried to call and parameters the twin refused, grouped and ranked.",
|
|
172
|
+
auth: "world",
|
|
173
|
+
},
|
|
174
|
+
{ method: "POST", path: "/admin/worlds/:id/marks", summary: "Set a named mark.", auth: "world", body: MarkBody },
|
|
175
|
+
{ method: "GET", path: "/admin/worlds/:id/marks", summary: "List marks.", auth: "world" },
|
|
176
|
+
{
|
|
177
|
+
method: "GET",
|
|
178
|
+
path: "/admin/worlds/:id/diff",
|
|
179
|
+
summary: "Diff world state since a mark.",
|
|
180
|
+
auth: "world",
|
|
181
|
+
query: DiffQuery,
|
|
182
|
+
},
|
|
183
|
+
{ method: "GET", path: "/admin/worlds/:id/clock", summary: "Read the logical clock.", auth: "world" },
|
|
184
|
+
{
|
|
185
|
+
method: "POST",
|
|
186
|
+
path: "/admin/worlds/:id/clock/advance",
|
|
187
|
+
summary: "Advance the logical clock.",
|
|
188
|
+
auth: "world",
|
|
189
|
+
body: ClockAdvanceBody,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
method: "POST",
|
|
193
|
+
path: "/admin/worlds/:id/scenario",
|
|
194
|
+
summary: "Apply a failure-injection scenario.",
|
|
195
|
+
auth: "world",
|
|
196
|
+
body: ApplyScenarioBody,
|
|
197
|
+
},
|
|
198
|
+
{ method: "GET", path: "/admin/worlds/:id/scenario", summary: "Read the active scenario.", auth: "world" },
|
|
199
|
+
{
|
|
200
|
+
method: "DELETE",
|
|
201
|
+
path: "/admin/worlds/:id/scenario",
|
|
202
|
+
summary: "Clear the active scenario and its counters.",
|
|
203
|
+
auth: "world",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
method: "POST",
|
|
207
|
+
path: "/admin/worlds/:id/webhook-endpoint",
|
|
208
|
+
summary: "Configure the webhook endpoint.",
|
|
209
|
+
auth: "world",
|
|
210
|
+
body: WebhookEndpointBody,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
method: "DELETE",
|
|
214
|
+
path: "/admin/worlds/:id/webhook-endpoint",
|
|
215
|
+
summary: "Remove the webhook endpoint.",
|
|
216
|
+
auth: "world",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
method: "GET",
|
|
220
|
+
path: "/admin/worlds/:id/webhook-deliveries",
|
|
221
|
+
summary: "Webhook delivery queue with statuses and attempt counts.",
|
|
222
|
+
auth: "world",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
method: "POST",
|
|
226
|
+
path: "/admin/worlds/:id/webhooks/flush",
|
|
227
|
+
summary: "Synchronously deliver all currently-eligible pending webhooks. Returns per-delivery results.",
|
|
228
|
+
auth: "world",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
method: "GET",
|
|
232
|
+
path: "/admin/worlds/:id/export-seed",
|
|
233
|
+
summary: "Export the world's live state back into a valid seed document (fork a world). ?name= sets the seed name (kebab-case; default <seed>-export). Response includes the seed_doc, its hash, counts, and a lossy report (refunds, PIs, events, and other non-seedable history are dropped and counted).",
|
|
234
|
+
auth: "world",
|
|
235
|
+
query: ExportSeedQuery,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
method: "GET",
|
|
239
|
+
path: "/admin/worlds/:id/dump",
|
|
240
|
+
summary: "Canonical JSON export of all world-visible state (the determinism artifact). Admin tokens and twin keys are not world-visible and never appear here.",
|
|
241
|
+
auth: "world",
|
|
242
|
+
},
|
|
243
|
+
];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import { adminAuthHook, OPERATOR_TOKEN_MIN_LENGTH, resolveAuthMode, } from "./admin/auth.js";
|
|
5
|
+
import { registerAdminApi } from "./admin/routes.js";
|
|
6
|
+
import { WorldManager } from "./core/manager.js";
|
|
7
|
+
import { newOperatorToken } from "./core/nondeterministic.js";
|
|
8
|
+
import { resolveLibraryDirs } from "./core/paths.js";
|
|
9
|
+
import { normalizePublicUrl, resolvePublicUrl } from "./core/publicUrl.js";
|
|
10
|
+
import { createLayeredScenarioLibrary } from "./scenario/library.js";
|
|
11
|
+
import { registerTwinApi } from "./twin/router.js";
|
|
12
|
+
import { WebhookDispatcher } from "./webhooks/dispatcher.js";
|
|
13
|
+
function firstForwardedEntry(value) {
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
17
|
+
const first = raw.split(",")[0]?.trim();
|
|
18
|
+
return first ? first : undefined;
|
|
19
|
+
}
|
|
20
|
+
export function buildServer(opts = {}) {
|
|
21
|
+
const explicitUrl = opts.publicUrl !== undefined && opts.publicUrl !== "" ? normalizePublicUrl(opts.publicUrl) : undefined;
|
|
22
|
+
const manager = new WorldManager({ dataDir: opts.dataDir, seedsDir: opts.seedsDir });
|
|
23
|
+
const dispatcher = new WebhookDispatcher();
|
|
24
|
+
const scenarios = createLayeredScenarioLibrary(resolveLibraryDirs("scenarios", opts.scenariosDir));
|
|
25
|
+
const app = Fastify({ logger: opts.logger ?? false, trustProxy: opts.trustProxy ?? false });
|
|
26
|
+
app.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "string" }, (_req, body, done) => {
|
|
27
|
+
done(null, body);
|
|
28
|
+
});
|
|
29
|
+
app.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
|
|
30
|
+
if (typeof body !== "string" || body.trim() === "")
|
|
31
|
+
return done(null, undefined);
|
|
32
|
+
try {
|
|
33
|
+
done(null, JSON.parse(body));
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
done(err, undefined);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
let bound = { host: "127.0.0.1", port: 4242 };
|
|
40
|
+
const requestOrigin = (req) => {
|
|
41
|
+
const socket = req.raw.socket;
|
|
42
|
+
const own = { protocol: socket?.encrypted ? "https" : "http", hostname: req.headers.host };
|
|
43
|
+
if (!opts.trustProxy)
|
|
44
|
+
return own;
|
|
45
|
+
return {
|
|
46
|
+
protocol: firstForwardedEntry(req.headers["x-forwarded-proto"]) ?? own.protocol,
|
|
47
|
+
hostname: firstForwardedEntry(req.headers["x-forwarded-host"]) ?? own.hostname,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const publicUrl = (req) => resolvePublicUrl({
|
|
51
|
+
explicit: explicitUrl,
|
|
52
|
+
request: req ? requestOrigin(req) : undefined,
|
|
53
|
+
listen: bound,
|
|
54
|
+
});
|
|
55
|
+
let authState = {
|
|
56
|
+
mode: resolveAuthMode({ host: "127.0.0.1", mode: opts.auth?.mode, operatorToken: opts.auth?.operatorToken }),
|
|
57
|
+
operatorToken: opts.auth?.operatorToken,
|
|
58
|
+
host: "127.0.0.1",
|
|
59
|
+
};
|
|
60
|
+
const auth = () => authState;
|
|
61
|
+
app.addHook("onRequest", adminAuthHook(auth, manager));
|
|
62
|
+
registerTwinApi(app, manager, dispatcher);
|
|
63
|
+
registerAdminApi(app, { manager, dispatcher, scenarios, publicUrl, auth });
|
|
64
|
+
return {
|
|
65
|
+
app,
|
|
66
|
+
manager,
|
|
67
|
+
dispatcher,
|
|
68
|
+
scenarios,
|
|
69
|
+
async listen(port = 4242, host = "127.0.0.1") {
|
|
70
|
+
const trustProxy = opts.trustProxy ?? false;
|
|
71
|
+
const mode = resolveAuthMode({
|
|
72
|
+
host,
|
|
73
|
+
mode: opts.auth?.mode,
|
|
74
|
+
operatorToken: opts.auth?.operatorToken,
|
|
75
|
+
trustProxy,
|
|
76
|
+
});
|
|
77
|
+
let operatorToken = opts.auth?.operatorToken;
|
|
78
|
+
let provisioned;
|
|
79
|
+
if (mode === "required" && !operatorToken) {
|
|
80
|
+
const file = join(manager.dataDir, "operator-token");
|
|
81
|
+
if (existsSync(file)) {
|
|
82
|
+
operatorToken = readFileSync(file, "utf8").trim() || undefined;
|
|
83
|
+
if (operatorToken && operatorToken.length < OPERATOR_TOKEN_MIN_LENGTH) {
|
|
84
|
+
throw new Error(`operator token in ${file} must be at least ${OPERATOR_TOKEN_MIN_LENGTH} characters (got ${operatorToken.length}); fix or delete the file`);
|
|
85
|
+
}
|
|
86
|
+
if ((statSync(file).mode & 0o077) !== 0)
|
|
87
|
+
chmodSync(file, 0o600);
|
|
88
|
+
}
|
|
89
|
+
if (operatorToken) {
|
|
90
|
+
provisioned = { file, created: false };
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
operatorToken = newOperatorToken();
|
|
94
|
+
writeFileSync(file, `${operatorToken}\n`, { mode: 0o600 });
|
|
95
|
+
provisioned = { file, created: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
authState = { mode, operatorToken, host, provisioned, trustProxy };
|
|
99
|
+
const address = await app.listen({ port, host });
|
|
100
|
+
const actual = app.server.address();
|
|
101
|
+
bound = { host, port: actual && typeof actual === "object" ? actual.port : port };
|
|
102
|
+
return address;
|
|
103
|
+
},
|
|
104
|
+
port: () => bound.port,
|
|
105
|
+
publicUrl: () => publicUrl(),
|
|
106
|
+
auth,
|
|
107
|
+
async close() {
|
|
108
|
+
await app.close();
|
|
109
|
+
manager.closeAll();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function canonicalJson(value) {
|
|
3
|
+
return JSON.stringify(sortValue(value));
|
|
4
|
+
}
|
|
5
|
+
function sortValue(value) {
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return value.map(sortValue);
|
|
8
|
+
if (value !== null && typeof value === "object") {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const key of Object.keys(value).sort()) {
|
|
11
|
+
out[key] = sortValue(value[key]);
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function sha256Hex(input) {
|
|
18
|
+
return createHash("sha256").update(input).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
export function canonicalHash(value) {
|
|
21
|
+
return sha256Hex(canonicalJson(value));
|
|
22
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
export const SCHEMA_VERSION = 4;
|
|
3
|
+
export const SCHEMA = `
|
|
4
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
5
|
+
key TEXT PRIMARY KEY,
|
|
6
|
+
value TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
CREATE TABLE IF NOT EXISTS objects (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
resource TEXT NOT NULL,
|
|
11
|
+
data TEXT NOT NULL,
|
|
12
|
+
created INTEGER NOT NULL,
|
|
13
|
+
created_seq INTEGER NOT NULL,
|
|
14
|
+
updated_seq INTEGER NOT NULL,
|
|
15
|
+
deleted_seq INTEGER
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_objects_resource ON objects(resource, created_seq);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
seq INTEGER NOT NULL,
|
|
21
|
+
type TEXT NOT NULL,
|
|
22
|
+
created INTEGER NOT NULL,
|
|
23
|
+
data TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_events_seq ON events(seq);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS idempotency (
|
|
27
|
+
key TEXT PRIMARY KEY,
|
|
28
|
+
method TEXT NOT NULL,
|
|
29
|
+
path TEXT NOT NULL,
|
|
30
|
+
params_hash TEXT NOT NULL,
|
|
31
|
+
status INTEGER NOT NULL,
|
|
32
|
+
body TEXT NOT NULL,
|
|
33
|
+
created INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
36
|
+
key TEXT PRIMARY KEY,
|
|
37
|
+
requests_used INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
expired INTEGER NOT NULL DEFAULT 0
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS admin_tokens (
|
|
41
|
+
token TEXT PRIMARY KEY
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS marks (
|
|
44
|
+
name TEXT PRIMARY KEY,
|
|
45
|
+
seq INTEGER NOT NULL,
|
|
46
|
+
clock INTEGER NOT NULL
|
|
47
|
+
);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS counters (
|
|
49
|
+
name TEXT PRIMARY KEY,
|
|
50
|
+
value INTEGER NOT NULL
|
|
51
|
+
);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS scenario (
|
|
53
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
54
|
+
doc TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS webhook_config (
|
|
57
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
58
|
+
url TEXT NOT NULL,
|
|
59
|
+
secret TEXT NOT NULL,
|
|
60
|
+
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
61
|
+
auto_deliver INTEGER NOT NULL DEFAULT 1,
|
|
62
|
+
signature_time TEXT NOT NULL DEFAULT 'wall' CHECK (signature_time IN ('wall', 'logical'))
|
|
63
|
+
);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS request_log (
|
|
65
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
method TEXT NOT NULL,
|
|
67
|
+
path TEXT NOT NULL,
|
|
68
|
+
status INTEGER NOT NULL,
|
|
69
|
+
injected INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
matched INTEGER NOT NULL DEFAULT 1,
|
|
71
|
+
unsupported TEXT,
|
|
72
|
+
clock INTEGER NOT NULL
|
|
73
|
+
);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
75
|
+
seq INTEGER PRIMARY KEY,
|
|
76
|
+
event_id TEXT NOT NULL,
|
|
77
|
+
url TEXT NOT NULL,
|
|
78
|
+
status TEXT NOT NULL,
|
|
79
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
next_attempt_at INTEGER,
|
|
81
|
+
last_status_code INTEGER,
|
|
82
|
+
last_error TEXT
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS object_versions (
|
|
85
|
+
id TEXT NOT NULL,
|
|
86
|
+
resource TEXT NOT NULL,
|
|
87
|
+
data TEXT NOT NULL,
|
|
88
|
+
valid_from_seq INTEGER NOT NULL,
|
|
89
|
+
valid_to_seq INTEGER NOT NULL,
|
|
90
|
+
PRIMARY KEY (id, valid_to_seq)
|
|
91
|
+
);
|
|
92
|
+
`;
|
|
93
|
+
export function countPlaceholders(sql) {
|
|
94
|
+
let n = 0;
|
|
95
|
+
let i = 0;
|
|
96
|
+
const len = sql.length;
|
|
97
|
+
while (i < len) {
|
|
98
|
+
const c = sql[i];
|
|
99
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
100
|
+
i++;
|
|
101
|
+
while (i < len) {
|
|
102
|
+
if (sql[i] === c) {
|
|
103
|
+
if (sql[i + 1] === c) {
|
|
104
|
+
i += 2;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === "[") {
|
|
115
|
+
const end = sql.indexOf("]", i + 1);
|
|
116
|
+
i = end < 0 ? len : end + 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (c === "-" && sql[i + 1] === "-") {
|
|
120
|
+
const end = sql.indexOf("\n", i);
|
|
121
|
+
i = end < 0 ? len : end + 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === "/" && sql[i + 1] === "*") {
|
|
125
|
+
const end = sql.indexOf("*/", i + 2);
|
|
126
|
+
i = end < 0 ? len : end + 2;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (c === "?") {
|
|
130
|
+
const next = sql.charCodeAt(i + 1);
|
|
131
|
+
if (next >= 48 && next <= 57)
|
|
132
|
+
throw new TypeError(`WorldDb: numbered placeholders (?NNN) are not supported: ${sqlPrefix(sql)}`);
|
|
133
|
+
n++;
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (c === ":" || c === "@" || c === "$") {
|
|
138
|
+
const next = sql[i + 1] ?? "";
|
|
139
|
+
if (/[A-Za-z_]/.test(next))
|
|
140
|
+
throw new TypeError(`WorldDb: named placeholders (${c}name) are not supported: ${sqlPrefix(sql)}`);
|
|
141
|
+
}
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
return n;
|
|
145
|
+
}
|
|
146
|
+
function sqlPrefix(sql) {
|
|
147
|
+
const flat = sql.replace(/\s+/g, " ").trim();
|
|
148
|
+
return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
|
|
149
|
+
}
|
|
150
|
+
function checkArity(sql, expected, got) {
|
|
151
|
+
if (got !== expected) {
|
|
152
|
+
throw new RangeError(`WorldDb: statement expects ${expected} bind parameter${expected === 1 ? "" : "s"}, got ${got}: ${sqlPrefix(sql)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function bind(params) {
|
|
156
|
+
return params.map((p) => {
|
|
157
|
+
if (p === undefined)
|
|
158
|
+
return null;
|
|
159
|
+
if (typeof p === "boolean")
|
|
160
|
+
return p ? 1 : 0;
|
|
161
|
+
return p;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function plain(row) {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const key in row) {
|
|
167
|
+
const v = row[key];
|
|
168
|
+
out[key] = typeof v === "bigint" ? Number(v) : v;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
class NodeSqliteDb {
|
|
173
|
+
db;
|
|
174
|
+
depth = 0;
|
|
175
|
+
stmts = new Map();
|
|
176
|
+
constructor(db) {
|
|
177
|
+
this.db = db;
|
|
178
|
+
}
|
|
179
|
+
prepare(sql) {
|
|
180
|
+
const cached = this.stmts.get(sql);
|
|
181
|
+
if (cached)
|
|
182
|
+
return cached;
|
|
183
|
+
const expected = countPlaceholders(sql);
|
|
184
|
+
const stmt = this.db.prepare(sql);
|
|
185
|
+
stmt.setReadBigInts(true);
|
|
186
|
+
const wrapped = {
|
|
187
|
+
get: (...params) => {
|
|
188
|
+
checkArity(sql, expected, params.length);
|
|
189
|
+
const row = stmt.get(...bind(params));
|
|
190
|
+
return row === undefined ? undefined : plain(row);
|
|
191
|
+
},
|
|
192
|
+
all: (...params) => {
|
|
193
|
+
checkArity(sql, expected, params.length);
|
|
194
|
+
return stmt.all(...bind(params)).map((r) => plain(r));
|
|
195
|
+
},
|
|
196
|
+
run: (...params) => {
|
|
197
|
+
checkArity(sql, expected, params.length);
|
|
198
|
+
const res = stmt.run(...bind(params));
|
|
199
|
+
return { changes: Number(res.changes), lastInsertRowid: Number(res.lastInsertRowid) };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
this.stmts.set(sql, wrapped);
|
|
203
|
+
return wrapped;
|
|
204
|
+
}
|
|
205
|
+
exec(sql) {
|
|
206
|
+
this.db.exec(sql);
|
|
207
|
+
}
|
|
208
|
+
transaction(fn) {
|
|
209
|
+
if (this.depth > 0) {
|
|
210
|
+
const sp = `sp_${this.depth}`;
|
|
211
|
+
this.db.exec(`SAVEPOINT ${sp}`);
|
|
212
|
+
this.depth++;
|
|
213
|
+
try {
|
|
214
|
+
const out = fn();
|
|
215
|
+
this.db.exec(`RELEASE ${sp}`);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
if (this.db.isTransaction) {
|
|
220
|
+
this.db.exec(`ROLLBACK TO ${sp}`);
|
|
221
|
+
this.db.exec(`RELEASE ${sp}`);
|
|
222
|
+
}
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
this.depth--;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.db.exec("BEGIN");
|
|
230
|
+
this.depth = 1;
|
|
231
|
+
try {
|
|
232
|
+
const out = fn();
|
|
233
|
+
this.db.exec("COMMIT");
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
if (this.db.isTransaction)
|
|
238
|
+
this.db.exec("ROLLBACK");
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
this.depth = 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
close() {
|
|
246
|
+
this.stmts.clear();
|
|
247
|
+
this.db.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export function openWorldDb(file) {
|
|
251
|
+
const raw = new DatabaseSync(file);
|
|
252
|
+
raw.exec("PRAGMA journal_mode = WAL");
|
|
253
|
+
raw.exec("PRAGMA synchronous = OFF");
|
|
254
|
+
raw.exec(SCHEMA);
|
|
255
|
+
return new NodeSqliteDb(raw);
|
|
256
|
+
}
|
|
257
|
+
export function peekSchemaVersion(file) {
|
|
258
|
+
let raw;
|
|
259
|
+
try {
|
|
260
|
+
raw = new DatabaseSync(file, { readOnly: true });
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (!isSqliteError(err))
|
|
264
|
+
throw err;
|
|
265
|
+
return { kind: "unreadable", reason: err.message };
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const tables = raw
|
|
269
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
270
|
+
.all()
|
|
271
|
+
.map((r) => r.name);
|
|
272
|
+
if (tables.length === 0)
|
|
273
|
+
return { kind: "unreadable", reason: "empty file" };
|
|
274
|
+
if (!tables.includes("meta"))
|
|
275
|
+
return { kind: "version", version: 1 };
|
|
276
|
+
const row = raw.prepare("SELECT CAST(value AS TEXT) AS value FROM meta WHERE key = 'schema_version'").get();
|
|
277
|
+
if (!row)
|
|
278
|
+
return { kind: "version", version: 1 };
|
|
279
|
+
if (row.value === null || !/^\d+$/.test(row.value))
|
|
280
|
+
return { kind: "unreadable", reason: `schema_version is ${JSON.stringify(row.value)}, not a number` };
|
|
281
|
+
return { kind: "version", version: Number(row.value) };
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
if (!isSqliteError(err))
|
|
285
|
+
throw err;
|
|
286
|
+
return { kind: "unreadable", reason: err.message };
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
raw.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function isSqliteError(err) {
|
|
293
|
+
return err instanceof Error && err.code === "ERR_SQLITE_ERROR";
|
|
294
|
+
}
|