miki-moni 0.3.1
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 +21 -0
- package/README.md +283 -0
- package/README.zh-CN.md +275 -0
- package/README.zh-TW.md +275 -0
- package/bin/miki.mjs +49 -0
- package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
- package/dist/web/assets/index--89DkyV1.css +1 -0
- package/dist/web/assets/index-CyPlxvOn.js +64 -0
- package/dist/web/index.html +20 -0
- package/dist/web/pair-info.html +138 -0
- package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
- package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
- package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
- package/dist/web-phone/index.html +20 -0
- package/hooks/miki-emit.ps1 +56 -0
- package/package.json +89 -0
- package/shared/i18n.ts +915 -0
- package/src/cli/i18n-cli.ts +149 -0
- package/src/cli/miki.ts +168 -0
- package/src/cli/pair.ts +534 -0
- package/src/cli/prompt.ts +6 -0
- package/src/cli/pushable-iter.ts +45 -0
- package/src/cli/setup-self-host.ts +292 -0
- package/src/cli/setup-wizard.ts +130 -0
- package/src/cli/wrap.ts +742 -0
- package/src/config.ts +121 -0
- package/src/crypto.ts +66 -0
- package/src/data-dir.ts +31 -0
- package/src/ext-registry.ts +47 -0
- package/src/hook-handler.ts +86 -0
- package/src/index.ts +279 -0
- package/src/install-hooks.ts +107 -0
- package/src/notifier.ts +21 -0
- package/src/pairing.ts +100 -0
- package/src/protocol-ext.ts +46 -0
- package/src/relay-client.ts +468 -0
- package/src/relay-protocol.ts +57 -0
- package/src/server.ts +1134 -0
- package/src/session-resolver.ts +437 -0
- package/src/session-store.ts +131 -0
- package/src/types.ts +33 -0
- package/src/vscode-bridge.ts +407 -0
- package/src/wrap-process.ts +183 -0
- package/tools/tray.ps1 +286 -0
- package/worker/package.json +24 -0
- package/worker/src/daemon-relay.ts +348 -0
- package/worker/src/env.ts +11 -0
- package/worker/src/handshake.ts +63 -0
- package/worker/src/index.ts +81 -0
- package/worker/src/pairing-code.ts +39 -0
- package/worker/src/pairing-coordinator.ts +145 -0
- package/worker/wrangler-selfhost.toml +36 -0
- package/worker/wrangler.toml +29 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
|
|
3
|
+
export const CHALLENGE_TTL_MS = 10_000; // 10s — daemon must respond fast
|
|
4
|
+
|
|
5
|
+
export interface Challenge {
|
|
6
|
+
nonce: Uint8Array; // 32 random bytes
|
|
7
|
+
issued_at_ms: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Generate a fresh challenge for the daemon to sign. */
|
|
11
|
+
export function generateChallenge(): Challenge {
|
|
12
|
+
return {
|
|
13
|
+
nonce: crypto.getRandomValues(new Uint8Array(32)),
|
|
14
|
+
issued_at_ms: Date.now(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the bytes the client is expected to sign: nonce (32B) ++ issued_at_ms (8B big-endian).
|
|
20
|
+
* Deterministic — both sides must compute the same bytes.
|
|
21
|
+
*/
|
|
22
|
+
export function buildChallengeMessage(nonce: Uint8Array, issued_at_ms: number): Uint8Array {
|
|
23
|
+
const out = new Uint8Array(32 + 8);
|
|
24
|
+
out.set(nonce, 0);
|
|
25
|
+
// 8-byte big-endian timestamp
|
|
26
|
+
const view = new DataView(out.buffer, 32, 8);
|
|
27
|
+
view.setBigUint64(0, BigInt(issued_at_ms), false);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Verify a challenge response. Returns true iff sig is valid AND challenge not expired. */
|
|
32
|
+
export function verifyChallengeResponse(
|
|
33
|
+
challenge: Challenge,
|
|
34
|
+
sig: Uint8Array,
|
|
35
|
+
pubkey: Uint8Array,
|
|
36
|
+
now_ms: number,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (now_ms > challenge.issued_at_ms + CHALLENGE_TTL_MS) return false;
|
|
39
|
+
const msg = buildChallengeMessage(challenge.nonce, challenge.issued_at_ms);
|
|
40
|
+
return nacl.sign.detached.verify(msg, sig, pubkey);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** daemon_id = first 16 bytes of SHA-256(signing_pubkey), hex-encoded (32 chars). */
|
|
44
|
+
export async function deriveDaemonId(signing_pubkey: Uint8Array): Promise<string> {
|
|
45
|
+
const hash = await crypto.subtle.digest("SHA-256", signing_pubkey);
|
|
46
|
+
const bytes = new Uint8Array(hash).slice(0, 16);
|
|
47
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Base64 helpers (workerd has atob/btoa but Uint8Array helpers are clearer) ──
|
|
51
|
+
|
|
52
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
53
|
+
let s = "";
|
|
54
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
55
|
+
return btoa(s);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function fromBase64(s: string): Uint8Array {
|
|
59
|
+
const bin = atob(s);
|
|
60
|
+
const out = new Uint8Array(bin.length);
|
|
61
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { PairingCoordinator } from "./pairing-coordinator.js";
|
|
2
|
+
import { DaemonRelay } from "./daemon-relay.js";
|
|
3
|
+
import { deriveDaemonId, fromBase64 } from "./handshake.js";
|
|
4
|
+
import type { Env } from "./env.js";
|
|
5
|
+
|
|
6
|
+
export { PairingCoordinator, DaemonRelay };
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
10
|
+
const url = new URL(req.url);
|
|
11
|
+
|
|
12
|
+
if (url.pathname === "/v1/health") {
|
|
13
|
+
return new Response("ok", { status: 200 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Per-IP rate limit (skip in tests where RATE_LIMITER returns success synthetically)
|
|
17
|
+
if (env.RATE_LIMITER && (url.pathname === "/v1/daemon" || url.pathname === "/v1/phone")) {
|
|
18
|
+
const ip = req.headers.get("CF-Connecting-IP") ?? "test-ip";
|
|
19
|
+
try {
|
|
20
|
+
const { success } = await env.RATE_LIMITER.limit({ key: ip });
|
|
21
|
+
if (!success) return new Response("rate limited", { status: 429 });
|
|
22
|
+
} catch { /* binding may be unavailable in some test envs */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (url.pathname === "/v1/daemon") {
|
|
26
|
+
if (req.headers.get("Upgrade") !== "websocket") {
|
|
27
|
+
return new Response("expected websocket", { status: 426 });
|
|
28
|
+
}
|
|
29
|
+
const pubkey_b64 = req.headers.get("X-Daemon-Pubkey");
|
|
30
|
+
if (!pubkey_b64) return new Response("missing X-Daemon-Pubkey", { status: 400 });
|
|
31
|
+
let pubkey: Uint8Array;
|
|
32
|
+
try {
|
|
33
|
+
pubkey = fromBase64(pubkey_b64);
|
|
34
|
+
if (pubkey.length !== 32) throw new Error("bad length");
|
|
35
|
+
} catch {
|
|
36
|
+
return new Response("bad X-Daemon-Pubkey", { status: 400 });
|
|
37
|
+
}
|
|
38
|
+
const daemon_id = await deriveDaemonId(pubkey);
|
|
39
|
+
const id = env.RELAY.idFromName(daemon_id);
|
|
40
|
+
const stub = env.RELAY.get(id);
|
|
41
|
+
return stub.fetch(req);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url.pathname === "/v1/phone") {
|
|
45
|
+
if (req.headers.get("Upgrade") !== "websocket") {
|
|
46
|
+
return new Response("expected websocket", { status: 426 });
|
|
47
|
+
}
|
|
48
|
+
// Browsers can't set custom headers on WebSocket; accept URL-query fallback.
|
|
49
|
+
const pairing_token = req.headers.get("X-Pairing-Token") ?? url.searchParams.get("token");
|
|
50
|
+
const daemon_id_hdr = req.headers.get("X-Daemon-Id") ?? url.searchParams.get("daemon_id");
|
|
51
|
+
|
|
52
|
+
let target_daemon_id: string | null = null;
|
|
53
|
+
if (pairing_token) {
|
|
54
|
+
const coordId = env.PAIRING.idFromName("coordinator");
|
|
55
|
+
const coordStub = env.PAIRING.get(coordId);
|
|
56
|
+
const claimRes = await coordStub.fetch(new Request("https://x/claim", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
body: JSON.stringify({ token: pairing_token }),
|
|
59
|
+
headers: { "content-type": "application/json" },
|
|
60
|
+
}));
|
|
61
|
+
const claim = await claimRes.json() as { ok: boolean; daemon_id?: string; reason?: string };
|
|
62
|
+
if (!claim.ok || !claim.daemon_id) {
|
|
63
|
+
return new Response("invalid_pairing_token", { status: 404 });
|
|
64
|
+
}
|
|
65
|
+
target_daemon_id = claim.daemon_id;
|
|
66
|
+
} else if (daemon_id_hdr) {
|
|
67
|
+
target_daemon_id = daemon_id_hdr;
|
|
68
|
+
} else {
|
|
69
|
+
return new Response("missing X-Pairing-Token or X-Daemon-Id", { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Forward the request to the DO. The DO also reads from query/header for downstream
|
|
73
|
+
// X-Phone-Pubkey / X-Sig in reconnect mode (modified in daemon-relay.ts).
|
|
74
|
+
const id = env.RELAY.idFromName(target_daemon_id);
|
|
75
|
+
const stub = env.RELAY.get(id);
|
|
76
|
+
return stub.fetch(req);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return new Response("not_found", { status: 404 });
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Crockford base32 alphabet — no 0/O/1/I/L (visually unambiguous).
|
|
2
|
+
// 32 characters: 2-9 (no 0,1) plus A-Z minus I,L,O.
|
|
3
|
+
export const PAIRING_CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
|
4
|
+
|
|
5
|
+
export const PAIRING_CODE_LENGTH = 16;
|
|
6
|
+
|
|
7
|
+
/** Generate a fresh random 16-char pairing code. ~76 bits entropy (log2(32^16)). */
|
|
8
|
+
export function generatePairingCode(): string {
|
|
9
|
+
const bytes = new Uint8Array(PAIRING_CODE_LENGTH);
|
|
10
|
+
crypto.getRandomValues(bytes);
|
|
11
|
+
let code = "";
|
|
12
|
+
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
|
|
13
|
+
code += PAIRING_CODE_ALPHABET[bytes[i]! % PAIRING_CODE_ALPHABET.length];
|
|
14
|
+
}
|
|
15
|
+
return code;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Strip hyphens + whitespace, uppercase. */
|
|
19
|
+
export function normalizePairingCode(input: string): string {
|
|
20
|
+
return input.replace(/[\s-]+/g, "").toUpperCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Insert hyphens every 4 chars for display (XXXX-XXXX-XXXX-XXXX). */
|
|
24
|
+
export function formatPairingCode(normalized: string): string {
|
|
25
|
+
const groups: string[] = [];
|
|
26
|
+
for (let i = 0; i < normalized.length; i += 4) {
|
|
27
|
+
groups.push(normalized.slice(i, i + 4));
|
|
28
|
+
}
|
|
29
|
+
return groups.join("-");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True iff input is exactly 16 chars from our alphabet (no hyphens, uppercase). */
|
|
33
|
+
export function isValidPairingCode(input: string): boolean {
|
|
34
|
+
if (input.length !== PAIRING_CODE_LENGTH) return false;
|
|
35
|
+
for (const ch of input) {
|
|
36
|
+
if (!PAIRING_CODE_ALPHABET.includes(ch)) return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Env } from "./env.js";
|
|
2
|
+
|
|
3
|
+
export const PAIRING_TTL_MS = 10 * 60 * 1000; // 10 min
|
|
4
|
+
const REGISTER_RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
5
|
+
const REGISTER_RATE_LIMIT = 100;
|
|
6
|
+
const ALARM_INTERVAL_MS = 60_000; // sweep every 60s
|
|
7
|
+
|
|
8
|
+
interface PendingEntry {
|
|
9
|
+
daemon_id: string;
|
|
10
|
+
expires_at_ms: number;
|
|
11
|
+
/** Persistent tokens never expire (sweep skips them) and are not consumed on
|
|
12
|
+
* claim — multiple phones can use the same QR over time, until the user
|
|
13
|
+
* rotates via `pnpm pair --rotate`. Default false = legacy ephemeral. */
|
|
14
|
+
persistent?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RateEntry {
|
|
18
|
+
count: number;
|
|
19
|
+
window_started_at_ms: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class PairingCoordinator implements DurableObject {
|
|
23
|
+
private pending = new Map<string, PendingEntry>();
|
|
24
|
+
private rateLimits = new Map<string, RateEntry>();
|
|
25
|
+
|
|
26
|
+
constructor(private state: DurableObjectState, private env: Env) {
|
|
27
|
+
this.state.blockConcurrencyWhile(async () => {
|
|
28
|
+
const stored = await this.state.storage.get<Map<string, PendingEntry>>("pending");
|
|
29
|
+
if (stored) this.pending = stored;
|
|
30
|
+
const rates = await this.state.storage.get<Map<string, RateEntry>>("rates");
|
|
31
|
+
if (rates) this.rateLimits = rates;
|
|
32
|
+
const next = await this.state.storage.getAlarm();
|
|
33
|
+
if (!next) await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async fetch(req: Request): Promise<Response> {
|
|
38
|
+
const url = new URL(req.url);
|
|
39
|
+
const path = url.pathname.replace(/^\//, "");
|
|
40
|
+
if (req.method !== "POST") return new Response("method", { status: 405 });
|
|
41
|
+
const body = await req.json() as Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
if (path === "register") {
|
|
44
|
+
const token = String(body.token ?? "");
|
|
45
|
+
const daemon_id = String(body.daemon_id ?? "");
|
|
46
|
+
const persistent = body.persistent === true;
|
|
47
|
+
return this.json(await this.register(token, daemon_id, persistent));
|
|
48
|
+
}
|
|
49
|
+
if (path === "claim") {
|
|
50
|
+
const token = String(body.token ?? "");
|
|
51
|
+
return this.json(await this.claim(token));
|
|
52
|
+
}
|
|
53
|
+
if (path === "revoke") {
|
|
54
|
+
const token = String(body.token ?? "");
|
|
55
|
+
await this.revoke(token);
|
|
56
|
+
return this.json({ ok: true });
|
|
57
|
+
}
|
|
58
|
+
return new Response("not_found", { status: 404 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private json(o: unknown): Response {
|
|
62
|
+
return new Response(JSON.stringify(o), { headers: { "content-type": "application/json" } });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async register(
|
|
66
|
+
token: string,
|
|
67
|
+
daemon_id: string,
|
|
68
|
+
persistent: boolean = false,
|
|
69
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
70
|
+
if (!token || !daemon_id) return { ok: false, reason: "bad_input" };
|
|
71
|
+
if (this.pending.size > 10000) return { ok: false, reason: "coordinator_full" };
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const r = this.rateLimits.get(daemon_id);
|
|
75
|
+
if (r && now - r.window_started_at_ms < REGISTER_RATE_WINDOW_MS) {
|
|
76
|
+
if (r.count >= REGISTER_RATE_LIMIT) return { ok: false, reason: "rate_limited" };
|
|
77
|
+
r.count++;
|
|
78
|
+
} else {
|
|
79
|
+
this.rateLimits.set(daemon_id, { count: 1, window_started_at_ms: now });
|
|
80
|
+
}
|
|
81
|
+
await this.state.storage.put("rates", this.rateLimits);
|
|
82
|
+
|
|
83
|
+
// Persistent: never expires. Sentinel via Number.MAX_SAFE_INTEGER so the
|
|
84
|
+
// existing `Date.now() > expires_at_ms` checks naturally fail.
|
|
85
|
+
const expires_at_ms = persistent ? Number.MAX_SAFE_INTEGER : now + PAIRING_TTL_MS;
|
|
86
|
+
this.pending.set(token, { daemon_id, expires_at_ms, persistent: persistent || undefined });
|
|
87
|
+
await this.state.storage.put("pending", this.pending);
|
|
88
|
+
return { ok: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async claim(token: string): Promise<{ ok: true; daemon_id: string } | { ok: false; reason: string }> {
|
|
92
|
+
const entry = this.pending.get(token);
|
|
93
|
+
if (!entry) return { ok: false, reason: "unknown" };
|
|
94
|
+
if (Date.now() > entry.expires_at_ms) {
|
|
95
|
+
this.pending.delete(token);
|
|
96
|
+
await this.state.storage.put("pending", this.pending);
|
|
97
|
+
return { ok: false, reason: "expired" };
|
|
98
|
+
}
|
|
99
|
+
// Persistent tokens stay in the map — same QR keeps working for the next
|
|
100
|
+
// device. Ephemeral tokens are consumed once.
|
|
101
|
+
if (!entry.persistent) {
|
|
102
|
+
this.pending.delete(token);
|
|
103
|
+
await this.state.storage.put("pending", this.pending);
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, daemon_id: entry.daemon_id };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async revoke(token: string): Promise<void> {
|
|
109
|
+
if (this.pending.delete(token)) {
|
|
110
|
+
await this.state.storage.put("pending", this.pending);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Test-only — inserts a token bypassing rate limit. Not used in production paths. */
|
|
115
|
+
async _test_insert(token: string, daemon_id: string, expires_at_ms: number): Promise<void> {
|
|
116
|
+
this.pending.set(token, { daemon_id, expires_at_ms });
|
|
117
|
+
await this.state.storage.put("pending", this.pending);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async alarm(): Promise<void> {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
let changed = false;
|
|
123
|
+
for (const [token, entry] of this.pending) {
|
|
124
|
+
// Persistent tokens never expire (they have expires_at_ms set to
|
|
125
|
+
// Number.MAX_SAFE_INTEGER, but check the flag too in case of legacy data).
|
|
126
|
+
if (entry.persistent) continue;
|
|
127
|
+
if (entry.expires_at_ms < now) {
|
|
128
|
+
this.pending.delete(token);
|
|
129
|
+
changed = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (changed) await this.state.storage.put("pending", this.pending);
|
|
133
|
+
|
|
134
|
+
let rateChanged = false;
|
|
135
|
+
for (const [did, r] of this.rateLimits) {
|
|
136
|
+
if (now - r.window_started_at_ms > REGISTER_RATE_WINDOW_MS) {
|
|
137
|
+
this.rateLimits.delete(did);
|
|
138
|
+
rateChanged = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (rateChanged) await this.state.storage.put("rates", this.rateLimits);
|
|
142
|
+
|
|
143
|
+
await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Self-host wrangler config — used by `miki setup` self-host wizard.
|
|
2
|
+
#
|
|
3
|
+
# Same as wrangler.toml, but WITHOUT the routes/custom_domain block. Self-host
|
|
4
|
+
# users get the default <worker-name>.<account>.workers.dev URL; they're free
|
|
5
|
+
# to attach their own custom domain via the CF dashboard afterwards.
|
|
6
|
+
#
|
|
7
|
+
# DO NOT add a routes block here — it would attach to whichever domain you
|
|
8
|
+
# write and risk hijacking the author's hosted instance if the user happens
|
|
9
|
+
# to share a CF account.
|
|
10
|
+
|
|
11
|
+
name = "miki-relay"
|
|
12
|
+
main = "src/index.ts"
|
|
13
|
+
compatibility_date = "2025-01-01"
|
|
14
|
+
compatibility_flags = ["nodejs_compat"]
|
|
15
|
+
|
|
16
|
+
# Explicitly enable workers.dev URL so wrangler prints it after deploy.
|
|
17
|
+
workers_dev = true
|
|
18
|
+
|
|
19
|
+
[[durable_objects.bindings]]
|
|
20
|
+
name = "PAIRING"
|
|
21
|
+
class_name = "PairingCoordinator"
|
|
22
|
+
|
|
23
|
+
[[durable_objects.bindings]]
|
|
24
|
+
name = "RELAY"
|
|
25
|
+
class_name = "DaemonRelay"
|
|
26
|
+
|
|
27
|
+
[[migrations]]
|
|
28
|
+
tag = "v1"
|
|
29
|
+
# Free tier requires new_sqlite_classes (SQLite-backed DO). Paid tier could use new_classes (KV-backed).
|
|
30
|
+
new_sqlite_classes = ["PairingCoordinator", "DaemonRelay"]
|
|
31
|
+
|
|
32
|
+
[[unsafe.bindings]]
|
|
33
|
+
name = "RATE_LIMITER"
|
|
34
|
+
type = "ratelimit"
|
|
35
|
+
namespace_id = "1"
|
|
36
|
+
simple = { limit = 30, period = 60 }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name = "miki-relay"
|
|
2
|
+
main = "src/index.ts"
|
|
3
|
+
compatibility_date = "2025-01-01"
|
|
4
|
+
compatibility_flags = ["nodejs_compat"]
|
|
5
|
+
|
|
6
|
+
# Custom domain — self-host users replace with your own zone, or delete this
|
|
7
|
+
# block entirely to use the default workers.dev subdomain.
|
|
8
|
+
routes = [
|
|
9
|
+
{ pattern = "relay.f1telemetrystationpro.org", custom_domain = true }
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[[durable_objects.bindings]]
|
|
13
|
+
name = "PAIRING"
|
|
14
|
+
class_name = "PairingCoordinator"
|
|
15
|
+
|
|
16
|
+
[[durable_objects.bindings]]
|
|
17
|
+
name = "RELAY"
|
|
18
|
+
class_name = "DaemonRelay"
|
|
19
|
+
|
|
20
|
+
[[migrations]]
|
|
21
|
+
tag = "v1"
|
|
22
|
+
# Free tier requires new_sqlite_classes (SQLite-backed DO). Paid tier could use new_classes (KV-backed).
|
|
23
|
+
new_sqlite_classes = ["PairingCoordinator", "DaemonRelay"]
|
|
24
|
+
|
|
25
|
+
[[unsafe.bindings]]
|
|
26
|
+
name = "RATE_LIMITER"
|
|
27
|
+
type = "ratelimit"
|
|
28
|
+
namespace_id = "1"
|
|
29
|
+
simple = { limit = 30, period = 60 }
|