@ckb-firewall/cli 0.3.1 → 0.5.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/README.md +11 -6
- package/dist/commands/anchor.d.ts +23 -0
- package/dist/commands/anchor.d.ts.map +1 -0
- package/dist/commands/anchor.js +412 -0
- package/dist/commands/anchor.js.map +1 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +59 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/execute.d.ts +9 -1
- package/dist/commands/execute.d.ts.map +1 -1
- package/dist/commands/execute.js +394 -229
- package/dist/commands/execute.js.map +1 -1
- package/dist/commands/gui.d.ts +6 -0
- package/dist/commands/gui.d.ts.map +1 -0
- package/dist/commands/gui.js +112 -0
- package/dist/commands/gui.js.map +1 -0
- package/dist/commands/import.d.ts.map +1 -1
- package/dist/commands/import.js +15 -48
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +32 -0
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/proposals.d.ts.map +1 -1
- package/dist/commands/proposals.js +2 -4
- package/dist/commands/proposals.js.map +1 -1
- package/dist/commands/propose.d.ts +5 -0
- package/dist/commands/propose.d.ts.map +1 -1
- package/dist/commands/propose.js +91 -7
- package/dist/commands/propose.js.map +1 -1
- package/dist/commands/reclaim.d.ts +18 -0
- package/dist/commands/reclaim.d.ts.map +1 -0
- package/dist/commands/reclaim.js +214 -0
- package/dist/commands/reclaim.js.map +1 -0
- package/dist/commands/sign.d.ts.map +1 -1
- package/dist/commands/sign.js +40 -94
- package/dist/commands/sign.js.map +1 -1
- package/dist/commands/vote.d.ts +2 -0
- package/dist/commands/vote.d.ts.map +1 -1
- package/dist/commands/vote.js +31 -25
- package/dist/commands/vote.js.map +1 -1
- package/dist/index.js +101 -16
- package/dist/index.js.map +1 -1
- package/dist/lib/Transaction Firewall.html +201 -0
- package/dist/lib/blkl.d.ts +17 -2
- package/dist/lib/blkl.d.ts.map +1 -1
- package/dist/lib/blkl.js +133 -17
- package/dist/lib/blkl.js.map +1 -1
- package/dist/lib/capacity.d.ts +12 -0
- package/dist/lib/capacity.d.ts.map +1 -0
- package/dist/lib/capacity.js +18 -0
- package/dist/lib/capacity.js.map +1 -0
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +35 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/defaults.d.ts +19 -0
- package/dist/lib/defaults.d.ts.map +1 -1
- package/dist/lib/defaults.js +34 -7
- package/dist/lib/defaults.js.map +1 -1
- package/dist/lib/governance-v4.d.ts +39 -0
- package/dist/lib/governance-v4.d.ts.map +1 -0
- package/dist/lib/governance-v4.js +194 -0
- package/dist/lib/governance-v4.js.map +1 -0
- package/dist/lib/gui-bundle.html +4266 -0
- package/dist/lib/gui-server.d.ts +12 -0
- package/dist/lib/gui-server.d.ts.map +1 -0
- package/dist/lib/gui-server.js +1634 -0
- package/dist/lib/gui-server.js.map +1 -0
- package/dist/lib/hints.d.ts +1 -1
- package/dist/lib/hints.d.ts.map +1 -1
- package/dist/lib/hints.js +3 -9
- package/dist/lib/hints.js.map +1 -1
- package/dist/lib/portless.d.ts +41 -0
- package/dist/lib/portless.d.ts.map +1 -0
- package/dist/lib/portless.js +194 -0
- package/dist/lib/portless.js.map +1 -0
- package/dist/lib/proposals.d.ts +17 -9
- package/dist/lib/proposals.d.ts.map +1 -1
- package/dist/lib/proposals.js +4 -26
- package/dist/lib/proposals.js.map +1 -1
- package/dist/lib/rpc.d.ts +5 -0
- package/dist/lib/rpc.d.ts.map +1 -1
- package/dist/lib/rpc.js +21 -0
- package/dist/lib/rpc.js.map +1 -1
- package/dist/lib/treasury-status.d.ts +28 -0
- package/dist/lib/treasury-status.d.ts.map +1 -0
- package/dist/lib/treasury-status.js +70 -0
- package/dist/lib/treasury-status.js.map +1 -0
- package/dist/lib/treasury.d.ts +15 -0
- package/dist/lib/treasury.d.ts.map +1 -0
- package/dist/lib/treasury.js +62 -0
- package/dist/lib/treasury.js.map +1 -0
- package/dist/lib/tx-deps.d.ts +9 -0
- package/dist/lib/tx-deps.d.ts.map +1 -0
- package/dist/lib/tx-deps.js +15 -0
- package/dist/lib/tx-deps.js.map +1 -0
- package/dist/lib/witness.d.ts +13 -11
- package/dist/lib/witness.d.ts.map +1 -1
- package/dist/lib/witness.js +85 -48
- package/dist/lib/witness.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1634 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { join, dirname as pathDirname } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
const _require = createRequire(import.meta.url);
|
|
8
|
+
const _cliVersion = _require("../../package.json").version;
|
|
9
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
10
|
+
import { listProposals, loadProposal, saveProposal, getProposalsDir, computeProposalIdHash, computeVoteDigestHash, voteSigningMessage, isReviewWindowPassed, isVoteApproved, countYes, REVIEW_WINDOW_MS, } from "./proposals.js";
|
|
11
|
+
import { getLiveCell } from "./rpc.js";
|
|
12
|
+
import { resolveRegistryOutpoint } from "./registry.js";
|
|
13
|
+
import { parseRegistryPayload } from "@ckb-firewall/sdk";
|
|
14
|
+
import { hexToBytes, bytesToHex, strip0x, extractGovernanceHeaderRaw, parseGovernanceHeader, governanceTreasuryLockHash, scriptToMoleculeBytes, } from "./blkl.js";
|
|
15
|
+
import { computeMerkleProof, verifyMerkleProof } from "./validator-set.js";
|
|
16
|
+
import { TESTNET_GOVERNANCE_PUBKEYS, TESTNET_CONTRACT_OUTPOINTS, SECP256K1_DEP_GROUP, } from "./defaults.js";
|
|
17
|
+
import { ckbBlake2b, buildGov1WitnessV4, buildValidatorVoteWitness, buildWitnessArgs, encodeRelativeTimestampSince, } from "./witness.js";
|
|
18
|
+
import { assertProposalCellMatches, assertProposalAnchorTypeMatches, encodeProposalCellData, loadRegistryStateForProposal, parseRegistryTypeIdValue, proposalCellDataHash, proposalV4Fields, } from "./governance-v4.js";
|
|
19
|
+
import { hexCapacity, occupiedCapacityShannons, parseCapacity } from "./capacity.js";
|
|
20
|
+
import { loadTreasuryStatus } from "./treasury-status.js";
|
|
21
|
+
const DEFAULT_FEE_SHANNONS = 100000n;
|
|
22
|
+
// Treasury-lock change outputs have 64-byte args — minimum occupied capacity is
|
|
23
|
+
// 8 (capacity field) + 117 (lock script molecule) = 125 CKB = 12,500,000,000 shannons.
|
|
24
|
+
const MIN_CHANGE_SHANNONS = 125n * 100000000n;
|
|
25
|
+
// ── /api/data handler ─────────────────────────────────────────────────────────
|
|
26
|
+
async function buildApiData(opts) {
|
|
27
|
+
const proposals = listProposals();
|
|
28
|
+
let registry;
|
|
29
|
+
try {
|
|
30
|
+
const { txHash, index } = await resolveRegistryOutpoint(opts.rpcUrl, opts.registryTx, opts.registryIndex);
|
|
31
|
+
const cell = await getLiveCell(opts.rpcUrl, txHash, index);
|
|
32
|
+
const payload = parseRegistryPayload(cell.data);
|
|
33
|
+
const governanceHeaderRaw = extractGovernanceHeaderRaw(cell.data);
|
|
34
|
+
const governanceHeader = governanceHeaderRaw ? parseGovernanceHeader(governanceHeaderRaw) : null;
|
|
35
|
+
registry = {
|
|
36
|
+
txHash,
|
|
37
|
+
index,
|
|
38
|
+
version: payload.version,
|
|
39
|
+
threshold: governanceHeader?.threshold ?? null,
|
|
40
|
+
validatorCount: governanceHeader?.validatorCount ?? null,
|
|
41
|
+
treasury: await loadTreasuryStatus(opts.rpcUrl, cell, governanceHeader),
|
|
42
|
+
error: null,
|
|
43
|
+
entries: payload.entries.map((e) => ({
|
|
44
|
+
identifier: e.identifier,
|
|
45
|
+
expiresAt: e.expiresAt === 0n ? null : String(e.expiresAt),
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
registry = {
|
|
51
|
+
txHash: opts.registryTx,
|
|
52
|
+
index: opts.registryIndex,
|
|
53
|
+
version: 0,
|
|
54
|
+
threshold: null,
|
|
55
|
+
validatorCount: null,
|
|
56
|
+
treasury: null,
|
|
57
|
+
error: err instanceof Error ? err.message : String(err),
|
|
58
|
+
entries: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify({
|
|
62
|
+
proposals,
|
|
63
|
+
registry,
|
|
64
|
+
meta: {
|
|
65
|
+
rpcUrl: opts.rpcUrl,
|
|
66
|
+
fetchedAt: new Date().toISOString(),
|
|
67
|
+
cliVersion: _cliVersion,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// ── request body reader ───────────────────────────────────────────────────────
|
|
72
|
+
function readJsonBody(req) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
let body = "";
|
|
75
|
+
req.on("data", (chunk) => {
|
|
76
|
+
body += chunk.toString();
|
|
77
|
+
if (body.length > 128 * 1024)
|
|
78
|
+
req.destroy(new Error("Request body too large"));
|
|
79
|
+
});
|
|
80
|
+
req.on("end", () => { try {
|
|
81
|
+
resolve(JSON.parse(body));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
reject(new Error("Invalid JSON body"));
|
|
85
|
+
} });
|
|
86
|
+
req.on("error", reject);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function apiOk(res, data) {
|
|
90
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache" });
|
|
91
|
+
res.end(JSON.stringify(data));
|
|
92
|
+
}
|
|
93
|
+
function apiErr(res, msg, status = 400) {
|
|
94
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
95
|
+
res.end(JSON.stringify({ ok: false, error: msg }));
|
|
96
|
+
}
|
|
97
|
+
function parseOutputIndex(value, name) {
|
|
98
|
+
const raw = String(value ?? "").trim();
|
|
99
|
+
if (!/^\d+$/.test(raw))
|
|
100
|
+
throw new Error(`${name} must be a non-negative integer.`);
|
|
101
|
+
return Number.parseInt(raw, 10);
|
|
102
|
+
}
|
|
103
|
+
function parseOptionalTxHash(value, name) {
|
|
104
|
+
const raw = String(value ?? "").trim();
|
|
105
|
+
if (!raw)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(raw))
|
|
108
|
+
throw new Error(`${name} must be a 0x-prefixed 32-byte transaction hash.`);
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
function extractProposalOutpoint(proposal, body) {
|
|
112
|
+
const txHash = parseOptionalTxHash(body.proposalTx, "proposalTx") || proposal.proposalCellTxHash?.trim();
|
|
113
|
+
const indexRaw = body.proposalIndex ?? (proposal.proposalCellIndex === undefined ? undefined : String(proposal.proposalCellIndex));
|
|
114
|
+
if (!txHash || indexRaw === undefined) {
|
|
115
|
+
throw new Error("Proposal cell outpoint is missing. Anchor the proposal cell first, then record its tx hash and output index.");
|
|
116
|
+
}
|
|
117
|
+
return { txHash, index: parseOutputIndex(indexRaw, "proposalIndex") };
|
|
118
|
+
}
|
|
119
|
+
// ── /api/propose ──────────────────────────────────────────────────────────────
|
|
120
|
+
function isValidHex(v) {
|
|
121
|
+
const c = strip0x(v);
|
|
122
|
+
return c.length > 0 && /^[0-9a-fA-F]+$/.test(c) && c.length % 2 === 0;
|
|
123
|
+
}
|
|
124
|
+
async function handlePropose(body) {
|
|
125
|
+
if (typeof body !== "object" || body === null)
|
|
126
|
+
throw new Error("Invalid body");
|
|
127
|
+
const b = body;
|
|
128
|
+
const action = String(b.action ?? "");
|
|
129
|
+
if (action !== "add" && action !== "remove")
|
|
130
|
+
throw new Error('action must be "add" or "remove"');
|
|
131
|
+
const rawArgs = String(b.lockArgs ?? "").trim();
|
|
132
|
+
if (!rawArgs || !isValidHex(rawArgs))
|
|
133
|
+
throw new Error("lockArgs must be valid even-length hex");
|
|
134
|
+
const lockArgs = `0x${strip0x(rawArgs).toLowerCase()}`;
|
|
135
|
+
const evidence = String(b.evidence ?? "").trim();
|
|
136
|
+
if (evidence.length < 10)
|
|
137
|
+
throw new Error("evidence must be at least 10 characters");
|
|
138
|
+
const classification = String(b.classification ?? "");
|
|
139
|
+
if (!["theft", "scam", "hack", "sanctions", "other"].includes(classification))
|
|
140
|
+
throw new Error("Invalid classification");
|
|
141
|
+
const severity = String(b.severity ?? "");
|
|
142
|
+
if (!["critical", "high", "medium", "low"].includes(severity))
|
|
143
|
+
throw new Error("Invalid severity");
|
|
144
|
+
const rationale = String(b.rationale ?? "").trim();
|
|
145
|
+
if (rationale.length < 20)
|
|
146
|
+
throw new Error("rationale must be at least 20 characters");
|
|
147
|
+
const proposer = String(b.proposer ?? "").trim();
|
|
148
|
+
if (!proposer)
|
|
149
|
+
throw new Error("proposer is required");
|
|
150
|
+
let expiresAt = "0";
|
|
151
|
+
if (action === "add" && b.expiresAt && b.expiresAt !== "0") {
|
|
152
|
+
const n = Number(b.expiresAt);
|
|
153
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0)
|
|
154
|
+
throw new Error("Invalid expiresAt timestamp");
|
|
155
|
+
if (n <= Math.floor(Date.now() / 1000))
|
|
156
|
+
throw new Error("expiresAt must be in the future");
|
|
157
|
+
expiresAt = String(n);
|
|
158
|
+
}
|
|
159
|
+
const submittedAt = new Date().toISOString();
|
|
160
|
+
const reviewWindowEndsAt = new Date(Date.now() + REVIEW_WINDOW_MS).toISOString();
|
|
161
|
+
const proposalIdHash = computeProposalIdHash({
|
|
162
|
+
action: action, lockArgs, expiresAt, evidence,
|
|
163
|
+
classification: classification, severity: severity,
|
|
164
|
+
rationale, proposer, submittedAt,
|
|
165
|
+
});
|
|
166
|
+
const proposal = {
|
|
167
|
+
id: proposalIdHash.slice(2, 14),
|
|
168
|
+
proposalIdHash, action: action,
|
|
169
|
+
lockArgs, expiresAt, evidence,
|
|
170
|
+
classification: classification,
|
|
171
|
+
severity: severity,
|
|
172
|
+
rationale, proposer, submittedAt, reviewWindowEndsAt,
|
|
173
|
+
status: "pending-review",
|
|
174
|
+
votes: [], voteDigestHash: computeVoteDigestHash([]), signatures: [],
|
|
175
|
+
};
|
|
176
|
+
saveProposal(proposal);
|
|
177
|
+
return { ok: true, id: proposal.id, proposal };
|
|
178
|
+
}
|
|
179
|
+
// ── /api/vote ─────────────────────────────────────────────────────────────────
|
|
180
|
+
async function handleVote(body, opts) {
|
|
181
|
+
if (typeof body !== "object" || body === null)
|
|
182
|
+
throw new Error("Invalid body");
|
|
183
|
+
const b = body;
|
|
184
|
+
const proposalId = String(b.proposalId ?? "").trim();
|
|
185
|
+
if (!proposalId)
|
|
186
|
+
throw new Error("proposalId required");
|
|
187
|
+
const choice = String(b.vote ?? "");
|
|
188
|
+
if (choice !== "yes" && choice !== "no" && choice !== "abstain")
|
|
189
|
+
throw new Error("vote must be yes, no, or abstain");
|
|
190
|
+
const pkHex = String(b.privateKey ?? "").trim();
|
|
191
|
+
if (!pkHex)
|
|
192
|
+
throw new Error("privateKey required");
|
|
193
|
+
let pkBytes;
|
|
194
|
+
try {
|
|
195
|
+
pkBytes = hexToBytes(pkHex);
|
|
196
|
+
if (pkBytes.length !== 32)
|
|
197
|
+
throw new Error("must be 32 bytes");
|
|
198
|
+
secp256k1.getPublicKey(pkBytes);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
throw new Error("Invalid private key: " + (e instanceof Error ? e.message : String(e)));
|
|
202
|
+
}
|
|
203
|
+
const proposal = loadProposal(proposalId);
|
|
204
|
+
if (proposal.status === "executed")
|
|
205
|
+
throw new Error("Proposal already executed");
|
|
206
|
+
if (proposal.status === "rejected")
|
|
207
|
+
throw new Error("Proposal was rejected");
|
|
208
|
+
const pubkeyBytes = secp256k1.getPublicKey(pkBytes, true);
|
|
209
|
+
const pubkey = bytesToHex(new Uint8Array(pubkeyBytes));
|
|
210
|
+
const validatorSet = TESTNET_GOVERNANCE_PUBKEYS.map((k) => bytesToHex(k));
|
|
211
|
+
const merkleResult = computeMerkleProof(validatorSet, pubkey);
|
|
212
|
+
if (!merkleResult) {
|
|
213
|
+
pkBytes.fill(0);
|
|
214
|
+
throw new Error(`Key is not an authorized governance voter (pubkey: ${pubkey.slice(0, 20)}…)`);
|
|
215
|
+
}
|
|
216
|
+
const { proof: merkleProof, leafIndex: merkleLeafIndex } = merkleResult;
|
|
217
|
+
if (proposal.votes.some((v) => v.pubkey.toLowerCase() === pubkey.toLowerCase())) {
|
|
218
|
+
pkBytes.fill(0);
|
|
219
|
+
throw new Error("This governance voter already voted on this proposal");
|
|
220
|
+
}
|
|
221
|
+
const timestamp = new Date().toISOString();
|
|
222
|
+
const msgHash = voteSigningMessage(proposal.proposalIdHash, choice, timestamp, pubkey);
|
|
223
|
+
const sigRaw = secp256k1.sign(msgHash, pkBytes, { lowS: true, format: "recovered" });
|
|
224
|
+
const sigBytes = new Uint8Array(65);
|
|
225
|
+
sigBytes.set(sigRaw.slice(1), 0);
|
|
226
|
+
sigBytes[64] = sigRaw[0] ?? 0;
|
|
227
|
+
pkBytes.fill(0);
|
|
228
|
+
proposal.votes.push({ pubkey, vote: choice, timestamp, signature: bytesToHex(sigBytes), merkleLeafIndex, merkleProof });
|
|
229
|
+
proposal.voteDigestHash = computeVoteDigestHash(proposal.votes);
|
|
230
|
+
const yesCount = countYes(proposal.votes);
|
|
231
|
+
const approved = isVoteApproved(proposal);
|
|
232
|
+
if (proposal.status === "pending-review" || proposal.status === "voting")
|
|
233
|
+
proposal.status = approved ? "approved" : "voting";
|
|
234
|
+
saveProposal(proposal);
|
|
235
|
+
return { ok: true, yesCount, approved, status: proposal.status, pubkey };
|
|
236
|
+
}
|
|
237
|
+
// ── /api/execute ──────────────────────────────────────────────────────────────
|
|
238
|
+
async function handleExecute(body, opts) {
|
|
239
|
+
if (typeof body !== "object" || body === null)
|
|
240
|
+
throw new Error("Invalid body");
|
|
241
|
+
const b = body;
|
|
242
|
+
const proposalId = String(b.proposalId ?? "").trim();
|
|
243
|
+
if (!proposalId)
|
|
244
|
+
throw new Error("proposalId required");
|
|
245
|
+
const proposal = loadProposal(proposalId);
|
|
246
|
+
if (proposal.status === "executed")
|
|
247
|
+
throw new Error("Proposal already executed");
|
|
248
|
+
if (!isReviewWindowPassed(proposal))
|
|
249
|
+
throw new Error("Local review window has not passed");
|
|
250
|
+
if (!isVoteApproved(proposal))
|
|
251
|
+
throw new Error("Vote threshold not met");
|
|
252
|
+
const outpoint = extractProposalOutpoint(proposal, b);
|
|
253
|
+
const state = await loadRegistryStateForProposal(opts.rpcUrl, opts.registryTx, opts.registryIndex, proposal);
|
|
254
|
+
const proposalCell = await getLiveCell(opts.rpcUrl, outpoint.txHash, outpoint.index);
|
|
255
|
+
const proposalDataHash = assertProposalCellMatches(proposal, proposalCell.data, state.registryTypeIdValue);
|
|
256
|
+
const fields = proposalV4Fields(proposal, state.registryTypeIdValue);
|
|
257
|
+
if (bytesToHex(fields.proposalDataHash) !== bytesToHex(proposalDataHash)) {
|
|
258
|
+
throw new Error("Internal proposal hash mismatch");
|
|
259
|
+
}
|
|
260
|
+
proposal.proposalDataHash = bytesToHex(proposalDataHash);
|
|
261
|
+
proposal.reviewDelayMs = fields.reviewDelayMs.toString();
|
|
262
|
+
proposal.proposalCellTxHash = outpoint.txHash;
|
|
263
|
+
proposal.proposalCellIndex = outpoint.index;
|
|
264
|
+
for (const v of proposal.votes) {
|
|
265
|
+
const sigBytes = hexToBytes(v.signature);
|
|
266
|
+
if (sigBytes.length !== 65)
|
|
267
|
+
throw new Error(`Vote from ${v.pubkey.slice(0, 14)}... has invalid signature length`);
|
|
268
|
+
const msgHash = voteSigningMessage(proposal.proposalIdHash, v.vote, v.timestamp, v.pubkey);
|
|
269
|
+
const sig65 = new Uint8Array(65);
|
|
270
|
+
sig65[0] = sigBytes[64];
|
|
271
|
+
sig65.set(sigBytes.subarray(0, 64), 1);
|
|
272
|
+
const recoveredPubkey = bytesToHex(new Uint8Array(secp256k1.recoverPublicKey(sig65, msgHash)));
|
|
273
|
+
if (recoveredPubkey !== v.pubkey)
|
|
274
|
+
throw new Error(`Vote signature does not match pubkey ${v.pubkey.slice(0, 14)}...`);
|
|
275
|
+
}
|
|
276
|
+
if (state.governanceHeader?.validatorCount) {
|
|
277
|
+
const rootHex = bytesToHex(state.governanceHeader.validatorMerkleRoot);
|
|
278
|
+
for (const v of proposal.votes) {
|
|
279
|
+
if (!verifyMerkleProof(rootHex, v.pubkey, v.merkleProof, v.merkleLeafIndex)) {
|
|
280
|
+
throw new Error(`Vote from ${v.pubkey.slice(0, 14)}... is not in the on-chain governance voter set`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const proposalCapacity = parseCapacity(proposalCell.capacity);
|
|
285
|
+
const proposalChangeCapacity = proposalCapacity - DEFAULT_FEE_SHANNONS;
|
|
286
|
+
const treasuryLockHash = governanceTreasuryLockHash(state.governanceHeader);
|
|
287
|
+
const minChange = treasuryLockHash
|
|
288
|
+
? MIN_CHANGE_SHANNONS
|
|
289
|
+
: occupiedCapacityShannons({ lock: proposalCell.lock, type: null, data: "0x" });
|
|
290
|
+
if (proposalChangeCapacity < minChange) {
|
|
291
|
+
throw new Error(`Proposal cell capacity ${proposalCapacity} shannons is too small to return change after fee`);
|
|
292
|
+
}
|
|
293
|
+
let registryOutputCapacity = parseCapacity(state.cell.capacity);
|
|
294
|
+
let extraTreasuryOutputCapacity = 0n;
|
|
295
|
+
let treasuryLockScript;
|
|
296
|
+
if (treasuryLockHash) {
|
|
297
|
+
treasuryLockScript = state.governanceHeader?.treasuryLockScript;
|
|
298
|
+
if (!treasuryLockScript) {
|
|
299
|
+
throw new Error("Registry treasury lock script is missing from the governance header. " +
|
|
300
|
+
"A full treasury lock script (v3 header) is required to safely route change capacity.");
|
|
301
|
+
}
|
|
302
|
+
assertProposalAnchorTypeMatches({
|
|
303
|
+
proposalCellType: proposalCell.type,
|
|
304
|
+
registryTypeIdValue: state.registryTypeIdValue,
|
|
305
|
+
governanceHeader: state.governanceHeader,
|
|
306
|
+
reclaimDelayMs: fields.reviewDelayMs,
|
|
307
|
+
});
|
|
308
|
+
const proposalLockHash = ckbBlake2b(scriptToMoleculeBytes(proposalCell.lock));
|
|
309
|
+
if (bytesToHex(proposalLockHash) !== bytesToHex(treasuryLockHash)) {
|
|
310
|
+
throw new Error("This registry uses treasury-funded anchors, but the proposal cell is not locked to the registry treasury");
|
|
311
|
+
}
|
|
312
|
+
const registryInputCapacity = parseCapacity(state.cell.capacity);
|
|
313
|
+
const minRegistryCapacity = occupiedCapacityShannons({
|
|
314
|
+
lock: state.cell.lock,
|
|
315
|
+
type: state.cell.type,
|
|
316
|
+
data: state.newBlkl,
|
|
317
|
+
});
|
|
318
|
+
registryOutputCapacity = minRegistryCapacity;
|
|
319
|
+
if (registryOutputCapacity > registryInputCapacity) {
|
|
320
|
+
throw new Error(`Registry update needs ${registryOutputCapacity - registryInputCapacity} shannons of treasury capacity for growth. ` +
|
|
321
|
+
"Use the CLI execute command with --treasury-cell inputs.");
|
|
322
|
+
}
|
|
323
|
+
extraTreasuryOutputCapacity = registryInputCapacity - registryOutputCapacity;
|
|
324
|
+
}
|
|
325
|
+
const yesVotes = proposal.votes
|
|
326
|
+
.filter((v) => v.vote === "yes")
|
|
327
|
+
.sort((a, b) => a.pubkey.localeCompare(b.pubkey));
|
|
328
|
+
const gov1 = buildGov1WitnessV4({
|
|
329
|
+
proposalIdHash: hexToBytes(proposal.proposalIdHash),
|
|
330
|
+
voteDigestHash: hexToBytes(proposal.voteDigestHash),
|
|
331
|
+
oldRoot: state.oldRoot,
|
|
332
|
+
newRoot: state.newRoot,
|
|
333
|
+
proposalDataHash,
|
|
334
|
+
reviewDelayMs: fields.reviewDelayMs,
|
|
335
|
+
});
|
|
336
|
+
const voteWitness = buildValidatorVoteWitness(yesVotes.map((v) => ({
|
|
337
|
+
pubkey: hexToBytes(v.pubkey),
|
|
338
|
+
vote: v.vote,
|
|
339
|
+
timestamp: v.timestamp,
|
|
340
|
+
signature: hexToBytes(v.signature),
|
|
341
|
+
merkleLeafIndex: v.merkleLeafIndex,
|
|
342
|
+
merkleProof: v.merkleProof.map(hexToBytes),
|
|
343
|
+
})));
|
|
344
|
+
const witnessBytes = buildWitnessArgs({ lock: voteWitness, inputType: gov1 });
|
|
345
|
+
const txJson = {
|
|
346
|
+
transaction: {
|
|
347
|
+
version: "0x0",
|
|
348
|
+
cell_deps: [
|
|
349
|
+
{ out_point: { tx_hash: SECP256K1_DEP_GROUP.txHash, index: "0x0" }, dep_type: "dep_group" },
|
|
350
|
+
{
|
|
351
|
+
out_point: {
|
|
352
|
+
tx_hash: TESTNET_CONTRACT_OUTPOINTS.blacklistRegistry.txHash,
|
|
353
|
+
index: `0x${TESTNET_CONTRACT_OUTPOINTS.blacklistRegistry.index.toString(16)}`,
|
|
354
|
+
},
|
|
355
|
+
dep_type: "code",
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
out_point: {
|
|
359
|
+
tx_hash: TESTNET_CONTRACT_OUTPOINTS.governanceLock.txHash,
|
|
360
|
+
index: `0x${TESTNET_CONTRACT_OUTPOINTS.governanceLock.index.toString(16)}`,
|
|
361
|
+
},
|
|
362
|
+
dep_type: "code",
|
|
363
|
+
},
|
|
364
|
+
...(treasuryLockHash ? [{
|
|
365
|
+
out_point: {
|
|
366
|
+
tx_hash: TESTNET_CONTRACT_OUTPOINTS.proposalAnchor.txHash,
|
|
367
|
+
index: `0x${TESTNET_CONTRACT_OUTPOINTS.proposalAnchor.index.toString(16)}`,
|
|
368
|
+
},
|
|
369
|
+
dep_type: "code",
|
|
370
|
+
}] : []),
|
|
371
|
+
],
|
|
372
|
+
header_deps: [],
|
|
373
|
+
inputs: [
|
|
374
|
+
{
|
|
375
|
+
since: "0x0",
|
|
376
|
+
previous_output: { tx_hash: state.cell.txHash, index: `0x${state.cell.index.toString(16)}` },
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
since: encodeRelativeTimestampSince(fields.reviewDelayMs),
|
|
380
|
+
previous_output: { tx_hash: outpoint.txHash, index: `0x${outpoint.index.toString(16)}` },
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
outputs: [
|
|
384
|
+
{ capacity: hexCapacity(registryOutputCapacity), lock: state.cell.lock, type: state.cell.type },
|
|
385
|
+
// Merge proposal change + extra treasury capacity into one output to avoid
|
|
386
|
+
// creating a cell below the minimum 125 CKB for treasury-lock's 64-byte args.
|
|
387
|
+
{ capacity: hexCapacity(proposalChangeCapacity + extraTreasuryOutputCapacity), lock: treasuryLockScript ?? proposalCell.lock, type: null },
|
|
388
|
+
],
|
|
389
|
+
outputs_data: [
|
|
390
|
+
bytesToHex(state.newBlkl),
|
|
391
|
+
"0x",
|
|
392
|
+
],
|
|
393
|
+
witnesses: [bytesToHex(witnessBytes), bytesToHex(buildWitnessArgs({}))],
|
|
394
|
+
},
|
|
395
|
+
multisig_configs: {},
|
|
396
|
+
signatures: {},
|
|
397
|
+
};
|
|
398
|
+
saveProposal(proposal);
|
|
399
|
+
return {
|
|
400
|
+
ok: true,
|
|
401
|
+
proposal,
|
|
402
|
+
txJson,
|
|
403
|
+
filename: `gov_execute_tx_${proposal.id}.json`,
|
|
404
|
+
proposalCell: outpoint,
|
|
405
|
+
proposalDataHash: bytesToHex(proposalDataHash),
|
|
406
|
+
since: encodeRelativeTimestampSince(fields.reviewDelayMs),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
// ── /api/anchor ───────────────────────────────────────────────────────────────
|
|
410
|
+
async function handleAnchor(body, opts) {
|
|
411
|
+
if (typeof body !== "object" || body === null)
|
|
412
|
+
throw new Error("Invalid body");
|
|
413
|
+
const b = body;
|
|
414
|
+
const proposalId = String(b.proposalId ?? "").trim();
|
|
415
|
+
if (!proposalId)
|
|
416
|
+
throw new Error("proposalId required");
|
|
417
|
+
const proposal = loadProposal(proposalId);
|
|
418
|
+
const { txHash, index } = await resolveRegistryOutpoint(opts.rpcUrl, opts.registryTx, opts.registryIndex);
|
|
419
|
+
const registryCell = await getLiveCell(opts.rpcUrl, txHash, index);
|
|
420
|
+
if (!registryCell.type)
|
|
421
|
+
throw new Error("Registry cell has no type script");
|
|
422
|
+
const registryTypeIdValue = parseRegistryTypeIdValue(registryCell.type.args);
|
|
423
|
+
const governanceHeaderRaw = extractGovernanceHeaderRaw(registryCell.data);
|
|
424
|
+
const governanceHeader = governanceHeaderRaw ? parseGovernanceHeader(governanceHeaderRaw) : null;
|
|
425
|
+
const proposalData = encodeProposalCellData(proposal, registryTypeIdValue);
|
|
426
|
+
const proposalDataHashHex = bytesToHex(proposalCellDataHash(proposal, registryTypeIdValue));
|
|
427
|
+
proposal.proposalDataHash = proposalDataHashHex;
|
|
428
|
+
proposal.reviewDelayMs = proposal.reviewDelayMs ?? String(REVIEW_WINDOW_MS);
|
|
429
|
+
const proposalTx = parseOptionalTxHash(b.proposalTx, "proposalTx");
|
|
430
|
+
let anchorVerified = false;
|
|
431
|
+
if (proposalTx) {
|
|
432
|
+
const proposalIndex = parseOutputIndex(b.proposalIndex, "proposalIndex");
|
|
433
|
+
const proposalCell = await getLiveCell(opts.rpcUrl, proposalTx, proposalIndex);
|
|
434
|
+
const liveHash = assertProposalCellMatches(proposal, proposalCell.data, registryTypeIdValue);
|
|
435
|
+
if (bytesToHex(liveHash) !== proposalDataHashHex) {
|
|
436
|
+
throw new Error("Live proposal-cell hash does not match the expected PBLK data");
|
|
437
|
+
}
|
|
438
|
+
assertProposalAnchorTypeMatches({
|
|
439
|
+
proposalCellType: proposalCell.type,
|
|
440
|
+
registryTypeIdValue,
|
|
441
|
+
governanceHeader,
|
|
442
|
+
reclaimDelayMs: BigInt(proposal.reviewDelayMs),
|
|
443
|
+
});
|
|
444
|
+
const treasuryLockHash = governanceTreasuryLockHash(governanceHeader);
|
|
445
|
+
if (treasuryLockHash) {
|
|
446
|
+
const proposalLockHash = ckbBlake2b(scriptToMoleculeBytes(proposalCell.lock));
|
|
447
|
+
if (bytesToHex(proposalLockHash) !== bytesToHex(treasuryLockHash)) {
|
|
448
|
+
throw new Error("This registry uses treasury-funded anchors, but the proposal cell is not locked to the registry treasury");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
proposal.proposalCellTxHash = proposalTx;
|
|
452
|
+
proposal.proposalCellIndex = proposalIndex;
|
|
453
|
+
anchorVerified = true;
|
|
454
|
+
}
|
|
455
|
+
else if (b.proposalIndex !== undefined && String(b.proposalIndex).trim()) {
|
|
456
|
+
throw new Error("proposalTx is required when proposalIndex is provided");
|
|
457
|
+
}
|
|
458
|
+
saveProposal(proposal);
|
|
459
|
+
const treasuryLockHash = governanceTreasuryLockHash(governanceHeader);
|
|
460
|
+
const toAddress = String(b.toAddress ?? "").trim();
|
|
461
|
+
const command = treasuryLockHash ? [
|
|
462
|
+
"ckb-firewall",
|
|
463
|
+
"anchor",
|
|
464
|
+
"--proposal", proposal.id,
|
|
465
|
+
"--tx-out", `gov_anchor_tx_${proposal.id}.json`,
|
|
466
|
+
].join(" ") : toAddress ? [
|
|
467
|
+
"ckb-cli",
|
|
468
|
+
"--url", opts.rpcUrl,
|
|
469
|
+
"wallet", "transfer",
|
|
470
|
+
"--to-address", toAddress,
|
|
471
|
+
"--capacity", "62",
|
|
472
|
+
"--to-data", bytesToHex(proposalData),
|
|
473
|
+
"--fee-rate", "1000",
|
|
474
|
+
"--output-format", "json",
|
|
475
|
+
].map((p) => (/\s/.test(p) ? JSON.stringify(p) : p)).join(" ") : null;
|
|
476
|
+
return {
|
|
477
|
+
ok: true,
|
|
478
|
+
proposal,
|
|
479
|
+
proposalData: bytesToHex(proposalData),
|
|
480
|
+
proposalDataHash: proposalDataHashHex,
|
|
481
|
+
reviewDelayMs: proposal.reviewDelayMs,
|
|
482
|
+
anchorVerified,
|
|
483
|
+
treasuryBacked: Boolean(treasuryLockHash),
|
|
484
|
+
command,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
// ── /api/import ───────────────────────────────────────────────────────────────
|
|
488
|
+
function handleImport(body) {
|
|
489
|
+
if (typeof body !== "object" || body === null)
|
|
490
|
+
throw new Error("Invalid body");
|
|
491
|
+
const p = body;
|
|
492
|
+
const requiredStrings = ["id", "proposalIdHash", "action", "lockArgs", "expiresAt", "evidence", "classification", "severity", "rationale", "proposer", "submittedAt", "reviewWindowEndsAt", "status", "voteDigestHash"];
|
|
493
|
+
for (const k of requiredStrings)
|
|
494
|
+
if (typeof p[k] !== "string")
|
|
495
|
+
throw new Error(`Missing field: ${k}`);
|
|
496
|
+
if (!Array.isArray(p.votes))
|
|
497
|
+
throw new Error("votes must be an array");
|
|
498
|
+
const computed = computeProposalIdHash({
|
|
499
|
+
action: p.action, lockArgs: p.lockArgs,
|
|
500
|
+
expiresAt: p.expiresAt, evidence: p.evidence,
|
|
501
|
+
classification: p.classification, severity: p.severity,
|
|
502
|
+
rationale: p.rationale, proposer: p.proposer, submittedAt: p.submittedAt,
|
|
503
|
+
});
|
|
504
|
+
if (computed !== p.proposalIdHash)
|
|
505
|
+
throw new Error("proposalIdHash integrity check failed — file may be tampered");
|
|
506
|
+
const expectedId = computed.slice(2, 14);
|
|
507
|
+
if (!/^[0-9a-f]{12}$/.test(String(p.id)) || String(p.id) !== expectedId)
|
|
508
|
+
throw new Error("id does not match proposalIdHash");
|
|
509
|
+
const recomputedDigest = computeVoteDigestHash(p.votes);
|
|
510
|
+
if (recomputedDigest !== p.voteDigestHash)
|
|
511
|
+
throw new Error("voteDigestHash integrity check failed — votes may be tampered");
|
|
512
|
+
const incoming = body;
|
|
513
|
+
const dir = getProposalsDir();
|
|
514
|
+
const destPath = join(dir, `${incoming.id}.json`);
|
|
515
|
+
if (existsSync(destPath)) {
|
|
516
|
+
let existing;
|
|
517
|
+
try {
|
|
518
|
+
existing = loadProposal(incoming.id);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
existing = incoming;
|
|
522
|
+
}
|
|
523
|
+
if (existing.proposalIdHash === incoming.proposalIdHash) {
|
|
524
|
+
const votesByPk = new Map(existing.votes.map((v) => [v.pubkey.toLowerCase(), v]));
|
|
525
|
+
for (const v of incoming.votes) {
|
|
526
|
+
const prev = votesByPk.get(v.pubkey.toLowerCase());
|
|
527
|
+
if (!prev || v.timestamp > prev.timestamp) {
|
|
528
|
+
votesByPk.set(v.pubkey.toLowerCase(), v);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const mergedVotes = [...votesByPk.values()];
|
|
532
|
+
const mergedVoteDigest = computeVoteDigestHash(mergedVotes);
|
|
533
|
+
const rankStatus = (st) => ["pending-review", "voting", "approved", "rejected", "executed"].indexOf(st);
|
|
534
|
+
const merged = {
|
|
535
|
+
...incoming,
|
|
536
|
+
votes: mergedVotes,
|
|
537
|
+
voteDigestHash: mergedVoteDigest,
|
|
538
|
+
signatures: [],
|
|
539
|
+
status: rankStatus(existing.status) >= rankStatus(incoming.status) ? existing.status : incoming.status,
|
|
540
|
+
};
|
|
541
|
+
const proposalDataHash = existing.proposalDataHash ?? incoming.proposalDataHash;
|
|
542
|
+
const reviewDelayMs = existing.reviewDelayMs ?? incoming.reviewDelayMs;
|
|
543
|
+
const proposalCellTxHash = existing.proposalCellTxHash ?? incoming.proposalCellTxHash;
|
|
544
|
+
const proposalCellIndex = existing.proposalCellIndex ?? incoming.proposalCellIndex;
|
|
545
|
+
if (proposalDataHash !== undefined)
|
|
546
|
+
merged.proposalDataHash = proposalDataHash;
|
|
547
|
+
if (reviewDelayMs !== undefined)
|
|
548
|
+
merged.reviewDelayMs = reviewDelayMs;
|
|
549
|
+
if (proposalCellTxHash !== undefined)
|
|
550
|
+
merged.proposalCellTxHash = proposalCellTxHash;
|
|
551
|
+
if (proposalCellIndex !== undefined)
|
|
552
|
+
merged.proposalCellIndex = proposalCellIndex;
|
|
553
|
+
saveProposal(merged);
|
|
554
|
+
return { ok: true, merged: true, id: incoming.id, votes: mergedVotes.length };
|
|
555
|
+
}
|
|
556
|
+
throw new Error(`A different proposal with ID ${incoming.id} already exists locally`);
|
|
557
|
+
}
|
|
558
|
+
saveProposal(incoming);
|
|
559
|
+
return { ok: true, merged: false, id: incoming.id };
|
|
560
|
+
}
|
|
561
|
+
// ── GUI bundle serving ────────────────────────────────────────────────────────
|
|
562
|
+
// gui-bundle.html is assembled by scripts/build-gui.js during `npm run build`.
|
|
563
|
+
// Source files live in src/lib/gui/ — edit those to change the UI.
|
|
564
|
+
//
|
|
565
|
+
// Per request: read the bundle once, cache it, then replace the sentinel
|
|
566
|
+
// comment with a <script> that sets window.TFW_* globals from live data.
|
|
567
|
+
const _GUI_BUNDLE_PATH = (() => {
|
|
568
|
+
try {
|
|
569
|
+
const __dir = pathDirname(fileURLToPath(import.meta.url));
|
|
570
|
+
const p = join(__dir, "gui-bundle.html");
|
|
571
|
+
return existsSync(p) ? p : null;
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
})();
|
|
577
|
+
let _guiTemplate = null;
|
|
578
|
+
function _buildGuiHtml(apiDataJson) {
|
|
579
|
+
if (!_guiTemplate) {
|
|
580
|
+
if (!_GUI_BUNDLE_PATH)
|
|
581
|
+
throw new Error("gui-bundle.html not found — run npm run build first");
|
|
582
|
+
_guiTemplate = readFileSync(_GUI_BUNDLE_PATH, "utf8");
|
|
583
|
+
}
|
|
584
|
+
const d = JSON.parse(apiDataJson);
|
|
585
|
+
const safeJson = (v) => JSON.stringify(v).replace(/<\/script/gi, "<\\/script");
|
|
586
|
+
const liveScript = `<script>
|
|
587
|
+
window.TFW_PROPOSALS = ${safeJson(d.proposals ?? [])};
|
|
588
|
+
window.TFW_REGISTRY_ENTRIES = ${safeJson(d.registry?.entries ?? [])};
|
|
589
|
+
window.TFW_META = ${safeJson({
|
|
590
|
+
threshold: d.registry?.threshold ?? 3,
|
|
591
|
+
governanceSetSize: d.registry?.validatorCount ?? 5,
|
|
592
|
+
reviewWindowHours: 72,
|
|
593
|
+
registryTxHash: d.registry?.txHash ?? null,
|
|
594
|
+
registryError: d.registry?.error ?? null,
|
|
595
|
+
treasury: d.registry?.treasury ?? null,
|
|
596
|
+
yourPubkey: null,
|
|
597
|
+
proposerName: loadConfig().proposerName ?? null,
|
|
598
|
+
...d.meta,
|
|
599
|
+
})};
|
|
600
|
+
</script>`;
|
|
601
|
+
return _guiTemplate.replace("<!-- LIVE_DATA_PLACEHOLDER -->", liveScript);
|
|
602
|
+
}
|
|
603
|
+
// ── HTML SPA ──────────────────────────────────────────────────────────────────
|
|
604
|
+
// Single self-contained page — no external dependencies.
|
|
605
|
+
/* eslint-disable max-len */
|
|
606
|
+
const HTML = `<!DOCTYPE html>
|
|
607
|
+
<html lang="en">
|
|
608
|
+
<head>
|
|
609
|
+
<meta charset="UTF-8">
|
|
610
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
611
|
+
<title>CKB Firewall</title>
|
|
612
|
+
<style>
|
|
613
|
+
:root{
|
|
614
|
+
--bg:#0d1117;--surf:#161b22;--surf2:#1c2128;--surf3:#22272e;
|
|
615
|
+
--border:#30363d;--border2:#21262d;
|
|
616
|
+
--text:#c9d1d9;--dim:#8b949e;--subtle:#6e7681;
|
|
617
|
+
--blue:#58a6ff;--green:#3fb950;--red:#f85149;
|
|
618
|
+
--yellow:#d29922;--cyan:#79c0ff;--purple:#bc8cff;
|
|
619
|
+
--font:'SF Mono','Cascadia Code','Fira Code',ui-monospace,monospace;
|
|
620
|
+
--sans:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
621
|
+
}
|
|
622
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
623
|
+
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.5}
|
|
624
|
+
a{color:var(--blue);text-decoration:none}
|
|
625
|
+
a:hover{text-decoration:underline}
|
|
626
|
+
code,.mono{font-family:var(--font);font-size:12px}
|
|
627
|
+
button{font-family:inherit}
|
|
628
|
+
::-webkit-scrollbar{width:6px;height:6px}
|
|
629
|
+
::-webkit-scrollbar-track{background:var(--surf)}
|
|
630
|
+
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
631
|
+
|
|
632
|
+
/* header */
|
|
633
|
+
header{
|
|
634
|
+
display:flex;align-items:center;gap:20px;
|
|
635
|
+
padding:0 24px;height:52px;
|
|
636
|
+
background:var(--surf);border-bottom:1px solid var(--border);
|
|
637
|
+
position:sticky;top:0;z-index:100;
|
|
638
|
+
}
|
|
639
|
+
.logo{display:flex;align-items:center;gap:8px;font-weight:700;white-space:nowrap;color:var(--cyan);letter-spacing:.3px;font-size:15px}
|
|
640
|
+
.logo-hex{font-size:18px}
|
|
641
|
+
nav{display:flex;gap:2px;flex:1}
|
|
642
|
+
.tab-btn{
|
|
643
|
+
padding:6px 14px;border:none;background:none;color:var(--dim);
|
|
644
|
+
cursor:pointer;border-radius:6px;font-size:13px;transition:color .15s,background .15s;
|
|
645
|
+
}
|
|
646
|
+
.tab-btn:hover{color:var(--text);background:var(--surf2)}
|
|
647
|
+
.tab-btn.active{color:var(--text);background:var(--surf3);font-weight:500}
|
|
648
|
+
.hdr-status{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:11px;white-space:nowrap}
|
|
649
|
+
.dot{width:7px;height:7px;border-radius:50%;background:var(--subtle);flex-shrink:0}
|
|
650
|
+
.dot.ok{background:var(--green)}
|
|
651
|
+
.dot.err{background:var(--red)}
|
|
652
|
+
.dot.spin{background:var(--yellow);animation:pulse 1.2s infinite}
|
|
653
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
654
|
+
|
|
655
|
+
/* pages */
|
|
656
|
+
.page{display:none;padding:24px;max-width:1120px;margin:0 auto}
|
|
657
|
+
.page.active{display:block}
|
|
658
|
+
|
|
659
|
+
/* stat tiles */
|
|
660
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:28px}
|
|
661
|
+
.stat{background:var(--surf);border:1px solid var(--border);border-radius:8px;padding:16px 20px}
|
|
662
|
+
.stat-val{font-size:30px;font-weight:700;line-height:1;margin-bottom:4px}
|
|
663
|
+
.stat-lbl{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.7px}
|
|
664
|
+
.stat-sub{color:var(--subtle);font-size:11px;margin-top:3px}
|
|
665
|
+
|
|
666
|
+
/* section */
|
|
667
|
+
.section{margin-bottom:28px}
|
|
668
|
+
.sec-hdr{
|
|
669
|
+
display:flex;align-items:center;gap:8px;
|
|
670
|
+
margin-bottom:12px;padding-bottom:8px;
|
|
671
|
+
border-bottom:1px solid var(--border2);
|
|
672
|
+
font-size:11px;font-weight:600;text-transform:uppercase;
|
|
673
|
+
letter-spacing:.7px;color:var(--dim);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/* badges */
|
|
677
|
+
.badge{
|
|
678
|
+
display:inline-flex;align-items:center;padding:2px 8px;
|
|
679
|
+
border-radius:10px;font-size:11px;font-weight:500;white-space:nowrap;
|
|
680
|
+
}
|
|
681
|
+
.b-yellow{background:rgba(210,153,34,.15);color:var(--yellow);border:1px solid rgba(210,153,34,.3)}
|
|
682
|
+
.b-green {background:rgba(63,185,80,.15) ;color:var(--green) ;border:1px solid rgba(63,185,80,.3) }
|
|
683
|
+
.b-red {background:rgba(248,81,73,.15) ;color:var(--red) ;border:1px solid rgba(248,81,73,.3) }
|
|
684
|
+
.b-blue {background:rgba(88,166,255,.15);color:var(--blue) ;border:1px solid rgba(88,166,255,.3) }
|
|
685
|
+
.b-dim {background:rgba(139,148,158,.1);color:var(--dim) ;border:1px solid rgba(139,148,158,.2)}
|
|
686
|
+
.b-cyan {background:rgba(121,192,255,.15);color:var(--cyan) ;border:1px solid rgba(121,192,255,.3)}
|
|
687
|
+
|
|
688
|
+
/* cards */
|
|
689
|
+
.card{
|
|
690
|
+
background:var(--surf);border:1px solid var(--border);
|
|
691
|
+
border-radius:8px;padding:14px 18px;margin-bottom:8px;
|
|
692
|
+
cursor:pointer;transition:border-color .15s;
|
|
693
|
+
}
|
|
694
|
+
.card:hover{border-color:var(--blue)}
|
|
695
|
+
.card-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
696
|
+
.card-action{font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.6px}
|
|
697
|
+
.add-col{color:var(--green)}.rem-col{color:var(--red)}
|
|
698
|
+
.card-args{font-family:var(--font);font-size:12px;color:var(--text)}
|
|
699
|
+
.card-id{font-family:var(--font);font-size:11px;color:var(--subtle)}
|
|
700
|
+
.card-meta{color:var(--dim);font-size:12px;margin-top:6px;line-height:1.7}
|
|
701
|
+
.card-foot{display:flex;align-items:center;gap:12px;margin-top:10px;flex-wrap:wrap}
|
|
702
|
+
|
|
703
|
+
/* progress */
|
|
704
|
+
.prog{display:flex;align-items:center;gap:7px;font-size:11px}
|
|
705
|
+
.prog-track{height:4px;background:var(--border);border-radius:2px;width:60px}
|
|
706
|
+
.prog-fill{height:100%;border-radius:2px;transition:width .3s}
|
|
707
|
+
.prog-g{background:var(--green)}.prog-y{background:var(--yellow)}
|
|
708
|
+
|
|
709
|
+
/* table */
|
|
710
|
+
.tbl-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:8px}
|
|
711
|
+
table{width:100%;border-collapse:collapse}
|
|
712
|
+
th{
|
|
713
|
+
text-align:left;padding:9px 14px;font-size:11px;
|
|
714
|
+
text-transform:uppercase;letter-spacing:.7px;
|
|
715
|
+
color:var(--dim);font-weight:600;
|
|
716
|
+
background:var(--surf);border-bottom:1px solid var(--border);
|
|
717
|
+
}
|
|
718
|
+
td{padding:10px 14px;border-bottom:1px solid var(--border2);font-size:13px;vertical-align:middle}
|
|
719
|
+
tr:last-child td{border-bottom:none}
|
|
720
|
+
tr.click{cursor:pointer}
|
|
721
|
+
tr.click:hover td{background:var(--surf2)}
|
|
722
|
+
.la{font-family:var(--font);font-size:12px}
|
|
723
|
+
|
|
724
|
+
/* filter bar */
|
|
725
|
+
.fbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
|
726
|
+
.inp{
|
|
727
|
+
background:var(--surf);border:1px solid var(--border);
|
|
728
|
+
border-radius:6px;padding:7px 12px;color:var(--text);
|
|
729
|
+
font-size:13px;outline:none;transition:border-color .15s;
|
|
730
|
+
}
|
|
731
|
+
.inp:focus{border-color:var(--blue)}
|
|
732
|
+
.inp.srch{min-width:240px;flex:1;max-width:380px}
|
|
733
|
+
.ftabs{display:flex;gap:4px;flex-wrap:wrap}
|
|
734
|
+
.ftab{
|
|
735
|
+
padding:5px 12px;border:1px solid var(--border);background:none;
|
|
736
|
+
color:var(--dim);border-radius:6px;cursor:pointer;font-size:12px;transition:all .15s;
|
|
737
|
+
}
|
|
738
|
+
.ftab:hover{color:var(--text);border-color:var(--dim)}
|
|
739
|
+
.ftab.active{background:var(--surf2);color:var(--text);border-color:var(--blue);font-weight:500}
|
|
740
|
+
label.ck{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12px;cursor:pointer;user-select:none}
|
|
741
|
+
label.ck input{accent-color:var(--blue)}
|
|
742
|
+
|
|
743
|
+
/* modal */
|
|
744
|
+
.overlay{
|
|
745
|
+
position:fixed;inset:0;background:rgba(0,0,0,.7);
|
|
746
|
+
display:flex;align-items:flex-start;justify-content:center;
|
|
747
|
+
padding:32px 16px;z-index:200;overflow-y:auto;
|
|
748
|
+
}
|
|
749
|
+
.overlay.hidden{display:none}
|
|
750
|
+
.modal{
|
|
751
|
+
background:var(--surf);border:1px solid var(--border);
|
|
752
|
+
border-radius:10px;width:100%;max-width:700px;
|
|
753
|
+
box-shadow:0 24px 80px rgba(0,0,0,.6);
|
|
754
|
+
}
|
|
755
|
+
.mhdr{
|
|
756
|
+
display:flex;align-items:center;gap:12px;
|
|
757
|
+
padding:16px 22px;border-bottom:1px solid var(--border);
|
|
758
|
+
}
|
|
759
|
+
.mtitle{font-weight:600;font-size:14px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
760
|
+
.mclose{
|
|
761
|
+
background:none;border:none;color:var(--dim);cursor:pointer;
|
|
762
|
+
font-size:18px;line-height:1;padding:4px 6px;border-radius:4px;flex-shrink:0;
|
|
763
|
+
}
|
|
764
|
+
.mclose:hover{color:var(--text);background:var(--surf2)}
|
|
765
|
+
.mbody{padding:20px 22px;max-height:calc(90vh - 80px);overflow-y:auto}
|
|
766
|
+
.msec{margin-bottom:20px}
|
|
767
|
+
.msec-ttl{
|
|
768
|
+
font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.7px;
|
|
769
|
+
color:var(--dim);margin-bottom:10px;padding-bottom:6px;
|
|
770
|
+
border-bottom:1px solid var(--border2);
|
|
771
|
+
}
|
|
772
|
+
.kv{display:grid;grid-template-columns:130px 1fr;gap:6px 14px;font-size:13px}
|
|
773
|
+
.kk{color:var(--dim)}
|
|
774
|
+
.kv-val{word-break:break-all}
|
|
775
|
+
|
|
776
|
+
/* cmd block */
|
|
777
|
+
.cmd{
|
|
778
|
+
background:var(--surf2);border:1px solid var(--border2);
|
|
779
|
+
border-radius:6px;padding:10px 14px;margin-bottom:8px;
|
|
780
|
+
display:flex;align-items:center;gap:10px;
|
|
781
|
+
}
|
|
782
|
+
.cmd-lbl{font-size:11px;color:var(--subtle);margin-bottom:4px}
|
|
783
|
+
.cmd-text{font-family:var(--font);font-size:12px;flex:1;color:var(--cyan);overflow-x:auto;white-space:nowrap}
|
|
784
|
+
.copy-btn{
|
|
785
|
+
background:var(--surf);border:1px solid var(--border);
|
|
786
|
+
border-radius:5px;padding:4px 10px;color:var(--dim);
|
|
787
|
+
cursor:pointer;font-size:11px;transition:all .15s;white-space:nowrap;flex-shrink:0;
|
|
788
|
+
}
|
|
789
|
+
.copy-btn:hover{color:var(--text);border-color:var(--blue)}
|
|
790
|
+
.copy-btn.ok{color:var(--green);border-color:var(--green)}
|
|
791
|
+
|
|
792
|
+
/* vote rows */
|
|
793
|
+
.vrow{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border2);font-size:12px}
|
|
794
|
+
.vrow:last-child{border-bottom:none}
|
|
795
|
+
.vch{font-weight:700;width:52px}
|
|
796
|
+
.vy{color:var(--green)}.vn{color:var(--red)}.va{color:var(--yellow)}
|
|
797
|
+
.vpk{font-family:var(--font);color:var(--dim);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
798
|
+
.vts{color:var(--subtle);white-space:nowrap}
|
|
799
|
+
|
|
800
|
+
/* misc */
|
|
801
|
+
.empty{text-align:center;padding:52px 24px;color:var(--dim)}
|
|
802
|
+
.empty-ico{font-size:28px;margin-bottom:12px;opacity:.5}
|
|
803
|
+
.empty-txt{font-size:14px;margin-bottom:6px}
|
|
804
|
+
.empty-sub{font-size:12px;color:var(--subtle)}
|
|
805
|
+
.err-bar{
|
|
806
|
+
background:rgba(248,81,73,.08);border:1px solid rgba(248,81,73,.3);
|
|
807
|
+
border-radius:6px;padding:10px 14px;margin-bottom:16px;
|
|
808
|
+
font-size:12px;color:var(--red);
|
|
809
|
+
}
|
|
810
|
+
.reg-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:11px}
|
|
811
|
+
.tbl-foot{color:var(--subtle);font-size:11px;padding:10px 14px;text-align:right}
|
|
812
|
+
|
|
813
|
+
/* ── forms & action buttons ── */
|
|
814
|
+
.form-row{margin-bottom:14px}
|
|
815
|
+
.form-lbl{display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);margin-bottom:6px}
|
|
816
|
+
.form-lbl small{text-transform:none;letter-spacing:0;font-weight:400;color:var(--subtle)}
|
|
817
|
+
.fi{width:100%;background:var(--surf2);border:1px solid var(--border);border-radius:6px;padding:9px 12px;color:var(--text);font-size:13px;outline:none;font-family:inherit;transition:border-color .15s;resize:vertical}
|
|
818
|
+
.fi:focus{border-color:var(--blue)}
|
|
819
|
+
select.fi option{background:var(--surf2)}
|
|
820
|
+
.fi-mono{font-family:var(--font);font-size:12px}
|
|
821
|
+
.form-err{color:var(--red);font-size:12px;padding:9px 13px;background:rgba(248,81,73,.08);border:1px solid rgba(248,81,73,.3);border-radius:5px;margin-top:10px;display:none}
|
|
822
|
+
.form-ok{color:var(--green);font-size:12px;padding:9px 13px;background:rgba(63,185,80,.08);border:1px solid rgba(63,185,80,.3);border-radius:5px;margin-top:10px;display:none}
|
|
823
|
+
.sec-note{padding:10px 14px;border-radius:6px;font-size:12px;margin-bottom:14px;line-height:1.6}
|
|
824
|
+
.sec-note-info{background:rgba(88,166,255,.07);border:1px solid rgba(88,166,255,.25);color:var(--blue)}
|
|
825
|
+
.sec-note-warn{background:rgba(248,81,73,.07);border:1px solid rgba(248,81,73,.25);color:var(--red)}
|
|
826
|
+
.sec-note-tip{background:rgba(210,153,34,.07);border:1px solid rgba(210,153,34,.25);color:var(--yellow)}
|
|
827
|
+
.radio-grp{display:flex;gap:8px;flex-wrap:wrap}
|
|
828
|
+
.radio-card{display:flex;align-items:center;gap:8px;padding:9px 14px;border:1px solid var(--border);border-radius:6px;cursor:pointer;transition:all .15s;flex:1;min-width:70px}
|
|
829
|
+
.radio-card:has(input:checked){border-color:var(--blue);background:rgba(88,166,255,.07)}
|
|
830
|
+
.radio-card input{accent-color:var(--blue)}
|
|
831
|
+
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all .15s;font-family:inherit;text-decoration:none;line-height:1.4;white-space:nowrap}
|
|
832
|
+
.btn:disabled{opacity:.4;cursor:not-allowed}
|
|
833
|
+
.btn-p{background:var(--blue);color:#000}.btn-p:hover:not(:disabled){filter:brightness(1.1)}
|
|
834
|
+
.btn-g{background:var(--green);color:#000}.btn-g:hover:not(:disabled){filter:brightness(1.1)}
|
|
835
|
+
.btn-r{background:rgba(248,81,73,.15);color:var(--red);border:1px solid rgba(248,81,73,.3)}.btn-r:hover:not(:disabled){background:rgba(248,81,73,.25)}
|
|
836
|
+
.btn-d{background:var(--surf2);color:var(--text);border:1px solid var(--border)}.btn-d:hover:not(:disabled){border-color:var(--blue)}
|
|
837
|
+
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:20px}
|
|
838
|
+
.act-btns{display:flex;gap:8px;flex-wrap:wrap;padding:14px 0 6px}
|
|
839
|
+
.hdr-btn{padding:5px 13px;border:1px solid var(--border);background:none;color:var(--dim);border-radius:6px;cursor:pointer;font-size:12px;font-family:inherit;transition:all .15s}
|
|
840
|
+
.hdr-btn:hover{color:var(--text);border-color:var(--blue)}
|
|
841
|
+
.hdr-btn-p{border-color:var(--blue);color:var(--blue);background:rgba(88,166,255,.08)}
|
|
842
|
+
.hdr-btn-p:hover{background:rgba(88,166,255,.15)}
|
|
843
|
+
</style>
|
|
844
|
+
</head>
|
|
845
|
+
<body>
|
|
846
|
+
|
|
847
|
+
<header>
|
|
848
|
+
<div class="logo"><span class="logo-hex">⬡</span> CKB Firewall</div>
|
|
849
|
+
<nav>
|
|
850
|
+
<button class="tab-btn active" data-tab="overview">Overview</button>
|
|
851
|
+
<button class="tab-btn" data-tab="registry">Registry</button>
|
|
852
|
+
<button class="tab-btn" data-tab="proposals">Proposals</button>
|
|
853
|
+
</nav>
|
|
854
|
+
<div style="display:flex;gap:6px;align-items:center;margin-left:auto">
|
|
855
|
+
<button class="hdr-btn hdr-btn-p" onclick="openCreate()">+ New Proposal</button>
|
|
856
|
+
<button class="hdr-btn" onclick="openImportForm()">⇧ Import</button>
|
|
857
|
+
</div>
|
|
858
|
+
<div class="hdr-status" style="margin-left:12px">
|
|
859
|
+
<span class="dot spin" id="dot"></span>
|
|
860
|
+
<span id="status-txt">Connecting…</span>
|
|
861
|
+
</div>
|
|
862
|
+
</header>
|
|
863
|
+
|
|
864
|
+
<div id="page-overview" class="page active"></div>
|
|
865
|
+
<div id="page-registry" class="page">
|
|
866
|
+
<div class="fbar">
|
|
867
|
+
<input id="reg-q" class="inp srch" placeholder="Search lock-args…" oninput="renderReg()">
|
|
868
|
+
<label class="ck"><input type="checkbox" id="reg-exp" onchange="renderReg()"> Show expired</label>
|
|
869
|
+
</div>
|
|
870
|
+
<div id="reg-body"></div>
|
|
871
|
+
</div>
|
|
872
|
+
<div id="page-proposals" class="page">
|
|
873
|
+
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
|
|
874
|
+
<button class="btn btn-p" onclick="openCreate()">+ New Proposal</button>
|
|
875
|
+
</div>
|
|
876
|
+
<div class="fbar">
|
|
877
|
+
<div class="ftabs" id="prop-ftabs">
|
|
878
|
+
<button class="ftab active" data-s="" onclick="setPF('')">All</button>
|
|
879
|
+
<button class="ftab" data-s="pending-review" onclick="setPF('pending-review')">Pending Review</button>
|
|
880
|
+
<button class="ftab" data-s="voting" onclick="setPF('voting')">Voting</button>
|
|
881
|
+
<button class="ftab" data-s="approved" onclick="setPF('approved')">Approved</button>
|
|
882
|
+
<button class="ftab" data-s="executed" onclick="setPF('executed')">Executed</button>
|
|
883
|
+
<button class="ftab" data-s="rejected" onclick="setPF('rejected')">Rejected</button>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
<div id="prop-body"></div>
|
|
887
|
+
</div>
|
|
888
|
+
|
|
889
|
+
<div class="overlay hidden" id="overlay">
|
|
890
|
+
<div class="modal" id="modal">
|
|
891
|
+
<div class="mhdr">
|
|
892
|
+
<div class="mtitle" id="m-title"></div>
|
|
893
|
+
<button class="mclose" id="m-close">✕</button>
|
|
894
|
+
</div>
|
|
895
|
+
<div class="mbody" id="m-body"></div>
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
898
|
+
|
|
899
|
+
<div class="overlay hidden" id="act-overlay">
|
|
900
|
+
<div class="modal" id="act-modal" style="max-width:540px">
|
|
901
|
+
<div class="mhdr">
|
|
902
|
+
<div class="mtitle" id="act-title"></div>
|
|
903
|
+
<button class="mclose" id="act-close">✕</button>
|
|
904
|
+
</div>
|
|
905
|
+
<div class="mbody" id="act-body"></div>
|
|
906
|
+
</div>
|
|
907
|
+
</div>
|
|
908
|
+
|
|
909
|
+
<script>
|
|
910
|
+
'use strict';
|
|
911
|
+
|
|
912
|
+
// ── state ────────────────────────────────────────────────────────────────────
|
|
913
|
+
var D = { proposals: [], registry: null, meta: null };
|
|
914
|
+
var UI = { tab: 'overview', pf: '', err: null };
|
|
915
|
+
|
|
916
|
+
// ── utils ────────────────────────────────────────────────────────────────────
|
|
917
|
+
function h(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
918
|
+
function trunc(s,n){ return (!s||s.length<=n)?s:s.slice(0,Math.floor(n/2))+'…'+s.slice(-Math.floor(n/2)-1); }
|
|
919
|
+
function fmtDate(iso){ if(!iso)return'—'; var d=new Date(iso); return d.toISOString().slice(0,16).replace('T',' ')+' UTC'; }
|
|
920
|
+
function countdown(iso){
|
|
921
|
+
var ms=new Date(iso).getTime()-Date.now();
|
|
922
|
+
if(ms<=0)return '<span style="color:var(--green)">✓ passed</span>';
|
|
923
|
+
var h2=Math.floor(ms/3600000),m=Math.floor((ms%3600000)/60000);
|
|
924
|
+
return '<span style="color:var(--yellow)">'+h2+'h '+m+'m remaining</span>';
|
|
925
|
+
}
|
|
926
|
+
function countYes(votes){ return (votes||[]).filter(function(v){return v.vote==='yes';}).length; }
|
|
927
|
+
function reviewPassed(p){ return Date.now()>=new Date(p.reviewWindowEndsAt).getTime(); }
|
|
928
|
+
function isReady(p){ return reviewPassed(p)&&countYes(p.votes)>=3; }
|
|
929
|
+
function prog(v,max){
|
|
930
|
+
var pct=Math.min(100,Math.round(v/max*100));
|
|
931
|
+
var cls=v>=max?'prog-g':'prog-y';
|
|
932
|
+
return '<span class="prog"><span class="prog-track"><span class="prog-fill '+cls+'" style="width:'+pct+'%"></span></span>'+v+'/'+max+'</span>';
|
|
933
|
+
}
|
|
934
|
+
function statusBadge(p){
|
|
935
|
+
if(isReady(p)) return '<span class="badge b-green">ready to execute</span>';
|
|
936
|
+
var m={'pending-review':'<span class="badge b-yellow">pending review</span>','voting':'<span class="badge b-cyan">voting</span>','approved':'<span class="badge b-blue">approved</span>','executed':'<span class="badge b-dim">executed</span>','rejected':'<span class="badge b-red">rejected</span>'};
|
|
937
|
+
return m[p.status]||'<span class="badge b-dim">'+h(p.status)+'</span>';
|
|
938
|
+
}
|
|
939
|
+
function actionBadge(p){
|
|
940
|
+
var cls=p.action==='add'?'add-col':'rem-col';
|
|
941
|
+
return '<span class="card-action '+cls+'">'+h(p.action)+'</span>';
|
|
942
|
+
}
|
|
943
|
+
function empty(txt,sub){
|
|
944
|
+
return '<div class="empty"><div class="empty-ico">○</div><div class="empty-txt">'+h(txt)+'</div><div class="empty-sub">'+h(sub)+'</div></div>';
|
|
945
|
+
}
|
|
946
|
+
function cmdRow(label,cmd,key){
|
|
947
|
+
return '<div class="cmd-lbl">'+h(label)+'</div>'
|
|
948
|
+
+'<div class="cmd"><span class="cmd-text">'+h(cmd)+'</span>'
|
|
949
|
+
+'<button class="copy-btn" data-copy="'+h(cmd)+'" data-key="'+h(key)+'">Copy</button></div>';
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// lookup maps
|
|
953
|
+
function pByAddr(){
|
|
954
|
+
var m={};
|
|
955
|
+
D.proposals.forEach(function(p){ var k=(p.lockArgs||'').toLowerCase(); if(!m[k])m[k]=[]; m[k].push(p); });
|
|
956
|
+
return m;
|
|
957
|
+
}
|
|
958
|
+
function regByAddr(){
|
|
959
|
+
var m={};
|
|
960
|
+
if(D.registry&&D.registry.entries) D.registry.entries.forEach(function(e){ m[e.identifier.toLowerCase()]=e; });
|
|
961
|
+
return m;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ── data fetch ────────────────────────────────────────────────────────────────
|
|
965
|
+
function load(){
|
|
966
|
+
document.getElementById('dot').className='dot spin';
|
|
967
|
+
return fetch('/api/data')
|
|
968
|
+
.then(function(r){return r.json();})
|
|
969
|
+
.then(function(d){
|
|
970
|
+
D.proposals=d.proposals||[];
|
|
971
|
+
D.registry=d.registry||{entries:[],error:null};
|
|
972
|
+
D.meta=d.meta||{};
|
|
973
|
+
UI.err=null;
|
|
974
|
+
document.getElementById('dot').className=D.registry.error?'dot err':'dot ok';
|
|
975
|
+
document.getElementById('status-txt').textContent='Updated '+new Date().toLocaleTimeString();
|
|
976
|
+
render();
|
|
977
|
+
})
|
|
978
|
+
.catch(function(e){
|
|
979
|
+
UI.err=e.message;
|
|
980
|
+
document.getElementById('dot').className='dot err';
|
|
981
|
+
document.getElementById('status-txt').textContent='Error';
|
|
982
|
+
render();
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
setInterval(load,15000);
|
|
986
|
+
|
|
987
|
+
// ── tab routing ───────────────────────────────────────────────────────────────
|
|
988
|
+
document.querySelectorAll('.tab-btn').forEach(function(b){
|
|
989
|
+
b.addEventListener('click',function(){ setTab(b.dataset.tab); });
|
|
990
|
+
});
|
|
991
|
+
function setTab(name){
|
|
992
|
+
UI.tab=name;
|
|
993
|
+
document.querySelectorAll('.tab-btn').forEach(function(b){ b.classList.toggle('active',b.dataset.tab===name); });
|
|
994
|
+
document.querySelectorAll('.page').forEach(function(p){ p.classList.toggle('active',p.id==='page-'+name); });
|
|
995
|
+
}
|
|
996
|
+
function setPF(s){
|
|
997
|
+
UI.pf=s;
|
|
998
|
+
document.querySelectorAll('.ftab').forEach(function(b){ b.classList.toggle('active',b.dataset.s===s); });
|
|
999
|
+
renderProps();
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ── overview ──────────────────────────────────────────────────────────────────
|
|
1003
|
+
function renderOverview(){
|
|
1004
|
+
var ps=D.proposals;
|
|
1005
|
+
var entries=(D.registry&&D.registry.entries)||[];
|
|
1006
|
+
var now=Date.now()/1000;
|
|
1007
|
+
var active=entries.filter(function(e){return !e.expiresAt||Number(e.expiresAt)>now;}).length;
|
|
1008
|
+
var expired=entries.filter(function(e){return e.expiresAt&&Number(e.expiresAt)<=now;}).length;
|
|
1009
|
+
var open=ps.filter(function(p){return p.status==='pending-review'||p.status==='voting';});
|
|
1010
|
+
var ready=ps.filter(isReady);
|
|
1011
|
+
var approved=ps.filter(function(p){return p.status==='approved'&&!isReady(p);});
|
|
1012
|
+
var executed=ps.filter(function(p){return p.status==='executed';});
|
|
1013
|
+
|
|
1014
|
+
var out='';
|
|
1015
|
+
if(UI.err) out+='<div class="err-bar">⚠ Could not reach server: '+h(UI.err)+'</div>';
|
|
1016
|
+
if(D.registry&&D.registry.error) out+='<div class="err-bar">⚠ Registry unavailable — '+h(D.registry.error)+' <span style="color:var(--subtle);font-size:11px">(proposals still shown below)</span></div>';
|
|
1017
|
+
|
|
1018
|
+
// stats
|
|
1019
|
+
out+='<div class="stats">';
|
|
1020
|
+
out+=stat(String(active),'Active Entries',expired?expired+' expired':'','var(--cyan)');
|
|
1021
|
+
out+=stat(String(open.length),'Open for Voting',open.length?'need your vote':'','var(--blue)');
|
|
1022
|
+
out+=stat(String(ready.length),'Ready to Execute','','var(--green)');
|
|
1023
|
+
out+=stat(String(approved.length),'Awaiting Sigs','','var(--yellow)');
|
|
1024
|
+
out+=stat(String(executed.length),'Executed','historical','var(--dim)');
|
|
1025
|
+
out+='</div>';
|
|
1026
|
+
|
|
1027
|
+
// action required
|
|
1028
|
+
var action=ready.concat(open);
|
|
1029
|
+
if(action.length){
|
|
1030
|
+
out+='<div class="section"><div class="sec-hdr">⚡ Action Required <span class="badge b-yellow" style="margin-left:4px">'+action.length+'</span></div>';
|
|
1031
|
+
action.forEach(function(p){ out+=propCard(p,true); });
|
|
1032
|
+
out+='</div>';
|
|
1033
|
+
}
|
|
1034
|
+
// recent
|
|
1035
|
+
var recent=ps.slice().sort(function(a,b){return b.submittedAt.localeCompare(a.submittedAt);}).slice(0,6);
|
|
1036
|
+
if(recent.length){
|
|
1037
|
+
out+='<div class="section"><div class="sec-hdr">📋 Recent Proposals</div>';
|
|
1038
|
+
recent.forEach(function(p){ out+=propCard(p,false); });
|
|
1039
|
+
out+='</div>';
|
|
1040
|
+
} else if(!UI.err){
|
|
1041
|
+
out+=empty('No proposals yet','Run: ckb-firewall propose');
|
|
1042
|
+
}
|
|
1043
|
+
document.getElementById('page-overview').innerHTML=out;
|
|
1044
|
+
}
|
|
1045
|
+
function stat(val,lbl,sub,color){
|
|
1046
|
+
return '<div class="stat"><div class="stat-val" style="color:'+color+'">'+h(val)+'</div>'
|
|
1047
|
+
+'<div class="stat-lbl">'+h(lbl)+'</div>'+(sub?'<div class="stat-sub">'+h(sub)+'</div>':'')+'</div>';
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ── registry ──────────────────────────────────────────────────────────────────
|
|
1051
|
+
function renderReg(){
|
|
1052
|
+
var q=((document.getElementById('reg-q')||{}).value||'').toLowerCase().trim();
|
|
1053
|
+
var showExp=((document.getElementById('reg-exp')||{}).checked)||false;
|
|
1054
|
+
var pa=pByAddr();
|
|
1055
|
+
var out='';
|
|
1056
|
+
|
|
1057
|
+
if(!D.registry){out+='<div style="color:var(--dim);padding:40px;text-align:center">Loading registry…</div>';document.getElementById('reg-body').innerHTML=out;return;}
|
|
1058
|
+
if(D.registry.error) out+='<div class="err-bar">'+h(D.registry.error)+'</div>';
|
|
1059
|
+
|
|
1060
|
+
var now=Date.now()/1000;
|
|
1061
|
+
var all=D.registry.entries||[];
|
|
1062
|
+
var shown=all.filter(function(e){
|
|
1063
|
+
var exp=e.expiresAt?Number(e.expiresAt):null;
|
|
1064
|
+
var isExp=exp&&exp<=now;
|
|
1065
|
+
if(!showExp&&isExp)return false;
|
|
1066
|
+
if(q&&!e.identifier.toLowerCase().includes(q))return false;
|
|
1067
|
+
return true;
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
if(!shown.length){
|
|
1071
|
+
document.getElementById('reg-body').innerHTML=empty(q?'No matching entries':'Registry is empty',q?'Try a different search':'');
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
out+='<div class="tbl-wrap"><table><thead><tr>'
|
|
1076
|
+
+'<th>Lock Args</th><th>Expires</th><th>Status</th><th>Proposals</th>'
|
|
1077
|
+
+'</tr></thead><tbody>';
|
|
1078
|
+
shown.forEach(function(e){
|
|
1079
|
+
var exp=e.expiresAt?Number(e.expiresAt):null;
|
|
1080
|
+
var isExp=exp&&exp<=now;
|
|
1081
|
+
var expTxt=!exp?'never':new Date(exp*1000).toISOString().slice(0,10);
|
|
1082
|
+
var statusCell=isExp?'<span class="badge b-red">expired</span>':'<span class="badge b-green">active</span>';
|
|
1083
|
+
var rel=pa[e.identifier.toLowerCase()]||[];
|
|
1084
|
+
var propCell=!rel.length?'<span style="color:var(--subtle)">—</span>':rel.map(function(p){
|
|
1085
|
+
var bc=p.status==='executed'?'b-dim':p.status==='rejected'?'b-red':'b-yellow';
|
|
1086
|
+
return '<span class="badge '+bc+' click" style="cursor:pointer;margin-right:4px" data-pid="'+h(p.id)+'">'+h(p.action)+' #'+h(p.id)+'</span>';
|
|
1087
|
+
}).join('');
|
|
1088
|
+
out+='<tr class="click" data-addr="'+h(e.identifier)+'">'
|
|
1089
|
+
+'<td><code class="la">'+h(e.identifier)+'</code></td>'
|
|
1090
|
+
+'<td style="color:'+(isExp?'var(--red)':'var(--dim)')+'">'+h(expTxt)+'</td>'
|
|
1091
|
+
+'<td>'+statusCell+'</td>'
|
|
1092
|
+
+'<td>'+propCell+'</td>'
|
|
1093
|
+
+'</tr>';
|
|
1094
|
+
});
|
|
1095
|
+
out+='</tbody></table>';
|
|
1096
|
+
out+='<div class="tbl-foot">'+shown.length+' of '+all.length+' entries'+(D.registry.txHash?' · cell <code style="font-size:11px">'+h(D.registry.txHash.slice(0,18))+'…</code>':'')+'</div>';
|
|
1097
|
+
out+='</div>';
|
|
1098
|
+
document.getElementById('reg-body').innerHTML=out;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ── proposals ─────────────────────────────────────────────────────────────────
|
|
1102
|
+
function renderProps(){
|
|
1103
|
+
var ps=D.proposals.filter(function(p){return !UI.pf||p.status===UI.pf;})
|
|
1104
|
+
.slice().sort(function(a,b){return b.submittedAt.localeCompare(a.submittedAt);});
|
|
1105
|
+
document.getElementById('prop-body').innerHTML=
|
|
1106
|
+
ps.length?ps.map(function(p){return propCard(p,false);}).join(''):empty('No proposals','Run: ckb-firewall propose');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ── proposal card ─────────────────────────────────────────────────────────────
|
|
1110
|
+
function propCard(p,compact){
|
|
1111
|
+
var rb=regByAddr();
|
|
1112
|
+
var e=rb[(p.lockArgs||'').toLowerCase()];
|
|
1113
|
+
var now=Date.now()/1000;
|
|
1114
|
+
var inReg=!!e;
|
|
1115
|
+
var isExpReg=inReg&&e.expiresAt&&Number(e.expiresAt)<=now;
|
|
1116
|
+
var regHint=!inReg?'<span style="color:var(--subtle);font-size:11px">not in registry</span>'
|
|
1117
|
+
:isExpReg?'<span class="badge b-dim">registry: expired</span>'
|
|
1118
|
+
:'<span class="badge b-green">registry: active</span>';
|
|
1119
|
+
|
|
1120
|
+
var out='<div class="card" data-pid="'+h(p.id)+'">';
|
|
1121
|
+
out+='<div class="card-top">';
|
|
1122
|
+
out+=actionBadge(p);
|
|
1123
|
+
out+='<code class="card-args">'+trunc(h(p.lockArgs),42)+'</code>';
|
|
1124
|
+
out+='<span class="card-id">#'+h(p.id)+'</span>';
|
|
1125
|
+
out+='<span style="flex:1"></span>';
|
|
1126
|
+
out+=statusBadge(p);
|
|
1127
|
+
out+='</div>';
|
|
1128
|
+
out+='<div class="card-foot">';
|
|
1129
|
+
out+=prog(countYes(p.votes),3);
|
|
1130
|
+
out+='<span style="flex:1"></span>';
|
|
1131
|
+
out+=regHint;
|
|
1132
|
+
// quick action buttons
|
|
1133
|
+
if(p.status==='pending-review'||p.status==='voting'){
|
|
1134
|
+
out+='<button class="btn btn-p" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();openVoteForm(\''+h(p.id)+'\')">Vote →</button>';
|
|
1135
|
+
} else if(isReady(p)){
|
|
1136
|
+
out+='<button class="btn btn-p" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();openExecuteForm(\''+h(p.id)+'\')">Execute →</button>';
|
|
1137
|
+
}
|
|
1138
|
+
out+='<a class="btn btn-d" style="padding:3px 10px;font-size:11px" href="/api/export/'+h(p.id)+'" download="proposal-'+h(p.id)+'.json" onclick="event.stopPropagation()">↓ JSON</a>';
|
|
1139
|
+
out+='</div>';
|
|
1140
|
+
if(!compact){
|
|
1141
|
+
out+='<div class="card-meta">';
|
|
1142
|
+
out+=h(p.classification)+' / '+h(p.severity)+' · ';
|
|
1143
|
+
out+='Review: '+countdown(p.reviewWindowEndsAt)+' · ';
|
|
1144
|
+
out+='by '+h(p.proposer);
|
|
1145
|
+
if(p.evidence){
|
|
1146
|
+
var isUrl=/^https?:\/\//.test(p.evidence);
|
|
1147
|
+
out+='<br>Evidence: '+(isUrl?'<a href="'+h(p.evidence)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">'+h(p.evidence)+'</a>':h(p.evidence.slice(0,80))+(p.evidence.length>80?'…':''));
|
|
1148
|
+
}
|
|
1149
|
+
out+='</div>';
|
|
1150
|
+
}
|
|
1151
|
+
out+='</div>';
|
|
1152
|
+
return out;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// ── proposal modal ────────────────────────────────────────────────────────────
|
|
1156
|
+
function openProposal(id){
|
|
1157
|
+
var p=D.proposals.find(function(x){return x.id===id;});
|
|
1158
|
+
if(!p)return;
|
|
1159
|
+
var rb=regByAddr();
|
|
1160
|
+
var e=rb[(p.lockArgs||'').toLowerCase()];
|
|
1161
|
+
var now=Date.now()/1000;
|
|
1162
|
+
var inReg=!!e;
|
|
1163
|
+
var isExpReg=inReg&&e.expiresAt&&Number(e.expiresAt)<=now;
|
|
1164
|
+
|
|
1165
|
+
var regVal=!inReg?'<span style="color:var(--subtle)">not in registry</span>'
|
|
1166
|
+
:isExpReg?'<span class="badge b-dim">expired — '+new Date(Number(e.expiresAt)*1000).toISOString().slice(0,10)+'</span>'
|
|
1167
|
+
:e.expiresAt?'<span class="badge b-green">active — expires '+new Date(Number(e.expiresAt)*1000).toISOString().slice(0,10)+'</span>'
|
|
1168
|
+
:'<span class="badge b-green">active — permanent</span>';
|
|
1169
|
+
|
|
1170
|
+
var out='';
|
|
1171
|
+
|
|
1172
|
+
// metadata
|
|
1173
|
+
out+='<div class="msec"><div class="kv">';
|
|
1174
|
+
out+='<span class="kk">Action</span><span class="kv-val">'+actionBadge(p)+'</span>';
|
|
1175
|
+
out+='<span class="kk">Lock Args</span><span class="kv-val"><code style="font-size:11px;word-break:break-all">'+h(p.lockArgs)+'</code></span>';
|
|
1176
|
+
out+='<span class="kk">Registry</span><span class="kv-val">'+regVal+'</span>';
|
|
1177
|
+
out+='<span class="kk">Classification</span><span class="kv-val">'+h(p.classification)+' / '+h(p.severity)+'</span>';
|
|
1178
|
+
out+='<span class="kk">Proposer</span><span class="kv-val">'+h(p.proposer)+'</span>';
|
|
1179
|
+
out+='<span class="kk">Submitted</span><span class="kv-val">'+fmtDate(p.submittedAt)+'</span>';
|
|
1180
|
+
out+='<span class="kk">Review ends</span><span class="kv-val">'+countdown(p.reviewWindowEndsAt)+'</span>';
|
|
1181
|
+
if(p.expiresAt&&p.expiresAt!=='0'){
|
|
1182
|
+
out+='<span class="kk">Entry expires</span><span class="kv-val">'+fmtDate(new Date(Number(p.expiresAt)*1000).toISOString())+'</span>';
|
|
1183
|
+
}
|
|
1184
|
+
if(p.txHash){
|
|
1185
|
+
out+='<span class="kk">Tx Hash</span><span class="kv-val"><code style="font-size:11px">'+h(p.txHash)+'</code></span>';
|
|
1186
|
+
}
|
|
1187
|
+
out+='</div>';
|
|
1188
|
+
|
|
1189
|
+
if(p.evidence){
|
|
1190
|
+
var isUrl=/^https?:\/\//.test(p.evidence);
|
|
1191
|
+
out+='<div style="margin-top:12px"><div style="font-size:11px;color:var(--dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:.6px">Evidence</div>';
|
|
1192
|
+
out+=isUrl?'<a href="'+h(p.evidence)+'" target="_blank" rel="noopener" style="font-size:13px">'+h(p.evidence)+'</a>'
|
|
1193
|
+
:'<p style="font-size:13px;color:var(--dim)">'+h(p.evidence)+'</p>';
|
|
1194
|
+
out+='</div>';
|
|
1195
|
+
}
|
|
1196
|
+
if(p.rationale){
|
|
1197
|
+
out+='<div style="margin-top:12px"><div style="font-size:11px;color:var(--dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:.6px">Rationale</div>';
|
|
1198
|
+
out+='<p style="font-size:13px;color:var(--dim);line-height:1.7">'+h(p.rationale)+'</p></div>';
|
|
1199
|
+
}
|
|
1200
|
+
out+='</div>';
|
|
1201
|
+
|
|
1202
|
+
// votes
|
|
1203
|
+
out+='<div class="msec"><div class="msec-ttl">Votes '+prog(countYes(p.votes),3)+'</div>';
|
|
1204
|
+
var votes=p.votes||[];
|
|
1205
|
+
if(!votes.length){out+='<div style="color:var(--subtle);font-size:12px">No votes yet.</div>';}
|
|
1206
|
+
else{votes.forEach(function(v){
|
|
1207
|
+
var vc=v.vote==='yes'?'vy':v.vote==='no'?'vn':'va';
|
|
1208
|
+
out+='<div class="vrow"><span class="vch '+vc+'">'+v.vote.toUpperCase()+'</span>'
|
|
1209
|
+
+'<code class="vpk">'+h(v.pubkey.slice(0,20))+'…</code>'
|
|
1210
|
+
+'<span class="vts">'+fmtDate(v.timestamp)+'</span></div>';
|
|
1211
|
+
});}
|
|
1212
|
+
out+='</div>';
|
|
1213
|
+
|
|
1214
|
+
// Actions
|
|
1215
|
+
out+='<div class="msec"><div class="msec-ttl">Actions</div><div class="act-btns">';
|
|
1216
|
+
if(p.status==='pending-review'||p.status==='voting'){
|
|
1217
|
+
out+='<button class="btn btn-p" onclick="closeModal();openVoteForm(\''+h(p.id)+'\')">Cast Vote</button>';
|
|
1218
|
+
}
|
|
1219
|
+
if(isReady(p)){
|
|
1220
|
+
out+='<button class="btn btn-p" onclick="closeModal();openExecuteForm(\''+h(p.id)+'\')">Build Execute TX</button>';
|
|
1221
|
+
}
|
|
1222
|
+
out+='<a class="btn btn-d" href="/api/export/'+h(p.id)+'" download="proposal-'+h(p.id)+'.json">↓ Export JSON</a>';
|
|
1223
|
+
out+='</div></div>';
|
|
1224
|
+
|
|
1225
|
+
// CLI commands
|
|
1226
|
+
out+='<div class="msec"><div class="msec-ttl">CLI Commands</div>';
|
|
1227
|
+
if(p.status==='pending-review'||p.status==='voting'){
|
|
1228
|
+
out+=cmdRow('Vote on this proposal','ckb-firewall vote --proposal '+p.id,'cmd-v');
|
|
1229
|
+
}
|
|
1230
|
+
if(isReady(p)){
|
|
1231
|
+
out+=cmdRow('Execute on-chain','ckb-firewall execute --proposal '+p.id,'cmd-e');
|
|
1232
|
+
}
|
|
1233
|
+
out+=cmdRow('Export to JSON file','ckb-firewall export --proposal '+p.id+' --out proposal-'+p.id+'.json','cmd-ex');
|
|
1234
|
+
out+=cmdRow('Check address in registry','ckb-firewall check --lock-args '+p.lockArgs,'cmd-ck');
|
|
1235
|
+
out+='</div>';
|
|
1236
|
+
|
|
1237
|
+
document.getElementById('m-title').innerHTML=
|
|
1238
|
+
actionBadge(p)+' <code style="font-size:12px">'+trunc(h(p.lockArgs),40)+'</code>'
|
|
1239
|
+
+' <span style="color:var(--subtle);font-size:12px">#'+h(p.id)+'</span>';
|
|
1240
|
+
document.getElementById('m-body').innerHTML=out;
|
|
1241
|
+
document.getElementById('overlay').classList.remove('hidden');
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ── address modal ─────────────────────────────────────────────────────────────
|
|
1245
|
+
function openAddr(identifier){
|
|
1246
|
+
var rb=regByAddr();
|
|
1247
|
+
var e=rb[identifier.toLowerCase()];
|
|
1248
|
+
var pa=pByAddr();
|
|
1249
|
+
var related=pa[identifier.toLowerCase()]||[];
|
|
1250
|
+
var now=Date.now()/1000;
|
|
1251
|
+
var exp=e&&e.expiresAt?Number(e.expiresAt):null;
|
|
1252
|
+
var isExp=exp&&exp<=now;
|
|
1253
|
+
|
|
1254
|
+
var out='<div class="msec">';
|
|
1255
|
+
out+='<div class="msec-ttl">Identifier</div>';
|
|
1256
|
+
out+='<div class="cmd"><span class="cmd-text">'+h(identifier)+'</span><button class="copy-btn" data-copy="'+h(identifier)+'" data-key="addr-c">Copy</button></div>';
|
|
1257
|
+
out+='<div style="margin-top:10px">';
|
|
1258
|
+
if(!e){out+='<span style="color:var(--subtle)">Not currently in registry</span>';}
|
|
1259
|
+
else if(isExp){out+='<span class="badge b-red">Expired</span> <span style="color:var(--dim);font-size:12px">since '+new Date(exp*1000).toISOString().slice(0,10)+'</span>';}
|
|
1260
|
+
else if(exp){out+='<span class="badge b-green">Active</span> <span style="color:var(--dim);font-size:12px">until '+new Date(exp*1000).toISOString().slice(0,10)+'</span>';}
|
|
1261
|
+
else{out+='<span class="badge b-green">Active — permanent</span>';}
|
|
1262
|
+
out+='</div></div>';
|
|
1263
|
+
|
|
1264
|
+
out+='<div class="msec"><div class="msec-ttl">Check Command</div>';
|
|
1265
|
+
out+=cmdRow('','ckb-firewall check --lock-args '+identifier,'addr-ck');
|
|
1266
|
+
out+='</div>';
|
|
1267
|
+
|
|
1268
|
+
if(related.length){
|
|
1269
|
+
out+='<div class="msec"><div class="msec-ttl">Governance Proposals ('+related.length+')</div>';
|
|
1270
|
+
related.forEach(function(p){
|
|
1271
|
+
out+='<div class="card" data-pid="'+h(p.id)+'" style="margin-bottom:8px">';
|
|
1272
|
+
out+='<div class="card-top">'+actionBadge(p)+'<span class="card-id">#'+h(p.id)+'</span><span style="flex:1"></span>'+statusBadge(p)+'</div>';
|
|
1273
|
+
out+='<div class="card-meta">'+h(p.classification)+' / '+h(p.severity)+' · '+fmtDate(p.submittedAt)+'</div>';
|
|
1274
|
+
out+='<div class="card-foot">'+prog(countYes(p.votes),3)+'</div>';
|
|
1275
|
+
out+='</div>';
|
|
1276
|
+
});
|
|
1277
|
+
out+='</div>';
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
document.getElementById('m-title').innerHTML='<code style="font-size:12px">'+trunc(h(identifier),44)+'</code>';
|
|
1281
|
+
document.getElementById('m-body').innerHTML=out;
|
|
1282
|
+
document.getElementById('overlay').classList.remove('hidden');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// ── modal close ───────────────────────────────────────────────────────────────
|
|
1286
|
+
document.getElementById('m-close').addEventListener('click',function(){ closeModal(); });
|
|
1287
|
+
document.getElementById('overlay').addEventListener('click',function(ev){ if(ev.target===document.getElementById('overlay'))closeModal(); });
|
|
1288
|
+
document.getElementById('act-close').addEventListener('click',function(){ closeAct(); });
|
|
1289
|
+
document.getElementById('act-overlay').addEventListener('click',function(ev){ if(ev.target===document.getElementById('act-overlay'))closeAct(); });
|
|
1290
|
+
document.addEventListener('keydown',function(ev){ if(ev.key==='Escape'){ closeAct(); closeModal(); } });
|
|
1291
|
+
function closeModal(){ document.getElementById('overlay').classList.add('hidden'); }
|
|
1292
|
+
function closeAct(){ document.getElementById('act-overlay').classList.add('hidden'); }
|
|
1293
|
+
function openAct(title,html){
|
|
1294
|
+
document.getElementById('act-title').textContent=title;
|
|
1295
|
+
document.getElementById('act-body').innerHTML=html;
|
|
1296
|
+
document.getElementById('act-overlay').classList.remove('hidden');
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ── form helpers ──────────────────────────────────────────────────────────────
|
|
1300
|
+
function showErr(id,msg){ var el=document.getElementById(id); if(el){el.textContent=msg;el.style.display='block';} }
|
|
1301
|
+
function showOk(id,msg){ var el=document.getElementById(id); if(el){el.textContent=msg;el.style.display='block';} }
|
|
1302
|
+
function hideMsg(id){ var el=document.getElementById(id); if(el)el.style.display='none'; }
|
|
1303
|
+
function setDisabled(id,v){ var el=document.getElementById(id); if(el)el.disabled=v; }
|
|
1304
|
+
function fval(id){ var el=document.getElementById(id); return el?el.value:''; }
|
|
1305
|
+
function fsel(id){ var el=document.getElementById(id); return el?el.value:''; }
|
|
1306
|
+
function fcheck(id){ var el=document.getElementById(id); return el?el.checked:false; }
|
|
1307
|
+
|
|
1308
|
+
// ── create proposal form ──────────────────────────────────────────────────────
|
|
1309
|
+
function openCreate(){
|
|
1310
|
+
var html='';
|
|
1311
|
+
html+='<div class="sec-note sec-note-info">🔒 Your key is never sent to the server. Proposals are stored locally.</div>';
|
|
1312
|
+
html+='<div class="form-row"><label class="form-lbl">Action</label>';
|
|
1313
|
+
html+='<div class="radio-grp">';
|
|
1314
|
+
html+='<label class="radio-card"><input type="radio" name="c-action" value="add" checked onchange="toggleExpiresAt()"> <span>➕ Add to blacklist</span></label>';
|
|
1315
|
+
html+='<label class="radio-card"><input type="radio" name="c-action" value="remove" onchange="toggleExpiresAt()"> <span>➖ Remove from blacklist</span></label>';
|
|
1316
|
+
html+='</div></div>';
|
|
1317
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-lockargs">Lock Args <small>(0x-prefixed hex)</small></label>';
|
|
1318
|
+
html+='<input id="c-lockargs" class="fi fi-mono" placeholder="0xabc123..." autocomplete="off"></div>';
|
|
1319
|
+
html+='<div class="form-row" id="c-exprow"><label class="form-lbl" for="c-expires">Expires At <small>(Unix timestamp, 0 = never)</small></label>';
|
|
1320
|
+
html+='<input id="c-expires" class="fi" type="number" min="0" value="0" placeholder="0"></div>';
|
|
1321
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-cls">Classification</label>';
|
|
1322
|
+
html+='<select id="c-cls" class="fi"><option value="theft">theft</option><option value="scam">scam</option><option value="hack">hack</option><option value="sanctions">sanctions</option><option value="other">other</option></select></div>';
|
|
1323
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-sev">Severity</label>';
|
|
1324
|
+
html+='<select id="c-sev" class="fi"><option value="critical">critical</option><option value="high">high</option><option value="medium">medium</option><option value="low">low</option></select></div>';
|
|
1325
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-evidence">Evidence <small>(URL or description)</small></label>';
|
|
1326
|
+
html+='<textarea id="c-evidence" class="fi" rows="2" placeholder="https://... or describe the evidence"></textarea></div>';
|
|
1327
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-rationale">Rationale <small>(min 20 chars)</small></label>';
|
|
1328
|
+
html+='<textarea id="c-rationale" class="fi" rows="3" placeholder="Why should this address be added/removed..."></textarea></div>';
|
|
1329
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-proposer">Proposer <small>(your name or handle)</small></label>';
|
|
1330
|
+
html+='<input id="c-proposer" class="fi" placeholder="e.g. alice"></div>';
|
|
1331
|
+
html+='<div id="c-err" class="form-err"></div>';
|
|
1332
|
+
html+='<div id="c-ok" class="form-ok"></div>';
|
|
1333
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="c-submit" onclick="submitCreate()">Create Proposal</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1334
|
+
openAct('New Proposal',html);
|
|
1335
|
+
}
|
|
1336
|
+
function toggleExpiresAt(){
|
|
1337
|
+
var add=document.querySelector('input[name="c-action"]:checked');
|
|
1338
|
+
var row=document.getElementById('c-exprow');
|
|
1339
|
+
if(row) row.style.display=(add&&add.value==='add')?'':'none';
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function submitCreate(){
|
|
1343
|
+
hideMsg('c-err'); hideMsg('c-ok'); setDisabled('c-submit',true);
|
|
1344
|
+
var action=(document.querySelector('input[name="c-action"]:checked')||{value:'add'}).value;
|
|
1345
|
+
var body={action:action,lockArgs:fval('c-lockargs').trim(),evidence:fval('c-evidence').trim(),
|
|
1346
|
+
classification:fsel('c-cls'),severity:fsel('c-sev'),rationale:fval('c-rationale').trim(),
|
|
1347
|
+
proposer:fval('c-proposer').trim(),expiresAt:action==='add'?fval('c-expires')||'0':'0'};
|
|
1348
|
+
fetch('/api/propose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
1349
|
+
.then(function(r){return r.json();})
|
|
1350
|
+
.then(function(d){
|
|
1351
|
+
setDisabled('c-submit',false);
|
|
1352
|
+
if(!d.ok){showErr('c-err',d.error||'Unknown error');return;}
|
|
1353
|
+
showOk('c-ok','Proposal #'+d.id+' created!');
|
|
1354
|
+
load().then(function(){ setTimeout(closeAct,1200); });
|
|
1355
|
+
})
|
|
1356
|
+
.catch(function(e){ setDisabled('c-submit',false); showErr('c-err',e.message); });
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// ── vote form ─────────────────────────────────────────────────────────────────
|
|
1360
|
+
function openVoteForm(id){
|
|
1361
|
+
var p=D.proposals.find(function(x){return x.id===id;});
|
|
1362
|
+
if(!p)return;
|
|
1363
|
+
var html='';
|
|
1364
|
+
html+='<div class="sec-note sec-note-warn">🔒 Your private key is used only locally (loopback) and zeroed immediately after signing. It is never stored or logged.</div>';
|
|
1365
|
+
html+='<div class="form-row"><label class="form-lbl">Vote</label>';
|
|
1366
|
+
html+='<div class="radio-grp">';
|
|
1367
|
+
html+='<label class="radio-card"><input type="radio" name="v-vote" value="yes" checked> <span style="color:var(--green)">✓ Yes</span></label>';
|
|
1368
|
+
html+='<label class="radio-card"><input type="radio" name="v-vote" value="no"> <span style="color:var(--red)">✗ No</span></label>';
|
|
1369
|
+
html+='<label class="radio-card"><input type="radio" name="v-vote" value="abstain"> <span style="color:var(--yellow)">— Abstain</span></label>';
|
|
1370
|
+
html+='</div></div>';
|
|
1371
|
+
html+='<div class="form-row"><label class="form-lbl" for="v-pk">Private Key <small>(32 bytes, hex)</small></label>';
|
|
1372
|
+
html+='<input id="v-pk" class="fi fi-mono" type="password" placeholder="64 hex chars" autocomplete="off" autocorrect="off" spellcheck="false"></div>';
|
|
1373
|
+
html+='<div id="v-err" class="form-err"></div>';
|
|
1374
|
+
html+='<div id="v-ok" class="form-ok"></div>';
|
|
1375
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="v-submit" onclick="submitVote(\''+h(id)+'\')">Cast Vote</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1376
|
+
openAct('Vote — Proposal #'+h(id),html);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function submitVote(id){
|
|
1380
|
+
hideMsg('v-err'); hideMsg('v-ok'); setDisabled('v-submit',true);
|
|
1381
|
+
var choice=(document.querySelector('input[name="v-vote"]:checked')||{value:'yes'}).value;
|
|
1382
|
+
var body={proposalId:id,vote:choice,privateKey:fval('v-pk').trim()};
|
|
1383
|
+
fetch('/api/vote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
1384
|
+
.then(function(r){return r.json();})
|
|
1385
|
+
.then(function(d){
|
|
1386
|
+
setDisabled('v-submit',false);
|
|
1387
|
+
document.getElementById('v-pk').value='';
|
|
1388
|
+
if(!d.ok){showErr('v-err',d.error||'Unknown error');return;}
|
|
1389
|
+
showOk('v-ok','Vote recorded! Yes: '+d.yesCount+'/3 — Status: '+d.status);
|
|
1390
|
+
load().then(function(){ setTimeout(closeAct,1500); });
|
|
1391
|
+
})
|
|
1392
|
+
.catch(function(e){ setDisabled('v-submit',false); showErr('v-err',e.message); });
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// ── execute form ──────────────────────────────────────────────────────────────
|
|
1396
|
+
function openExecuteForm(id){
|
|
1397
|
+
var html='';
|
|
1398
|
+
html+='<div class="sec-note sec-note-tip">⚠ This verifies validator votes and builds the transaction JSON for download. Submit it via ckb-cli or a CKB wallet.</div>';
|
|
1399
|
+
html+='<div id="e-err" class="form-err"></div>';
|
|
1400
|
+
html+='<div id="e-ok" class="form-ok"></div>';
|
|
1401
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="e-submit" onclick="submitExecute(\''+h(id)+'\')">Build & Download TX</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1402
|
+
openAct('Execute — Proposal #'+h(id),html);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function submitExecute(id){
|
|
1406
|
+
hideMsg('e-err'); hideMsg('e-ok'); setDisabled('e-submit',true);
|
|
1407
|
+
fetch('/api/execute',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({proposalId:id})})
|
|
1408
|
+
.then(function(r){return r.json();})
|
|
1409
|
+
.then(function(d){
|
|
1410
|
+
setDisabled('e-submit',false);
|
|
1411
|
+
if(!d.ok){showErr('e-err',d.error||'Unknown error');return;}
|
|
1412
|
+
// trigger browser download
|
|
1413
|
+
var blob=new Blob([JSON.stringify(d.txJson,null,2)+'\n'],{type:'application/json'});
|
|
1414
|
+
var url=URL.createObjectURL(blob);
|
|
1415
|
+
var a=document.createElement('a');
|
|
1416
|
+
a.href=url; a.download=d.filename||('gov_execute_tx_'+id+'.json');
|
|
1417
|
+
document.body.appendChild(a); a.click();
|
|
1418
|
+
document.body.removeChild(a); URL.revokeObjectURL(url);
|
|
1419
|
+
showOk('e-ok','TX file downloaded: '+d.filename);
|
|
1420
|
+
})
|
|
1421
|
+
.catch(function(e){ setDisabled('e-submit',false); showErr('e-err',e.message); });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// ── import form ───────────────────────────────────────────────────────────────
|
|
1425
|
+
function openImportForm(){
|
|
1426
|
+
var html='';
|
|
1427
|
+
html+='<div class="sec-note sec-note-info">Paste or upload a proposal JSON exported from another governance participant.</div>';
|
|
1428
|
+
html+='<div class="form-row"><label class="form-lbl">Upload JSON file</label>';
|
|
1429
|
+
html+='<input type="file" accept=".json,application/json" class="fi" onchange="onImportFile(this)"></div>';
|
|
1430
|
+
html+='<div class="form-row"><label class="form-lbl" for="i-json">Or paste JSON</label>';
|
|
1431
|
+
html+='<textarea id="i-json" class="fi fi-mono" rows="10" placeholder=\'{"id":"...","proposalIdHash":"0x...",...}\'></textarea></div>';
|
|
1432
|
+
html+='<div id="i-err" class="form-err"></div>';
|
|
1433
|
+
html+='<div id="i-ok" class="form-ok"></div>';
|
|
1434
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="i-submit" onclick="submitImport()">Import</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1435
|
+
openAct('Import Proposal',html);
|
|
1436
|
+
}
|
|
1437
|
+
function onImportFile(input){
|
|
1438
|
+
var file=input.files&&input.files[0];
|
|
1439
|
+
if(!file)return;
|
|
1440
|
+
var reader=new FileReader();
|
|
1441
|
+
reader.onload=function(e){ var ta=document.getElementById('i-json'); if(ta)ta.value=e.target.result; };
|
|
1442
|
+
reader.readAsText(file);
|
|
1443
|
+
}
|
|
1444
|
+
function submitImport(){
|
|
1445
|
+
hideMsg('i-err'); hideMsg('i-ok'); setDisabled('i-submit',true);
|
|
1446
|
+
var raw=fval('i-json').trim();
|
|
1447
|
+
if(!raw){setDisabled('i-submit',false);showErr('i-err','Paste or upload a proposal JSON first.');return;}
|
|
1448
|
+
var parsed;
|
|
1449
|
+
try{ parsed=JSON.parse(raw); } catch(e){ setDisabled('i-submit',false); showErr('i-err','Invalid JSON: '+e.message); return; }
|
|
1450
|
+
fetch('/api/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(parsed)})
|
|
1451
|
+
.then(function(r){return r.json();})
|
|
1452
|
+
.then(function(d){
|
|
1453
|
+
setDisabled('i-submit',false);
|
|
1454
|
+
if(!d.ok){showErr('i-err',d.error||'Unknown error');return;}
|
|
1455
|
+
showOk('i-ok',d.merged?'Merged proposal #'+d.id+' ('+d.votes+' votes)':'Imported proposal #'+d.id);
|
|
1456
|
+
load().then(function(){ setTimeout(closeAct,1500); });
|
|
1457
|
+
})
|
|
1458
|
+
.catch(function(e){ setDisabled('i-submit',false); showErr('i-err',e.message); });
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ── event delegation ──────────────────────────────────────────────────────────
|
|
1462
|
+
document.addEventListener('click',function(ev){
|
|
1463
|
+
var t=ev.target;
|
|
1464
|
+
|
|
1465
|
+
// copy button
|
|
1466
|
+
if(t.classList&&t.classList.contains('copy-btn')){
|
|
1467
|
+
var txt=t.dataset.copy;
|
|
1468
|
+
if(!txt)return;
|
|
1469
|
+
if(navigator.clipboard){
|
|
1470
|
+
navigator.clipboard.writeText(txt).then(function(){
|
|
1471
|
+
t.textContent='Copied!';t.classList.add('ok');
|
|
1472
|
+
setTimeout(function(){t.textContent='Copy';t.classList.remove('ok');},2000);
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// proposal badge in registry table
|
|
1479
|
+
var pidEl=t.closest('[data-pid]');
|
|
1480
|
+
if(pidEl&&!t.closest('.modal')){
|
|
1481
|
+
openProposal(pidEl.dataset.pid);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// registry row
|
|
1486
|
+
var addrEl=t.closest('[data-addr]');
|
|
1487
|
+
if(addrEl){
|
|
1488
|
+
openAddr(addrEl.dataset.addr);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
// ── render ────────────────────────────────────────────────────────────────────
|
|
1494
|
+
function render(){
|
|
1495
|
+
renderOverview();
|
|
1496
|
+
renderReg();
|
|
1497
|
+
renderProps();
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// ── init ──────────────────────────────────────────────────────────────────────
|
|
1501
|
+
load();
|
|
1502
|
+
</script>
|
|
1503
|
+
</body>
|
|
1504
|
+
</html>`;
|
|
1505
|
+
/* eslint-enable max-len */
|
|
1506
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
1507
|
+
// createServer's callback must be synchronous — Node's http module does not
|
|
1508
|
+
// await Promises returned from request handlers. Any unhandled rejection from
|
|
1509
|
+
// an async handler silently drops the TCP connection, which the browser sees as
|
|
1510
|
+
// "Failed to fetch". We extract the async logic and attach a top-level .catch
|
|
1511
|
+
// so the connection always gets a response.
|
|
1512
|
+
async function handleRequest(req, res, opts) {
|
|
1513
|
+
const remoteAddr = req.socket.remoteAddress ?? "";
|
|
1514
|
+
if (remoteAddr !== "127.0.0.1" && remoteAddr !== "::1" && remoteAddr !== "::ffff:127.0.0.1") {
|
|
1515
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
1516
|
+
res.end("Forbidden");
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
const url = req.url ?? "/";
|
|
1520
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1521
|
+
if (url === "/" || url === "/index.html") {
|
|
1522
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
1523
|
+
if (_GUI_BUNDLE_PATH) {
|
|
1524
|
+
const apiData = await buildApiData(opts);
|
|
1525
|
+
res.end(_buildGuiHtml(apiData));
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
res.end(HTML);
|
|
1529
|
+
}
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (url === "/api/data" && method === "GET") {
|
|
1533
|
+
const body = await buildApiData(opts);
|
|
1534
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache" });
|
|
1535
|
+
res.end(body);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
// ── write endpoints ───────────────────────────────────────────────────────
|
|
1539
|
+
if (url === "/api/propose" && method === "POST") {
|
|
1540
|
+
try {
|
|
1541
|
+
apiOk(res, await handlePropose(await readJsonBody(req)));
|
|
1542
|
+
}
|
|
1543
|
+
catch (e) {
|
|
1544
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1545
|
+
}
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (url === "/api/vote" && method === "POST") {
|
|
1549
|
+
try {
|
|
1550
|
+
apiOk(res, await handleVote(await readJsonBody(req), opts));
|
|
1551
|
+
}
|
|
1552
|
+
catch (e) {
|
|
1553
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1554
|
+
}
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (url === "/api/anchor" && method === "POST") {
|
|
1558
|
+
try {
|
|
1559
|
+
apiOk(res, await handleAnchor(await readJsonBody(req), opts));
|
|
1560
|
+
}
|
|
1561
|
+
catch (e) {
|
|
1562
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1563
|
+
}
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (url === "/api/execute" && method === "POST") {
|
|
1567
|
+
try {
|
|
1568
|
+
apiOk(res, await handleExecute(await readJsonBody(req), opts));
|
|
1569
|
+
}
|
|
1570
|
+
catch (e) {
|
|
1571
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1572
|
+
}
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (url === "/api/import" && method === "POST") {
|
|
1576
|
+
try {
|
|
1577
|
+
apiOk(res, handleImport(await readJsonBody(req)));
|
|
1578
|
+
}
|
|
1579
|
+
catch (e) {
|
|
1580
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1581
|
+
}
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
// GET /api/export/:id
|
|
1585
|
+
const exportMatch = /^\/api\/export\/([a-f0-9]{12})$/i.exec(url);
|
|
1586
|
+
if (exportMatch && method === "GET") {
|
|
1587
|
+
try {
|
|
1588
|
+
const proposal = loadProposal(exportMatch[1]);
|
|
1589
|
+
const json = JSON.stringify(proposal, null, 2) + "\n";
|
|
1590
|
+
res.writeHead(200, {
|
|
1591
|
+
"Content-Type": "application/json",
|
|
1592
|
+
"Content-Disposition": `attachment; filename="proposal-${exportMatch[1]}.json"`,
|
|
1593
|
+
});
|
|
1594
|
+
res.end(json);
|
|
1595
|
+
}
|
|
1596
|
+
catch (e) {
|
|
1597
|
+
apiErr(res, e instanceof Error ? e.message : String(e), 404);
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
res.writeHead(404);
|
|
1602
|
+
res.end("Not found");
|
|
1603
|
+
}
|
|
1604
|
+
export function startGuiServer(opts) {
|
|
1605
|
+
return new Promise((resolve, reject) => {
|
|
1606
|
+
const server = createServer((req, res) => {
|
|
1607
|
+
handleRequest(req, res, opts).catch((err) => {
|
|
1608
|
+
if (res.headersSent)
|
|
1609
|
+
return;
|
|
1610
|
+
try {
|
|
1611
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1612
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
1613
|
+
}
|
|
1614
|
+
catch {
|
|
1615
|
+
res.destroy();
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
});
|
|
1619
|
+
server.once("error", reject);
|
|
1620
|
+
// Listen on all interfaces (omitting host) so both 127.0.0.1 (IPv4) and ::1
|
|
1621
|
+
// (IPv6) work. On Linux, browsers often resolve "localhost" to ::1; binding
|
|
1622
|
+
// only to 127.0.0.1 causes the HTML to load but subsequent fetch() calls to
|
|
1623
|
+
// fail with "Failed to fetch".
|
|
1624
|
+
const listenPort = opts.port === 0 ? 0 : opts.port;
|
|
1625
|
+
server.listen(listenPort, () => {
|
|
1626
|
+
const { port } = server.address();
|
|
1627
|
+
resolve({
|
|
1628
|
+
port,
|
|
1629
|
+
close: () => server.close(),
|
|
1630
|
+
});
|
|
1631
|
+
});
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
//# sourceMappingURL=gui-server.js.map
|