create-planet 1.0.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/index.js +235 -0
- package/package.json +20 -0
- package/template/.claude/settings.json +5 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +96 -0
- package/template/LICENSE +201 -0
- package/template/PROTOCOL_DIAGRAMS.md +195 -0
- package/template/README.md +51 -0
- package/template/astro.config.mjs +20 -0
- package/template/package.json +38 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/map.js +329 -0
- package/template/public/planet.css +873 -0
- package/template/public/three.min.js +29395 -0
- package/template/schema.sql +22 -0
- package/template/scripts/inject-do-exports.js +61 -0
- package/template/scripts/simulate-universe.js +158 -0
- package/template/src/env.d.ts +21 -0
- package/template/src/lib/config.ts +52 -0
- package/template/src/lib/consensus.ts +127 -0
- package/template/src/lib/crypto.ts +89 -0
- package/template/src/lib/identity.ts +35 -0
- package/template/src/lib/travel.ts +75 -0
- package/template/src/pages/api/v1/control-ws.ts +22 -0
- package/template/src/pages/api/v1/port.ts +673 -0
- package/template/src/pages/control.astro +232 -0
- package/template/src/pages/index.astro +1009 -0
- package/template/src/pages/manifest.json.ts +37 -0
- package/template/src/tests/protocol.test.ts +158 -0
- package/template/src/tests/warp-links.test.ts +117 -0
- package/template/src/traffic-control.ts +52 -0
- package/template/tsconfig.json +9 -0
- package/template/vitest.config.ts +12 -0
- package/template/wrangler.build.jsonc +36 -0
- package/template/wrangler.dev.jsonc +41 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import * as cheerio from "cheerio";
|
|
4
|
+
import { TravelCalculator, type PlanetManifest } from "../../../lib/travel";
|
|
5
|
+
import { CryptoCore } from "../../../lib/crypto";
|
|
6
|
+
import { PlanetIdentity } from "../../../lib/identity";
|
|
7
|
+
import { ConsensusEngine, type TravelPlan } from "../../../lib/consensus";
|
|
8
|
+
import { WARP_LINKS, PLANET_NAME } from "../../../lib/config";
|
|
9
|
+
import { env } from "cloudflare:workers";
|
|
10
|
+
|
|
11
|
+
const InitiateSchema = z.object({
|
|
12
|
+
ship_id: z.string(),
|
|
13
|
+
destination_url: z.string().url(),
|
|
14
|
+
departure_timestamp: z.number(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const TravelPlanSchema = z.object({
|
|
18
|
+
id: z.string(),
|
|
19
|
+
ship_id: z.string(),
|
|
20
|
+
origin_url: z.string().url(),
|
|
21
|
+
destination_url: z.string().url(),
|
|
22
|
+
start_timestamp: z.number(),
|
|
23
|
+
end_timestamp: z.number(),
|
|
24
|
+
status: z.enum(["PREPARING", "PLAN_ACCEPTED"]),
|
|
25
|
+
traffic_controllers: z.array(z.string()),
|
|
26
|
+
signatures: z.record(z.string()),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Returns ms per Flight-Year. Default: 1 hour (production). Override with WARP_MS_PER_FY for dev.
|
|
30
|
+
const msPerFY = (): number =>
|
|
31
|
+
parseInt((env as any).WARP_MS_PER_FY) || 3600 * 1000;
|
|
32
|
+
const departureBuffer = (): number =>
|
|
33
|
+
parseInt((env as any).DEPARTURE_BUFFER_MS) || 30 * 1000;
|
|
34
|
+
|
|
35
|
+
// Simulation Overrides helper
|
|
36
|
+
const getLocalPlanetInfo = (currentUrl: string) => {
|
|
37
|
+
// Robust helper to get simulation variables
|
|
38
|
+
const getSimVar = (name: string): string | undefined => {
|
|
39
|
+
if (env && (env as any)[name]) return (env as any)[name];
|
|
40
|
+
if (
|
|
41
|
+
typeof process !== "undefined" &&
|
|
42
|
+
process.env &&
|
|
43
|
+
(process.env as any)[name]
|
|
44
|
+
)
|
|
45
|
+
return (process.env as any)[name];
|
|
46
|
+
if (import.meta.env && (import.meta.env as any)[name])
|
|
47
|
+
return (import.meta.env as any)[name];
|
|
48
|
+
return undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const simUrl = getSimVar("PUBLIC_SIM_LANDING_SITE");
|
|
52
|
+
const simName = getSimVar("PUBLIC_SIM_PLANET_NAME");
|
|
53
|
+
const origin = new URL(currentUrl).origin;
|
|
54
|
+
const landingSite = simUrl || origin;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name: simName || PLANET_NAME,
|
|
58
|
+
landing_site: landingSite,
|
|
59
|
+
space_port: `${landingSite}/api/v1/port`,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
async function broadcastEvent(
|
|
64
|
+
TRAFFIC_CONTROL: DurableObjectNamespace,
|
|
65
|
+
event: any,
|
|
66
|
+
) {
|
|
67
|
+
if (!TRAFFIC_CONTROL || typeof TRAFFIC_CONTROL.idFromName !== "function") {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const id = TRAFFIC_CONTROL.idFromName("global");
|
|
72
|
+
const obj = TRAFFIC_CONTROL.get(id);
|
|
73
|
+
const payload = JSON.stringify({ ...event, timestamp: Date.now() });
|
|
74
|
+
console.log(`[broadcastEvent] Sending to DO: ${payload}`);
|
|
75
|
+
// Fire and forget to avoid blocking or crashing on DO errors
|
|
76
|
+
obj
|
|
77
|
+
.fetch("http://do/events", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
body: payload,
|
|
80
|
+
})
|
|
81
|
+
.then((res) => {
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
console.warn(`[broadcastEvent] DO responded with ${res.status}`);
|
|
84
|
+
})
|
|
85
|
+
.catch((e) =>
|
|
86
|
+
console.warn(
|
|
87
|
+
`[broadcastEvent] Background DO broadcast failed: ${e.message}`,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
} catch (e: any) {
|
|
91
|
+
console.warn(`[broadcastEvent] Failed to initiate broadcast: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export const GET: APIRoute = async ({ request }) => {
|
|
95
|
+
const { DB } = env as any;
|
|
96
|
+
const url = new URL(request.url);
|
|
97
|
+
const action = url.searchParams.get("action");
|
|
98
|
+
|
|
99
|
+
if (action === "check") {
|
|
100
|
+
const targetUrl = url.searchParams.get("url");
|
|
101
|
+
if (!targetUrl) {
|
|
102
|
+
return new Response(JSON.stringify({ error: "Missing url parameter" }), {
|
|
103
|
+
status: 400,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const manifest = await discoverSpacePort(targetUrl, DB);
|
|
107
|
+
return new Response(JSON.stringify({ has_space_port: manifest !== null }), {
|
|
108
|
+
status: 200,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (action === "neighbors") {
|
|
113
|
+
if (!DB || WARP_LINKS.length === 0) {
|
|
114
|
+
return new Response(JSON.stringify({ neighbors: [] }), { status: 200 });
|
|
115
|
+
}
|
|
116
|
+
const results = await Promise.all(
|
|
117
|
+
WARP_LINKS.map((l) => discoverSpacePort(l.url, DB)),
|
|
118
|
+
);
|
|
119
|
+
const neighbors = results.filter((n): n is PlanetManifest => n !== null);
|
|
120
|
+
return new Response(JSON.stringify({ neighbors }), { status: 200 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
|
124
|
+
status: 400,
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
129
|
+
const { KV, DB, TRAFFIC_CONTROL } = env as any;
|
|
130
|
+
const url = new URL(request.url);
|
|
131
|
+
const action = url.searchParams.get("action");
|
|
132
|
+
const senderOrigin =
|
|
133
|
+
request.headers.get("X-Planet-Origin") || "Browser/Unknown";
|
|
134
|
+
|
|
135
|
+
const localPlanet = getLocalPlanetInfo(request.url);
|
|
136
|
+
|
|
137
|
+
console.log(
|
|
138
|
+
`[${localPlanet.name}] Received ${request.method} request for action: ${action} from ${senderOrigin}`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Broadcast incoming request event
|
|
143
|
+
await broadcastEvent(TRAFFIC_CONTROL, {
|
|
144
|
+
type: "API_REQUEST",
|
|
145
|
+
planet: localPlanet.name,
|
|
146
|
+
from: senderOrigin,
|
|
147
|
+
action,
|
|
148
|
+
method: request.method,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
switch (action) {
|
|
152
|
+
case "initiate":
|
|
153
|
+
return await handleInitiate(
|
|
154
|
+
request,
|
|
155
|
+
KV,
|
|
156
|
+
DB,
|
|
157
|
+
localPlanet,
|
|
158
|
+
TRAFFIC_CONTROL,
|
|
159
|
+
);
|
|
160
|
+
case "prepare":
|
|
161
|
+
return await handlePrepare(
|
|
162
|
+
request,
|
|
163
|
+
KV,
|
|
164
|
+
DB,
|
|
165
|
+
localPlanet,
|
|
166
|
+
TRAFFIC_CONTROL,
|
|
167
|
+
);
|
|
168
|
+
case "register":
|
|
169
|
+
return await handleRegister(
|
|
170
|
+
request,
|
|
171
|
+
KV,
|
|
172
|
+
DB,
|
|
173
|
+
localPlanet,
|
|
174
|
+
TRAFFIC_CONTROL,
|
|
175
|
+
);
|
|
176
|
+
case "commit":
|
|
177
|
+
return await handleCommit(
|
|
178
|
+
request,
|
|
179
|
+
KV,
|
|
180
|
+
DB,
|
|
181
|
+
localPlanet,
|
|
182
|
+
TRAFFIC_CONTROL,
|
|
183
|
+
);
|
|
184
|
+
default:
|
|
185
|
+
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
|
186
|
+
status: 400,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
} catch (e: any) {
|
|
190
|
+
console.error(`[${localPlanet.name}] Action ${action} failed:`, e);
|
|
191
|
+
await broadcastEvent(TRAFFIC_CONTROL, {
|
|
192
|
+
type: "API_ERROR",
|
|
193
|
+
planet: localPlanet.name,
|
|
194
|
+
action,
|
|
195
|
+
error: e.message,
|
|
196
|
+
});
|
|
197
|
+
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
async function discoverSpacePort(
|
|
202
|
+
landingSiteUrl: string,
|
|
203
|
+
DB: D1Database,
|
|
204
|
+
): Promise<PlanetManifest | null> {
|
|
205
|
+
try {
|
|
206
|
+
const cached: any = await DB.prepare(
|
|
207
|
+
`
|
|
208
|
+
SELECT * FROM traffic_controllers
|
|
209
|
+
WHERE planet_url = ?
|
|
210
|
+
AND last_manifest_fetch > ?
|
|
211
|
+
`,
|
|
212
|
+
)
|
|
213
|
+
.bind(landingSiteUrl, Date.now() - 3600000)
|
|
214
|
+
.first();
|
|
215
|
+
|
|
216
|
+
if (cached) {
|
|
217
|
+
return {
|
|
218
|
+
name: cached.name,
|
|
219
|
+
landing_site: cached.planet_url,
|
|
220
|
+
space_port: cached.space_port_url,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const siteRes = await fetch(landingSiteUrl);
|
|
225
|
+
const html = await siteRes.text();
|
|
226
|
+
if (!html || !html.includes('rel="space-manifest"')) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const $ = cheerio.load(html);
|
|
230
|
+
|
|
231
|
+
let manifestPath = $('link[rel="space-manifest"]').attr("href");
|
|
232
|
+
if (!manifestPath) return null;
|
|
233
|
+
|
|
234
|
+
const manifestUrl = new URL(manifestPath, landingSiteUrl).href;
|
|
235
|
+
const manifestRes = await fetch(manifestUrl);
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
!manifestRes.ok ||
|
|
239
|
+
!manifestRes.headers.get("content-type")?.includes("application/json")
|
|
240
|
+
) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const remoteManifest: any = await manifestRes.json().catch(() => null);
|
|
245
|
+
|
|
246
|
+
if (!remoteManifest || !remoteManifest.space_port) return null;
|
|
247
|
+
|
|
248
|
+
const planet: PlanetManifest = {
|
|
249
|
+
name: remoteManifest.name,
|
|
250
|
+
landing_site: remoteManifest.landing_site || landingSiteUrl,
|
|
251
|
+
space_port: remoteManifest.space_port,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
await DB.prepare(
|
|
255
|
+
`
|
|
256
|
+
INSERT OR REPLACE INTO traffic_controllers (planet_url, name, space_port_url, last_manifest_fetch)
|
|
257
|
+
VALUES (?, ?, ?, ?)
|
|
258
|
+
`,
|
|
259
|
+
)
|
|
260
|
+
.bind(planet.landing_site, planet.name, planet.space_port, Date.now())
|
|
261
|
+
.run();
|
|
262
|
+
|
|
263
|
+
return planet;
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.error(`Discovery failed for ${landingSiteUrl}:`, e);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function handleInitiate(
|
|
271
|
+
request: Request,
|
|
272
|
+
KV: KVNamespace,
|
|
273
|
+
DB: D1Database,
|
|
274
|
+
localPlanet: any,
|
|
275
|
+
TRAFFIC_CONTROL: any,
|
|
276
|
+
) {
|
|
277
|
+
const body = await request.json();
|
|
278
|
+
const data = InitiateSchema.parse(body);
|
|
279
|
+
|
|
280
|
+
console.log(
|
|
281
|
+
`[${localPlanet.name}] Initiating travel for ship ${data.ship_id} to ${data.destination_url}`,
|
|
282
|
+
);
|
|
283
|
+
await broadcastEvent(TRAFFIC_CONTROL, {
|
|
284
|
+
type: "INITIATE_TRAVEL",
|
|
285
|
+
planet: localPlanet.name,
|
|
286
|
+
ship_id: data.ship_id,
|
|
287
|
+
destination: data.destination_url,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const myCoords = TravelCalculator.calculateCoordinates(
|
|
291
|
+
localPlanet.landing_site,
|
|
292
|
+
);
|
|
293
|
+
const destCoords = TravelCalculator.calculateCoordinates(
|
|
294
|
+
data.destination_url,
|
|
295
|
+
);
|
|
296
|
+
const distance = TravelCalculator.calculateDistance(myCoords, destCoords);
|
|
297
|
+
const travelTimeHours = TravelCalculator.calculateTravelTime(distance);
|
|
298
|
+
const startTimestamp = data.departure_timestamp + departureBuffer();
|
|
299
|
+
const endTimestamp = startTimestamp + travelTimeHours * msPerFY();
|
|
300
|
+
|
|
301
|
+
const discoveryPromises = [
|
|
302
|
+
discoverSpacePort(data.destination_url, DB),
|
|
303
|
+
...WARP_LINKS.map((l) => discoverSpacePort(l.url, DB)),
|
|
304
|
+
];
|
|
305
|
+
const [destManifest, ...neighborResults] =
|
|
306
|
+
await Promise.all(discoveryPromises);
|
|
307
|
+
|
|
308
|
+
if (!destManifest) {
|
|
309
|
+
return new Response(
|
|
310
|
+
JSON.stringify({ error: "no_destination_space_port" }),
|
|
311
|
+
{ status: 422 },
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const originNeighbors = neighborResults.filter(
|
|
316
|
+
(n): n is PlanetManifest => n !== null,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
console.log(
|
|
320
|
+
`[${localPlanet.name}] Origin neighbors checked (${WARP_LINKS.length} links, ${originNeighbors.length} with space port): ${WARP_LINKS.map((l, i) => `${l.name ?? l.url} → ${neighborResults[i] ? "✓" : "✗"}`).join(", ")}`,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Fetch destination's known neighbors so we can elect TCs from both sides
|
|
324
|
+
let destNeighbors: PlanetManifest[] = [];
|
|
325
|
+
try {
|
|
326
|
+
const res = await fetch(`${destManifest.space_port}?action=neighbors`, {
|
|
327
|
+
headers: { "X-Planet-Origin": localPlanet.landing_site },
|
|
328
|
+
});
|
|
329
|
+
if (res.ok) {
|
|
330
|
+
const json: any = await res.json();
|
|
331
|
+
destNeighbors = (json.neighbors || []).filter(
|
|
332
|
+
(n: any): n is PlanetManifest => n.landing_site && n.space_port,
|
|
333
|
+
);
|
|
334
|
+
console.log(
|
|
335
|
+
`[${localPlanet.name}] Destination neighbors from ${destManifest.name} (${destNeighbors.length} with space port): ${destNeighbors.map((n) => n.name ?? n.landing_site).join(", ") || "(none)"}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
} catch (e: any) {
|
|
339
|
+
console.warn(
|
|
340
|
+
`[${localPlanet.name}] Could not fetch destination neighbors: ${e.message}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Origin and destination are mandatory TC participants — their votes are always required
|
|
345
|
+
const mandatoryTCs: PlanetManifest[] = [
|
|
346
|
+
{
|
|
347
|
+
name: localPlanet.name,
|
|
348
|
+
landing_site: localPlanet.landing_site,
|
|
349
|
+
space_port: localPlanet.space_port,
|
|
350
|
+
},
|
|
351
|
+
destManifest,
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
// Require at least 4 controllers (3f+1 where f≥1) for meaningful fault tolerance
|
|
355
|
+
const MIN_CONTROLLERS = 4;
|
|
356
|
+
|
|
357
|
+
// Elect remaining slots equally from each neighbor pool, excluding mandatory TCs
|
|
358
|
+
const remaining = MIN_CONTROLLERS - mandatoryTCs.length;
|
|
359
|
+
const halfRemaining = Math.ceil(remaining / 2);
|
|
360
|
+
const seed = `${myCoords.x}${myCoords.y}${myCoords.z}${destCoords.x}${destCoords.y}${destCoords.z}${data.departure_timestamp}`;
|
|
361
|
+
|
|
362
|
+
// Deduplicate each pool against mandatory TCs, then against each other so a
|
|
363
|
+
// planet shared by both neighbor lists is only counted once across the pools.
|
|
364
|
+
const mandatoryUrls = new Set(mandatoryTCs.map((m) => m.landing_site));
|
|
365
|
+
|
|
366
|
+
const originPool = originNeighbors.filter(
|
|
367
|
+
(n) => !mandatoryUrls.has(n.landing_site),
|
|
368
|
+
);
|
|
369
|
+
// Dest pool excludes only mandatory TCs; elected origin candidates are
|
|
370
|
+
// excluded below after the origin election so small networks (where both
|
|
371
|
+
// pools overlap) can still contribute planets to reach MIN_CONTROLLERS.
|
|
372
|
+
const destPool = destNeighbors.filter(
|
|
373
|
+
(n) => !mandatoryUrls.has(n.landing_site),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const originElected = TravelCalculator.electControllers(
|
|
377
|
+
seed,
|
|
378
|
+
originPool,
|
|
379
|
+
halfRemaining,
|
|
380
|
+
);
|
|
381
|
+
const originElectedUrls = new Set([
|
|
382
|
+
...mandatoryUrls,
|
|
383
|
+
...originElected.map((n) => n.landing_site),
|
|
384
|
+
]);
|
|
385
|
+
// Exclude mandatory TCs and already-elected origin candidates so no planet
|
|
386
|
+
// is counted twice, but planets shared by both pools are still available.
|
|
387
|
+
const destPoolFiltered = destPool.filter(
|
|
388
|
+
(n) => !originElectedUrls.has(n.landing_site),
|
|
389
|
+
);
|
|
390
|
+
const destElected = TravelCalculator.electControllers(
|
|
391
|
+
seed,
|
|
392
|
+
destPoolFiltered,
|
|
393
|
+
halfRemaining,
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// Combine: mandatory first, then elected (already disjoint, no further dedup needed)
|
|
397
|
+
const electedTCs = [...mandatoryTCs, ...originElected, ...destElected];
|
|
398
|
+
|
|
399
|
+
console.log(
|
|
400
|
+
`[${localPlanet.name}] TC election: mandatory=[${mandatoryTCs.map((t) => t.name ?? t.landing_site).join(", ")}] originPool=${originPool.length} destPool=${destPool.length} elected=[${electedTCs.map((t) => t.name ?? t.landing_site).join(", ")}] (${electedTCs.length}/${MIN_CONTROLLERS} required)`,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
if (electedTCs.length < MIN_CONTROLLERS) {
|
|
404
|
+
return new Response(
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
error: "insufficient_controllers",
|
|
407
|
+
found: electedTCs.length,
|
|
408
|
+
required: MIN_CONTROLLERS,
|
|
409
|
+
}),
|
|
410
|
+
{ status: 422 },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const plan: TravelPlan = {
|
|
415
|
+
id: crypto.randomUUID(),
|
|
416
|
+
ship_id: data.ship_id,
|
|
417
|
+
origin_url: localPlanet.landing_site.replace(/\/$/, ""),
|
|
418
|
+
destination_url: data.destination_url.replace(/\/$/, ""),
|
|
419
|
+
start_timestamp: startTimestamp,
|
|
420
|
+
end_timestamp: endTimestamp,
|
|
421
|
+
status: "PREPARING",
|
|
422
|
+
traffic_controllers: electedTCs.map((tc) => tc.landing_site),
|
|
423
|
+
signatures: {},
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const { privateKey } = await PlanetIdentity.getIdentity(KV);
|
|
427
|
+
const signature = await CryptoCore.sign(JSON.stringify(plan), privateKey);
|
|
428
|
+
plan.signatures[localPlanet.landing_site] = signature;
|
|
429
|
+
|
|
430
|
+
await ConsensusEngine.savePlanState(KV, plan);
|
|
431
|
+
await ConsensusEngine.broadcast(plan, "prepare", electedTCs);
|
|
432
|
+
|
|
433
|
+
return new Response(JSON.stringify({ plan }), { status: 200 });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function handlePrepare(
|
|
437
|
+
request: Request,
|
|
438
|
+
KV: KVNamespace,
|
|
439
|
+
DB: D1Database,
|
|
440
|
+
localPlanet: any,
|
|
441
|
+
TRAFFIC_CONTROL: any,
|
|
442
|
+
) {
|
|
443
|
+
const plan = TravelPlanSchema.parse(await request.json());
|
|
444
|
+
|
|
445
|
+
console.log(
|
|
446
|
+
`[${localPlanet.name}] Preparing for travel plan ${plan.id} for ship ${plan.ship_id}`,
|
|
447
|
+
);
|
|
448
|
+
await broadcastEvent(TRAFFIC_CONTROL, {
|
|
449
|
+
type: "PREPARE_PLAN",
|
|
450
|
+
planet: localPlanet.name,
|
|
451
|
+
ship_id: plan.ship_id,
|
|
452
|
+
plan_id: plan.id,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const originCoords = TravelCalculator.calculateCoordinates(plan.origin_url);
|
|
456
|
+
const destCoords = TravelCalculator.calculateCoordinates(
|
|
457
|
+
plan.destination_url,
|
|
458
|
+
);
|
|
459
|
+
const dist = TravelCalculator.calculateDistance(originCoords, destCoords);
|
|
460
|
+
const expectedTime = TravelCalculator.calculateTravelTime(dist);
|
|
461
|
+
const actualTime = (plan.end_timestamp - plan.start_timestamp) / msPerFY();
|
|
462
|
+
|
|
463
|
+
if (Math.abs(actualTime - expectedTime) > 0.01) {
|
|
464
|
+
throw new Error("Invalid travel time calculation.");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const { privateKey } = await PlanetIdentity.getIdentity(KV);
|
|
468
|
+
const signature = await CryptoCore.sign(JSON.stringify(plan), privateKey);
|
|
469
|
+
plan.signatures[localPlanet.landing_site] = signature;
|
|
470
|
+
|
|
471
|
+
await ConsensusEngine.savePlanState(KV, plan);
|
|
472
|
+
|
|
473
|
+
const controllersPromises = plan.traffic_controllers.map((url) =>
|
|
474
|
+
discoverSpacePort(url, DB),
|
|
475
|
+
);
|
|
476
|
+
const controllers = (await Promise.all(controllersPromises)).filter(
|
|
477
|
+
(n): n is PlanetManifest => n !== null,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
await ConsensusEngine.broadcast(plan, "commit", controllers);
|
|
481
|
+
|
|
482
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
483
|
+
}
|
|
484
|
+
async function handleRegister(
|
|
485
|
+
request: Request,
|
|
486
|
+
KV: KVNamespace,
|
|
487
|
+
DB: D1Database,
|
|
488
|
+
localPlanet: any,
|
|
489
|
+
TRAFFIC_CONTROL: any,
|
|
490
|
+
) {
|
|
491
|
+
const plan = TravelPlanSchema.parse(await request.json());
|
|
492
|
+
|
|
493
|
+
console.log(
|
|
494
|
+
`[${localPlanet.name}] Registering incoming plan ${plan.id} for ship ${plan.ship_id}`,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// Verify this planet is the intended destination
|
|
498
|
+
if (
|
|
499
|
+
new URL(plan.destination_url).origin !==
|
|
500
|
+
new URL(localPlanet.landing_site).origin
|
|
501
|
+
) {
|
|
502
|
+
return new Response(JSON.stringify({ error: "not_our_destination" }), {
|
|
503
|
+
status: 422,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Verify travel time math (same check as handlePrepare)
|
|
508
|
+
const originCoords = TravelCalculator.calculateCoordinates(plan.origin_url);
|
|
509
|
+
const destCoords = TravelCalculator.calculateCoordinates(
|
|
510
|
+
plan.destination_url,
|
|
511
|
+
);
|
|
512
|
+
const dist = TravelCalculator.calculateDistance(originCoords, destCoords);
|
|
513
|
+
const expectedTime = TravelCalculator.calculateTravelTime(dist);
|
|
514
|
+
const actualTime = (plan.end_timestamp - plan.start_timestamp) / msPerFY();
|
|
515
|
+
if (Math.abs(actualTime - expectedTime) > 0.01) {
|
|
516
|
+
return new Response(JSON.stringify({ error: "invalid_travel_time" }), {
|
|
517
|
+
status: 422,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Anti-cheat: plan must not have already expired
|
|
522
|
+
if (Date.now() > plan.end_timestamp) {
|
|
523
|
+
return new Response(JSON.stringify({ error: "plan_expired" }), {
|
|
524
|
+
status: 422,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Require quorum before accepting the plan
|
|
529
|
+
if (!ConsensusEngine.hasQuorum(plan)) {
|
|
530
|
+
return new Response(JSON.stringify({ error: "insufficient_quorum" }), {
|
|
531
|
+
status: 422,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const alreadyStored = await DB.prepare(
|
|
536
|
+
`SELECT id FROM travel_plans WHERE id = ?`,
|
|
537
|
+
)
|
|
538
|
+
.bind(plan.id)
|
|
539
|
+
.first();
|
|
540
|
+
|
|
541
|
+
if (!alreadyStored) {
|
|
542
|
+
await DB.prepare(
|
|
543
|
+
`INSERT OR IGNORE INTO travel_plans (id, ship_id, origin_url, destination_url, start_timestamp, end_timestamp, status, signatures)
|
|
544
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
545
|
+
)
|
|
546
|
+
.bind(
|
|
547
|
+
plan.id,
|
|
548
|
+
plan.ship_id,
|
|
549
|
+
plan.origin_url,
|
|
550
|
+
plan.destination_url,
|
|
551
|
+
plan.start_timestamp,
|
|
552
|
+
plan.end_timestamp,
|
|
553
|
+
plan.status,
|
|
554
|
+
JSON.stringify(plan.signatures),
|
|
555
|
+
)
|
|
556
|
+
.run();
|
|
557
|
+
|
|
558
|
+
const fmt = (n: number) => n.toFixed(1);
|
|
559
|
+
const originCoordsFormatted = `${fmt(originCoords.x)}:${fmt(originCoords.y)}:${fmt(originCoords.z)}`;
|
|
560
|
+
await broadcastEvent(TRAFFIC_CONTROL, {
|
|
561
|
+
type: "INCOMING_REGISTERED",
|
|
562
|
+
planet: localPlanet.name,
|
|
563
|
+
ship_id: plan.ship_id,
|
|
564
|
+
plan_id: plan.id,
|
|
565
|
+
plan,
|
|
566
|
+
origin_coords_formatted: originCoordsFormatted,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function handleCommit(
|
|
574
|
+
request: Request,
|
|
575
|
+
KV: KVNamespace,
|
|
576
|
+
DB: D1Database,
|
|
577
|
+
localPlanet: any,
|
|
578
|
+
TRAFFIC_CONTROL: any,
|
|
579
|
+
) {
|
|
580
|
+
const incomingPlan = TravelPlanSchema.parse(await request.json());
|
|
581
|
+
const existing =
|
|
582
|
+
(await ConsensusEngine.getPlanState(KV, incomingPlan.id)) || incomingPlan;
|
|
583
|
+
|
|
584
|
+
console.log(
|
|
585
|
+
`[${localPlanet.name}] Committing travel plan ${incomingPlan.id} (Existing signatures: ${Object.keys(existing.signatures).length}, New signatures: ${Object.keys(incomingPlan.signatures).length})`,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
existing.signatures = { ...existing.signatures, ...incomingPlan.signatures };
|
|
589
|
+
await ConsensusEngine.savePlanState(KV, existing);
|
|
590
|
+
|
|
591
|
+
if (ConsensusEngine.hasQuorum(existing) && existing.status === "PREPARING") {
|
|
592
|
+
// Check if we already archived it to prevent race condition across TCs
|
|
593
|
+
const alreadyArchived = await DB.prepare(
|
|
594
|
+
`SELECT id FROM travel_plans WHERE id = ?`,
|
|
595
|
+
)
|
|
596
|
+
.bind(existing.id)
|
|
597
|
+
.first();
|
|
598
|
+
|
|
599
|
+
if (!alreadyArchived) {
|
|
600
|
+
console.log(
|
|
601
|
+
`[${localPlanet.name}] Quorum reached for plan ${existing.id}. Registering with destination.`,
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
existing.status = "PLAN_ACCEPTED";
|
|
605
|
+
await ConsensusEngine.savePlanState(KV, existing);
|
|
606
|
+
|
|
607
|
+
// Register with destination synchronously — must succeed before committing locally
|
|
608
|
+
const destManifest = await discoverSpacePort(
|
|
609
|
+
existing.destination_url,
|
|
610
|
+
DB,
|
|
611
|
+
);
|
|
612
|
+
if (!destManifest?.space_port) {
|
|
613
|
+
return new Response(
|
|
614
|
+
JSON.stringify({ error: "Destination space port not found" }),
|
|
615
|
+
{ status: 502 },
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const registerResp = await fetch(
|
|
620
|
+
`${destManifest.space_port}?action=register`,
|
|
621
|
+
{
|
|
622
|
+
method: "POST",
|
|
623
|
+
headers: {
|
|
624
|
+
"Content-Type": "application/json",
|
|
625
|
+
"X-Planet-Origin": localPlanet.landing_site,
|
|
626
|
+
},
|
|
627
|
+
body: JSON.stringify(existing),
|
|
628
|
+
},
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
if (!registerResp.ok) {
|
|
632
|
+
const body = await registerResp.text().catch(() => "");
|
|
633
|
+
console.warn(
|
|
634
|
+
`[${localPlanet.name}] Destination rejected plan registration: ${registerResp.status} ${body}`,
|
|
635
|
+
);
|
|
636
|
+
return new Response(
|
|
637
|
+
JSON.stringify({
|
|
638
|
+
error: "Destination rejected plan",
|
|
639
|
+
status: registerResp.status,
|
|
640
|
+
}),
|
|
641
|
+
{ status: 502 },
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
await DB.prepare(
|
|
646
|
+
`
|
|
647
|
+
INSERT OR IGNORE INTO travel_plans (id, ship_id, origin_url, destination_url, start_timestamp, end_timestamp, status, signatures)
|
|
648
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
649
|
+
`,
|
|
650
|
+
)
|
|
651
|
+
.bind(
|
|
652
|
+
existing.id,
|
|
653
|
+
existing.ship_id,
|
|
654
|
+
existing.origin_url,
|
|
655
|
+
existing.destination_url,
|
|
656
|
+
existing.start_timestamp,
|
|
657
|
+
existing.end_timestamp,
|
|
658
|
+
existing.status,
|
|
659
|
+
JSON.stringify(existing.signatures),
|
|
660
|
+
)
|
|
661
|
+
.run();
|
|
662
|
+
|
|
663
|
+
await broadcastEvent(TRAFFIC_CONTROL, {
|
|
664
|
+
type: "QUORUM_REACHED",
|
|
665
|
+
planet: localPlanet.name,
|
|
666
|
+
ship_id: existing.ship_id,
|
|
667
|
+
plan_id: existing.id,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
673
|
+
}
|