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,22 @@
|
|
|
1
|
+
-- Travel Plans Table: Tracks all journeys (active and historical)
|
|
2
|
+
CREATE TABLE IF NOT EXISTS travel_plans (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
ship_id TEXT NOT NULL,
|
|
5
|
+
origin_url TEXT NOT NULL,
|
|
6
|
+
destination_url TEXT NOT NULL,
|
|
7
|
+
start_timestamp INTEGER NOT NULL,
|
|
8
|
+
end_timestamp INTEGER NOT NULL,
|
|
9
|
+
status TEXT CHECK(status IN ('PREPARING', 'PLAN_ACCEPTED')) NOT NULL DEFAULT 'PREPARING',
|
|
10
|
+
signatures TEXT NOT NULL -- JSON array of cryptographic signatures
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
-- Index for fast retrieval of active plans (not yet arrived)
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_travel_plans_active ON travel_plans (end_timestamp, origin_url, destination_url);
|
|
15
|
+
|
|
16
|
+
-- Traffic Controllers Cache: Known neighbors with space ports
|
|
17
|
+
CREATE TABLE IF NOT EXISTS traffic_controllers (
|
|
18
|
+
planet_url TEXT PRIMARY KEY,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
space_port_url TEXT NOT NULL,
|
|
21
|
+
last_manifest_fetch INTEGER NOT NULL
|
|
22
|
+
);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-build script that patches dist/server/entry.mjs to export Durable Object
|
|
3
|
+
* classes required by Cloudflare Workers. Astro's build doesn't re-export these
|
|
4
|
+
* from the entrypoint, causing "Worker depends on Durable Objects not exported
|
|
5
|
+
* in entrypoint" errors.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, readdirSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
|
|
10
|
+
const serverDir = "dist/server";
|
|
11
|
+
const chunksDir = join(serverDir, "chunks");
|
|
12
|
+
const entryFile = join(serverDir, "entry.mjs");
|
|
13
|
+
|
|
14
|
+
// Map of DO class names to search for in chunks
|
|
15
|
+
const durableObjects = ["TrafficControl"];
|
|
16
|
+
|
|
17
|
+
for (const className of durableObjects) {
|
|
18
|
+
// Find the chunk containing this DO class
|
|
19
|
+
const chunks = readdirSync(chunksDir);
|
|
20
|
+
let targetChunk = null;
|
|
21
|
+
|
|
22
|
+
for (const chunk of chunks) {
|
|
23
|
+
if (!chunk.endsWith(".mjs")) continue;
|
|
24
|
+
const content = readFileSync(join(chunksDir, chunk), "utf-8");
|
|
25
|
+
if (content.includes(`class ${className} extends DurableObject`)) {
|
|
26
|
+
targetChunk = chunk;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!targetChunk) {
|
|
32
|
+
console.error(
|
|
33
|
+
`[inject-do-exports] Could not find ${className} in any chunk`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Add named export to the chunk file
|
|
39
|
+
const chunkPath = join(chunksDir, targetChunk);
|
|
40
|
+
let chunkContent = readFileSync(chunkPath, "utf-8");
|
|
41
|
+
if (!chunkContent.includes(`export { ${className}`)) {
|
|
42
|
+
chunkContent = chunkContent.replace(
|
|
43
|
+
/export \{\s*page\s*\};/,
|
|
44
|
+
`export {\n page,\n ${className}\n};`,
|
|
45
|
+
);
|
|
46
|
+
writeFileSync(chunkPath, chunkContent);
|
|
47
|
+
console.log(
|
|
48
|
+
`[inject-do-exports] Exported ${className} from chunks/${targetChunk}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Re-export from entry.mjs
|
|
53
|
+
let entryContent = readFileSync(entryFile, "utf-8");
|
|
54
|
+
if (!entryContent.includes(className)) {
|
|
55
|
+
entryContent += `export { ${className} } from './chunks/${targetChunk}';\n`;
|
|
56
|
+
writeFileSync(entryFile, entryContent);
|
|
57
|
+
console.log(`[inject-do-exports] Re-exported ${className} in entry.mjs`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log("[inject-do-exports] Done");
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { spawn, execSync } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
const NUM_PLANETS = 10;
|
|
8
|
+
const BASE_PORT = 3000;
|
|
9
|
+
const BASE_INSPECTOR_PORT = 19229;
|
|
10
|
+
const OPEN_BROWSER = process.argv.includes("--open");
|
|
11
|
+
|
|
12
|
+
const allPlanets = Array.from({ length: NUM_PLANETS }, (_, i) => ({
|
|
13
|
+
name: `Towel ${i + 1}`,
|
|
14
|
+
url: `http://towel-${i + 1}.localhost:${BASE_PORT + i}`,
|
|
15
|
+
port: BASE_PORT + i,
|
|
16
|
+
id: i + 1,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const processes = [];
|
|
20
|
+
|
|
21
|
+
const cleanup = () => {
|
|
22
|
+
console.log("Cleaning up existing processes...");
|
|
23
|
+
processes.forEach((p) => {
|
|
24
|
+
try {
|
|
25
|
+
p.kill();
|
|
26
|
+
} catch (e) {}
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
execSync("pkill -f 'wrangler dev' || true");
|
|
30
|
+
} catch (e) {}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const startPlanet = (planet) => {
|
|
34
|
+
const { id, name, url, port } = planet;
|
|
35
|
+
const inspectorPort = BASE_INSPECTOR_PORT + id;
|
|
36
|
+
|
|
37
|
+
// Initialize Database first
|
|
38
|
+
console.log(`[${name}] Initializing database...`);
|
|
39
|
+
execSync(
|
|
40
|
+
`npx wrangler d1 execute planet_db --file=schema.sql -c wrangler.dev.jsonc --local --persist-to=.wrangler/state/planet-${id}`,
|
|
41
|
+
{
|
|
42
|
+
cwd: path.join(__dirname, ".."),
|
|
43
|
+
stdio: "inherit",
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const env = {
|
|
48
|
+
...process.env,
|
|
49
|
+
PUBLIC_SIM_PLANET_NAME: name,
|
|
50
|
+
PUBLIC_SIM_LANDING_SITE: url,
|
|
51
|
+
PUBLIC_SIM_WARP_LINKS: JSON.stringify(
|
|
52
|
+
allPlanets
|
|
53
|
+
.filter((p) => p.url !== url)
|
|
54
|
+
.sort(() => 0.5 - Math.random())
|
|
55
|
+
.slice(0, 5)
|
|
56
|
+
.map((n) => ({ name: n.name, url: n.url })),
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const child = spawn(
|
|
61
|
+
"npx",
|
|
62
|
+
[
|
|
63
|
+
"wrangler",
|
|
64
|
+
"dev",
|
|
65
|
+
"--port",
|
|
66
|
+
port,
|
|
67
|
+
"--ip",
|
|
68
|
+
"0.0.0.0",
|
|
69
|
+
"--inspector-port",
|
|
70
|
+
inspectorPort,
|
|
71
|
+
"-c",
|
|
72
|
+
"wrangler.dev.jsonc",
|
|
73
|
+
"--persist-to",
|
|
74
|
+
`.wrangler/state/planet-${id}`,
|
|
75
|
+
"--var",
|
|
76
|
+
`PUBLIC_SIM_PLANET_NAME:"${name}"`,
|
|
77
|
+
"--var",
|
|
78
|
+
`PUBLIC_SIM_LANDING_SITE:"${url}"`,
|
|
79
|
+
"--var",
|
|
80
|
+
`PUBLIC_SIM_WARP_LINKS:'${env.PUBLIC_SIM_WARP_LINKS}'`,
|
|
81
|
+
],
|
|
82
|
+
{
|
|
83
|
+
cwd: path.join(__dirname, ".."),
|
|
84
|
+
env: process.env,
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
shell: true,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
processes.push(child);
|
|
91
|
+
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
let isReady = false;
|
|
94
|
+
const timeout = setTimeout(() => {
|
|
95
|
+
if (!isReady)
|
|
96
|
+
reject(new Error(`[${name}] Timed out waiting for readiness`));
|
|
97
|
+
}, 30000);
|
|
98
|
+
|
|
99
|
+
const handleData = (data) => {
|
|
100
|
+
const str = data.toString();
|
|
101
|
+
process.stdout.write(`[${name}] ${str}`);
|
|
102
|
+
if (str.includes("Ready on")) {
|
|
103
|
+
isReady = true;
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
resolve();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
child.stdout.on("data", handleData);
|
|
110
|
+
child.stderr.on("data", handleData);
|
|
111
|
+
child.on("error", reject);
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const run = async () => {
|
|
116
|
+
try {
|
|
117
|
+
cleanup();
|
|
118
|
+
console.log("Building project for wrangler...");
|
|
119
|
+
execSync("npm run build", {
|
|
120
|
+
cwd: path.join(__dirname, ".."),
|
|
121
|
+
stdio: "inherit",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
console.log(
|
|
125
|
+
`--- SIMULATING FEDERATED UNIVERSE (${NUM_PLANETS} PLANETS) ---`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Start planets sequentially to avoid overwhelming the system, but wait for readiness
|
|
129
|
+
for (const planet of allPlanets) {
|
|
130
|
+
await startPlanet(planet);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (OPEN_BROWSER) {
|
|
134
|
+
console.log("All planets are ready! Opening browser windows...");
|
|
135
|
+
const urlsToOpen = [
|
|
136
|
+
allPlanets[0].url, // First planet landing site
|
|
137
|
+
...allPlanets.map((p) => `${p.url}/control`), // All planets control centers
|
|
138
|
+
];
|
|
139
|
+
const urls = urlsToOpen.join(" ");
|
|
140
|
+
// On macOS, 'open' with multiple URLs opens them in tabs
|
|
141
|
+
execSync(`open ${urls}`);
|
|
142
|
+
} else {
|
|
143
|
+
console.log(`All planets are ready! (pass --open to launch browser)`);
|
|
144
|
+
allPlanets.forEach((p) => console.log(` ${p.name}: ${p.url}`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log("Simulation running. Press Ctrl+C to stop.");
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.error("Simulation failed to start:", e.message);
|
|
150
|
+
cleanup();
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
process.on("SIGINT", cleanup);
|
|
156
|
+
process.on("SIGTERM", cleanup);
|
|
157
|
+
|
|
158
|
+
run();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/// <reference types="astro/client" />
|
|
2
|
+
|
|
3
|
+
interface ImportMetaEnv {
|
|
4
|
+
readonly KV: import("@cloudflare/workers-types").KVNamespace;
|
|
5
|
+
readonly DB: import("@cloudflare/workers-types").D1Database;
|
|
6
|
+
readonly TRAFFIC_CONTROL: import("@cloudflare/workers-types").DurableObjectNamespace;
|
|
7
|
+
readonly PUBLIC_SIM_PLANET_NAME?: string;
|
|
8
|
+
readonly PUBLIC_SIM_PLANET_DESCRIPTION?: string;
|
|
9
|
+
readonly PUBLIC_SIM_LANDING_SITE?: string;
|
|
10
|
+
readonly PUBLIC_SIM_WARP_LINKS?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ImportMeta {
|
|
14
|
+
readonly env: ImportMetaEnv;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Runtime = import("@astrojs/cloudflare").Runtime<ImportMetaEnv>;
|
|
18
|
+
|
|
19
|
+
declare namespace App {
|
|
20
|
+
interface Locals extends Runtime {}
|
|
21
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Baked in at scaffold time by `npm create planet`
|
|
2
|
+
export const PLANET_NAME = "Towel 42 Space Outpost";
|
|
3
|
+
export const PLANET_DESCRIPTION =
|
|
4
|
+
"A remote outpost in the Federated Planets. A quiet spot for deep-space weary travelers. Don't panic, but bring a towel.";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_WARP_LINKS = [
|
|
7
|
+
{
|
|
8
|
+
name: "Federation Prime",
|
|
9
|
+
url: "https://prime.federatedplanets.com/",
|
|
10
|
+
},
|
|
11
|
+
{ name: "Waystation Meridian", url: "https://waystation.federatedplanets.com/" },
|
|
12
|
+
{ name: "The Interchange", url: "https://interchange.federatedplanets.com/" },
|
|
13
|
+
{ name: "Port Cassini", url: "https://port-cassini.federatedplanets.com/" },
|
|
14
|
+
{ name: "Terminus Reach", url: "https://terminus.federatedplanets.com/" },
|
|
15
|
+
{ name: "Driftyard Seven", url: "https://driftyard.federatedplanets.com/" },
|
|
16
|
+
{ name: "Towel 42 Space Outpost", url: "https://towel-42.federatedplanets.com/" },
|
|
17
|
+
{ name: "Explorers Outpost", url: "https://www.nasa.gov/" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
import { env as cloudflareEnv } from "cloudflare:workers";
|
|
21
|
+
|
|
22
|
+
// Robust helper to get simulation variables from any available environment source
|
|
23
|
+
const getSimVar = (name: string): string | undefined => {
|
|
24
|
+
// 1. Check Cloudflare env object (for wrangler dev --var)
|
|
25
|
+
const env = cloudflareEnv as any;
|
|
26
|
+
if (env && env[name]) return env[name];
|
|
27
|
+
|
|
28
|
+
// 2. Check process.env (traditional node/dev env)
|
|
29
|
+
if (typeof process !== "undefined" && process.env && process.env[name])
|
|
30
|
+
return process.env[name];
|
|
31
|
+
|
|
32
|
+
// 3. Check import.meta.env (build-time or astro-provided)
|
|
33
|
+
if (import.meta.env && import.meta.env[name]) return import.meta.env[name];
|
|
34
|
+
|
|
35
|
+
return undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const getWarpLinks = () => {
|
|
39
|
+
const landingSite = getSimVar("PUBLIC_SIM_LANDING_SITE")?.replace(/\/$/, "");
|
|
40
|
+
try {
|
|
41
|
+
const simLinks = getSimVar("PUBLIC_SIM_WARP_LINKS");
|
|
42
|
+
if (simLinks) {
|
|
43
|
+
return JSON.parse(simLinks);
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {}
|
|
46
|
+
if (!landingSite) return DEFAULT_WARP_LINKS;
|
|
47
|
+
return DEFAULT_WARP_LINKS.filter(
|
|
48
|
+
(l) => l.url.replace(/\/$/, "") !== landingSite,
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const WARP_LINKS = getWarpLinks();
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { CryptoCore } from "./crypto";
|
|
2
|
+
import type { PlanetManifest } from "./travel";
|
|
3
|
+
import { env as cloudflareEnv } from "cloudflare:workers";
|
|
4
|
+
import { PLANET_NAME } from "./config";
|
|
5
|
+
|
|
6
|
+
// Robust helper to get simulation variables from any available environment source
|
|
7
|
+
const getSimVar = (name: string): string | undefined => {
|
|
8
|
+
// 1. Check Cloudflare env object (for wrangler dev --var)
|
|
9
|
+
const env = cloudflareEnv as any;
|
|
10
|
+
if (env && env[name]) return env[name];
|
|
11
|
+
|
|
12
|
+
// 2. Check process.env (traditional node/dev env)
|
|
13
|
+
if (
|
|
14
|
+
typeof process !== "undefined" &&
|
|
15
|
+
process.env &&
|
|
16
|
+
(process.env as any)[name]
|
|
17
|
+
)
|
|
18
|
+
return (process.env as any)[name];
|
|
19
|
+
|
|
20
|
+
// 3. Check import.meta.env (build-time or astro-provided)
|
|
21
|
+
if (import.meta.env && (import.meta.env as any)[name])
|
|
22
|
+
return (import.meta.env as any)[name];
|
|
23
|
+
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface TravelPlan {
|
|
28
|
+
id: string;
|
|
29
|
+
ship_id: string;
|
|
30
|
+
origin_url: string;
|
|
31
|
+
destination_url: string;
|
|
32
|
+
start_timestamp: number;
|
|
33
|
+
end_timestamp: number;
|
|
34
|
+
status: "PREPARING" | "PLAN_ACCEPTED";
|
|
35
|
+
traffic_controllers: string[]; // List of landing site URLs elected
|
|
36
|
+
signatures: Record<string, string>; // planet_url -> signature
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ConsensusEngine {
|
|
40
|
+
/**
|
|
41
|
+
* Broadcasts a message to all elected Traffic Controllers
|
|
42
|
+
*/
|
|
43
|
+
static async broadcast(
|
|
44
|
+
plan: TravelPlan,
|
|
45
|
+
action: "prepare" | "commit",
|
|
46
|
+
controllers: PlanetManifest[],
|
|
47
|
+
) {
|
|
48
|
+
const localName = getSimVar("PUBLIC_SIM_PLANET_NAME") || PLANET_NAME;
|
|
49
|
+
console.log(
|
|
50
|
+
`[${localName}] Broadcasting ${action} for plan ${plan.id} to ${controllers.length} controllers`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const promises = controllers.map(async (tc) => {
|
|
54
|
+
if (!tc.space_port) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`[${localName}] Skipping broadcast to ${tc.landing_site}: No space_port found in manifest`,
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const localUrl =
|
|
62
|
+
getSimVar("PUBLIC_SIM_LANDING_SITE") || "http://unknown";
|
|
63
|
+
console.log(`[${localName}] Sending ${action} to ${tc.space_port}...`);
|
|
64
|
+
const res = await fetch(`${tc.space_port}?action=${action}`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"X-Planet-Origin": localUrl,
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify(plan),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
console.error(
|
|
74
|
+
`[${localName}] Broadcast ${action} to ${tc.landing_site} failed: ${res.status} ${await res.text()}`,
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
console.log(
|
|
78
|
+
`[${localName}] Broadcast ${action} to ${tc.landing_site} succeeded`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
} catch (e: any) {
|
|
82
|
+
console.error(
|
|
83
|
+
`[${localName}] Failed to broadcast ${action} to ${tc.landing_site}:`,
|
|
84
|
+
e.message,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return Promise.all(promises);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Checks if we have enough signatures for consensus
|
|
93
|
+
* N = 3f + 1, we need 2f + 1 signatures
|
|
94
|
+
*/
|
|
95
|
+
static hasQuorum(plan: TravelPlan): boolean {
|
|
96
|
+
const localName = getSimVar("PUBLIC_SIM_PLANET_NAME") || PLANET_NAME;
|
|
97
|
+
const n = plan.traffic_controllers.length;
|
|
98
|
+
const f = Math.floor((n - 1) / 3);
|
|
99
|
+
const required = 2 * f + 1;
|
|
100
|
+
const current = Object.keys(plan.signatures).length;
|
|
101
|
+
|
|
102
|
+
console.log(
|
|
103
|
+
`[${localName}] Quorum check for plan ${plan.id}: ${current}/${required} signatures (N=${n}, f=${f})`,
|
|
104
|
+
);
|
|
105
|
+
return current >= required;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Saves plan state to KV for active consensus tracking
|
|
110
|
+
*/
|
|
111
|
+
static async savePlanState(KV: KVNamespace, plan: TravelPlan) {
|
|
112
|
+
await KV.put(`consensus_plan_${plan.id}`, JSON.stringify(plan), {
|
|
113
|
+
expirationTtl: 3600,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Retrieves plan state from KV
|
|
119
|
+
*/
|
|
120
|
+
static async getPlanState(
|
|
121
|
+
KV: KVNamespace,
|
|
122
|
+
planId: string,
|
|
123
|
+
): Promise<TravelPlan | null> {
|
|
124
|
+
const data = await KV.get(`consensus_plan_${planId}`);
|
|
125
|
+
return data ? JSON.parse(data) : null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptography Utility for Space Travel Protocol
|
|
3
|
+
* Uses Web Crypto API (Ed25519) for signing and verification.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class CryptoCore {
|
|
7
|
+
private static KEY_ALGO = {
|
|
8
|
+
name: "Ed25519",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generates a new Ed25519 KeyPair
|
|
13
|
+
*/
|
|
14
|
+
static async generateKeyPair(): Promise<CryptoKeyPair> {
|
|
15
|
+
return (await crypto.subtle.generateKey(this.KEY_ALGO, true, [
|
|
16
|
+
"sign",
|
|
17
|
+
"verify",
|
|
18
|
+
])) as CryptoKeyPair;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Exports a key to Base64 format
|
|
23
|
+
*/
|
|
24
|
+
static async exportKey(key: CryptoKey): Promise<string> {
|
|
25
|
+
const exported = await crypto.subtle.exportKey(
|
|
26
|
+
key.type === "public" ? "spki" : "pkcs8",
|
|
27
|
+
key,
|
|
28
|
+
);
|
|
29
|
+
return btoa(String.fromCharCode(...new Uint8Array(exported)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Imports a key from Base64 format
|
|
34
|
+
*/
|
|
35
|
+
static async importKey(
|
|
36
|
+
base64Key: string,
|
|
37
|
+
type: "public" | "private",
|
|
38
|
+
): Promise<CryptoKey> {
|
|
39
|
+
const binary = atob(base64Key);
|
|
40
|
+
const bytes = new Uint8Array(binary.length);
|
|
41
|
+
for (let i = 0; i < binary.length; i++) {
|
|
42
|
+
bytes[i] = binary.charCodeAt(i);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return await crypto.subtle.importKey(
|
|
46
|
+
type === "public" ? "spki" : "pkcs8",
|
|
47
|
+
bytes,
|
|
48
|
+
this.KEY_ALGO,
|
|
49
|
+
true,
|
|
50
|
+
type === "public" ? ["verify"] : ["sign"],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Signs data string using a private key
|
|
56
|
+
*/
|
|
57
|
+
static async sign(data: string, privateKey: CryptoKey): Promise<string> {
|
|
58
|
+
const encoder = new TextEncoder();
|
|
59
|
+
const signature = await crypto.subtle.sign(
|
|
60
|
+
this.KEY_ALGO,
|
|
61
|
+
privateKey,
|
|
62
|
+
encoder.encode(data),
|
|
63
|
+
);
|
|
64
|
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Verifies signature for data string using a public key
|
|
69
|
+
*/
|
|
70
|
+
static async verify(
|
|
71
|
+
data: string,
|
|
72
|
+
signatureBase64: string,
|
|
73
|
+
publicKey: CryptoKey,
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
const encoder = new TextEncoder();
|
|
76
|
+
const signatureBinary = atob(signatureBase64);
|
|
77
|
+
const signatureBytes = new Uint8Array(signatureBinary.length);
|
|
78
|
+
for (let i = 0; i < signatureBinary.length; i++) {
|
|
79
|
+
signatureBytes[i] = signatureBinary.charCodeAt(i);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return await crypto.subtle.verify(
|
|
83
|
+
this.KEY_ALGO,
|
|
84
|
+
publicKey,
|
|
85
|
+
signatureBytes,
|
|
86
|
+
encoder.encode(data),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CryptoCore } from "./crypto";
|
|
2
|
+
|
|
3
|
+
export class PlanetIdentity {
|
|
4
|
+
/**
|
|
5
|
+
* Retrieves or generates the planet's Ed25519 identity keys from KV.
|
|
6
|
+
*/
|
|
7
|
+
static async getIdentity(KV: KVNamespace): Promise<{
|
|
8
|
+
publicKey: CryptoKey;
|
|
9
|
+
privateKey: CryptoKey;
|
|
10
|
+
publicKeyBase64: string;
|
|
11
|
+
}> {
|
|
12
|
+
const existingPublic = await KV.get("identity_public");
|
|
13
|
+
const existingPrivate = await KV.get("identity_private");
|
|
14
|
+
|
|
15
|
+
if (existingPublic && existingPrivate) {
|
|
16
|
+
const publicKey = await CryptoCore.importKey(existingPublic, "public");
|
|
17
|
+
const privateKey = await CryptoCore.importKey(existingPrivate, "private");
|
|
18
|
+
return { publicKey, privateKey, publicKeyBase64: existingPublic };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Generate new if not found
|
|
22
|
+
const keyPair = await CryptoCore.generateKeyPair();
|
|
23
|
+
const publicB64 = await CryptoCore.exportKey(keyPair.publicKey);
|
|
24
|
+
const privateB64 = await CryptoCore.exportKey(keyPair.privateKey);
|
|
25
|
+
|
|
26
|
+
await KV.put("identity_public", publicB64);
|
|
27
|
+
await KV.put("identity_private", privateB64);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
publicKey: keyPair.publicKey,
|
|
31
|
+
privateKey: keyPair.privateKey,
|
|
32
|
+
publicKeyBase64: publicB64,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Travel Logic for Federated Planets
|
|
3
|
+
* Handles distance, time, and controller election.
|
|
4
|
+
*/
|
|
5
|
+
import md5 from "md5";
|
|
6
|
+
|
|
7
|
+
export interface Coordinates {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
z: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PlanetManifest {
|
|
14
|
+
name: string;
|
|
15
|
+
landing_site: string;
|
|
16
|
+
space_port?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TravelCalculator {
|
|
20
|
+
/**
|
|
21
|
+
* Calculates 3D coordinates from a URL deterministically
|
|
22
|
+
*/
|
|
23
|
+
static calculateCoordinates(url: string): Coordinates {
|
|
24
|
+
const domain = new URL(url).hostname.toLowerCase();
|
|
25
|
+
const hash = md5(domain);
|
|
26
|
+
|
|
27
|
+
const xHex = hash.slice(0, 6);
|
|
28
|
+
const yHex = hash.slice(6, 12);
|
|
29
|
+
const zHex = hash.slice(12, 18);
|
|
30
|
+
|
|
31
|
+
const x = (parseInt(xHex, 16) % 100000) / 100;
|
|
32
|
+
const y = (parseInt(yHex, 16) % 100000) / 100;
|
|
33
|
+
const z = (parseInt(zHex, 16) % 100000) / 100;
|
|
34
|
+
|
|
35
|
+
return { x, y, z };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Calculates 3D Euclidean distance between two points
|
|
40
|
+
*/
|
|
41
|
+
static calculateDistance(p1: Coordinates, p2: Coordinates): number {
|
|
42
|
+
return Math.sqrt(
|
|
43
|
+
Math.pow(p2.x - p1.x, 2) +
|
|
44
|
+
Math.pow(p2.y - p1.y, 2) +
|
|
45
|
+
Math.pow(p2.z - p1.z, 2),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Calculates travel time in Earth hours (Flight-Years)
|
|
51
|
+
* 1 FY per 100 sparsecs.
|
|
52
|
+
*/
|
|
53
|
+
static calculateTravelTime(distance: number): number {
|
|
54
|
+
return distance / 100;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Elects Traffic Controllers based on proximity sortition
|
|
59
|
+
*/
|
|
60
|
+
static electControllers(
|
|
61
|
+
seed: string,
|
|
62
|
+
eligibleNeighbors: PlanetManifest[],
|
|
63
|
+
n: number = 4, // Default 3f + 1 where f=1
|
|
64
|
+
): PlanetManifest[] {
|
|
65
|
+
const scored = eligibleNeighbors.map((neighbor) => {
|
|
66
|
+
const score = md5(seed + neighbor.landing_site);
|
|
67
|
+
return { neighbor, score };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Deterministic sort by score
|
|
71
|
+
scored.sort((a, b) => a.score.localeCompare(b.score));
|
|
72
|
+
|
|
73
|
+
return scored.slice(0, n).map((s) => s.neighbor);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { env } from "cloudflare:workers";
|
|
3
|
+
import TrafficControl from "../../../traffic-control";
|
|
4
|
+
export { TrafficControl };
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async ({ request }) => {
|
|
7
|
+
const { TRAFFIC_CONTROL } = env as any;
|
|
8
|
+
|
|
9
|
+
if (!TRAFFIC_CONTROL) {
|
|
10
|
+
return new Response("Durable Object binding not found", { status: 500 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isWebSocket = request.headers.get("Upgrade") === "websocket";
|
|
14
|
+
const id = TRAFFIC_CONTROL.idFromName("global");
|
|
15
|
+
const obj = TRAFFIC_CONTROL.get(id);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
return await obj.fetch(request);
|
|
19
|
+
} catch (e: any) {
|
|
20
|
+
return new Response(`DO fetch error: ${e.message}`, { status: 500 });
|
|
21
|
+
}
|
|
22
|
+
};
|