feat: establish NekoNest Cloud control and relay

This commit is contained in:
2026-08-12 23:25:43 +08:00
commit f27606b709
222 changed files with 71456 additions and 0 deletions
+384
View File
@@ -0,0 +1,384 @@
"use client";
import { useState } from "react";
import type {
AccountRecord,
AccountDeletionRequestRecord,
BetaAccessRequestRecord,
BetaRecord,
FeedbackRecord,
GrantRecord,
LaunchGateRecord,
RetentionMaintenanceResult,
ServiceIncidentRecord,
} from "@/db/repository";
type SubmitState = { loading: boolean; message: string; error: boolean };
const idle: SubmitState = { loading: false, message: "", error: false };
async function postJson<T extends Record<string, unknown> = Record<string, unknown>>(path: string, payload: Record<string, unknown>): Promise<T & { message?: string }> {
const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, idempotencyKey: crypto.randomUUID() }) });
const body = (await response.json()) as T & { message?: string };
if (!response.ok) throw new Error(body.message || "操作失败");
return body;
}
export function BetaAction({ beta, blockedP0 }: { beta: BetaRecord | null; blockedP0: number }) {
const [enabled, setEnabled] = useState(beta?.state === "active");
const [capacity, setCapacity] = useState(beta?.capacity_slots?.toString() ?? "");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault(); setState({ loading: true, message: "", error: false });
try { await postJson("/api/admin/beta", { enabled, capacitySlots: capacity ? Number(capacity) : null, reason }); setState({ loading: false, message: "新的公测政策版本已写入,页面即将刷新。", error: false }); window.setTimeout(() => window.location.reload(), 700); }
catch (error) { setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true }); }
}
return <form className="admin-form" onSubmit={submit}><label className="switch-row"><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} /><span><strong></strong><small> P0</small></span></label>{blockedP0 > 0 && <p className="form-error" role="status"> {blockedP0} P0 使</p>}<label><span></span><input type="number" min={1} value={capacity} onChange={(event) => setCapacity(event.target.value)} placeholder="留空 = 每账户不按槽位限额" /><small></small></label><label><span></span><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="为什么现在调整公测政策" required /></label><Submit state={state} label="发布新公测政策版本" /></form>;
}
export function ExemptionAction({ accounts }: { accounts: AccountRecord[] }) {
const [accountId, setAccountId] = useState(accounts[0]?.id ?? "");
const [capacity, setCapacity] = useState("1");
const [endsAt, setEndsAt] = useState("");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) { event.preventDefault(); setState({ loading: true, message: "", error: false }); try { await postJson("/api/admin/exemptions", { action: "create", accountId, capacitySlots: capacity ? Number(capacity) : null, endsAt: new Date(endsAt).toISOString(), reason }); setState({ loading: false, message: "闭测邀请已签发;没有创建金额、余额或支付。", error: false }); window.setTimeout(() => window.location.reload(), 700); } catch (error) { setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true }); } }
return <form className="admin-form" onSubmit={submit}><label><span></span><select value={accountId} onChange={(event) => setAccountId(event.target.value)} required disabled={!accounts.length}>{accounts.length ? accounts.map((account) => <option value={account.id} key={account.id}>{account.email} · {account.display_name} · {account.id.slice(-8)}</option>) : <option value=""></option>}</select><small> ID</small></label><div className="admin-form-grid"><label><span></span><input type="number" min={1} value={capacity} onChange={(event) => setCapacity(event.target.value)} placeholder="留空 = 不按槽位限额" /></label><label><span></span><input type="datetime-local" value={endsAt} onChange={(event) => setEndsAt(event.target.value)} required /></label></div><label><span></span><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="例如:首批闭测用户,有效至指定日期" required /></label><Submit state={state} label="签发有期限闭测邀请" /></form>;
}
export function AccessRequestAction({ request }: { request: BetaAccessRequestRecord }) {
const [action, setAction] = useState<"approve" | "decline">("approve");
const [capacity, setCapacity] = useState(request.requested_slots);
const [endsAt, setEndsAt] = useState("");
const [response, setResponse] = useState("");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postJson("/api/admin/beta-access", {
requestId: request.id,
action,
capacitySlots: capacity,
endsAt: action === "approve" ? new Date(endsAt).toISOString() : "",
response,
reason,
});
setState({ loading: false, message: action === "approve" ? "申请已批准,免费闭测邀请已签发。" : "申请已拒绝,用户可在控制台看到说明。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true });
}
}
return (
<form className="admin-form" onSubmit={submit}>
<div className="admin-form-grid">
<label><span></span><select value={action} onChange={(event) => setAction(event.target.value as "approve" | "decline")}><option value="approve"></option><option value="decline"></option></select></label>
{action === "approve" && <label><span></span><input type="number" min={1} max={3} value={capacity} onChange={(event) => setCapacity(Number(event.target.value))} required /></label>}
</div>
{action === "approve" && <label><span></span><input type="datetime-local" value={endsAt} onChange={(event) => setEndsAt(event.target.value)} required /></label>}
<label><span></span><textarea minLength={2} maxLength={500} value={response} onChange={(event) => setResponse(event.target.value)} placeholder={action === "approve" ? "例如:已开放 1 台主机的免费闭测资格,有效期见资格页。" : "例如:当前测试名额有限,暂未开放;以后可以重新申请。"} required /></label>
<label><span></span><textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="审核依据;仅进入内部审计" required /></label>
<Submit state={state} label={action === "approve" ? "批准并签发邀请" : "拒绝申请"} />
</form>
);
}
export function InvitationRevokeAction({ invitation }: { invitation: GrantRecord }) {
const [confirmed, setConfirmed] = useState(false);
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postJson("/api/admin/exemptions", {
action: "revoke",
grantId: invitation.id,
reason,
});
setState({ loading: false, message: "闭测邀请已撤销;既有主机不会被自动断开。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true });
}
}
return (
<form className="admin-form invitation-revoke-form" onSubmit={submit}>
<label><span></span><textarea value={reason} onChange={(event) => setReason(event.target.value)} maxLength={500} required /></label>
<label className="switch-row"><input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} /><span><strong></strong><small> daemon </small></span></label>
<Submit state={state} label="撤销闭测邀请" disabled={!confirmed} />
</form>
);
}
export function GateAction({ gates }: { gates: LaunchGateRecord[] }) {
const [key, setKey] = useState(gates[0]?.key ?? "");
const selected = gates.find((gate) => gate.key === key);
const [status, setStatus] = useState<LaunchGateRecord["status"]>(selected?.status ?? "blocked");
const [owner, setOwner] = useState(selected?.owner ?? "");
const [evidenceUrl, setEvidenceUrl] = useState(selected?.evidence_url ?? "");
const [notes, setNotes] = useState(selected?.notes ?? "");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
function selectGate(nextKey: string) { const gate = gates.find((item) => item.key === nextKey); setKey(nextKey); setStatus(gate?.status ?? "blocked"); setOwner(gate?.owner ?? ""); setEvidenceUrl(gate?.evidence_url ?? ""); setNotes(gate?.notes ?? ""); }
async function submit(event: React.FormEvent) { event.preventDefault(); setState({ loading: true, message: "", error: false }); try { await postJson("/api/admin/launch-gates", { key, status, owner, evidenceUrl, notes, reason }); setState({ loading: false, message: "门禁状态和证据已审计保存。", error: false }); window.setTimeout(() => window.location.reload(), 700); } catch (error) { setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true }); } }
return <form className="admin-form" onSubmit={submit}><label><span></span><select value={key} onChange={(event) => selectGate(event.target.value)}>{gates.map((gate) => <option value={gate.key} key={gate.key}>{gate.priority} · {gate.title}</option>)}</select></label><div className="admin-form-grid"><label><span></span><select value={status} onChange={(event) => setStatus(event.target.value as LaunchGateRecord["status"])}><option value="blocked"></option><option value="in_progress"></option><option value="passed"></option><option value="not_applicable"></option></select></label><label><span></span><input value={owner} onChange={(event) => setOwner(event.target.value)} placeholder="姓名或角色" /></label></div><label><span> URL</span><input type="url" value={evidenceUrl} onChange={(event) => setEvidenceUrl(event.target.value)} placeholder="https://…" /></label><label><span></span><textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="测试范围、日期和结论" /></label><label><span></span><textarea value={reason} onChange={(event) => setReason(event.target.value)} required /></label><Submit state={state} label="保存门禁状态" /></form>;
}
export function FeedbackAction({ feedback }: { feedback: FeedbackRecord }) {
const [response, setResponse] = useState("");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postJson("/api/admin/feedback", {
feedbackId: feedback.id,
response,
reason,
});
setState({ loading: false, message: "回复已保存,反馈已关闭。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
return (
<form className="admin-form feedback-admin-form" onSubmit={submit}>
<label>
<span></span>
<textarea
minLength={2}
maxLength={1000}
value={response}
onChange={(event) => setResponse(event.target.value)}
required
/>
</label>
<label>
<span></span>
<textarea
maxLength={500}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="例如:已确认配置问题并给出恢复步骤"
required
/>
</label>
<Submit state={state} label="回复并关闭" />
</form>
);
}
export function IncidentAction() {
const [severity, setSeverity] = useState("degraded");
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postJson("/api/admin/incidents", {
action: "create",
severity,
title,
message,
reason,
});
setState({ loading: false, message: "故障公告已发布到服务状态页。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
return (
<form className="admin-form" onSubmit={submit}>
<label>
<span></span>
<select value={severity} onChange={(event) => setSeverity(event.target.value)}>
<option value="maintenance"></option>
<option value="degraded"></option>
<option value="outage"></option>
</select>
</label>
<label>
<span></span>
<input minLength={4} maxLength={80} value={title} onChange={(event) => setTitle(event.target.value)} required />
</label>
<label>
<span></span>
<textarea minLength={10} maxLength={1000} value={message} onChange={(event) => setMessage(event.target.value)} required />
</label>
<label>
<span></span>
<textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
</label>
<Submit state={state} label="发布服务公告" />
</form>
);
}
export function IncidentResolveAction({ incident }: { incident: ServiceIncidentRecord }) {
const [resolution, setResolution] = useState("");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postJson("/api/admin/incidents", {
action: "resolve",
incidentId: incident.id,
resolution,
reason,
});
setState({ loading: false, message: "恢复说明已发布。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
return (
<form className="admin-form incident-resolve-form" onSubmit={submit}>
<label>
<span></span>
<textarea minLength={4} maxLength={500} value={resolution} onChange={(event) => setResolution(event.target.value)} required />
</label>
<label>
<span></span>
<textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
</label>
<Submit state={state} label="标记恢复并发布说明" />
</form>
);
}
export function RetentionAction() {
const [confirmed, setConfirmed] = useState(false);
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
const body = await postJson<{ retention: RetentionMaintenanceResult }>(
"/api/admin/retention",
{ confirmed, reason },
);
const result = body.retention;
const total =
result.retiredPairingCodes +
result.deletedClaimRateWindows +
result.deletedClaimAttempts +
result.deletedIdempotencyRecords;
setState({
loading: false,
message: `清理完成:处理 ${total} 条到期技术记录,管理审计已追加。`,
error: false,
});
setConfirmed(false);
setReason("");
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
return (
<form className="admin-form" onSubmit={submit}>
<div className="retention-scope-list">
<span> tombstone</span>
<span> 24 </span>
<span> 30 </span>
<span> </span>
</div>
<label>
<span></span>
<textarea
maxLength={500}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="例如:执行每周公测数据最小化维护"
required
/>
</label>
<label className="switch-row">
<input
type="checkbox"
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
/>
<span><strong></strong><small></small></span>
</label>
<Submit state={state} label="执行到期数据清理" disabled={!confirmed} />
</form>
);
}
export function PurgeAction({ request }: { request: AccountDeletionRequestRecord }) {
const [confirmation, setConfirmation] = useState("");
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postJson("/api/admin/relay-purges", {
deletion_request_id: request.id,
reason,
confirmation,
});
setState({ loading: false, message: "访问已暂停,Relay 将删除实时数据、附件和全部备份。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
return (
<form className="admin-form" onSubmit={submit}>
<label>
<span></span>
<textarea minLength={8} maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
</label>
<label>
<span> DELETE TENANT DATA </span>
<input value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" required />
<small> Engine Relay SQLite</small>
</label>
<Submit state={state} label="永久逻辑删除 Relay 数据" disabled={confirmation !== "DELETE TENANT DATA" || reason.trim().length < 8} />
</form>
);
}
function Submit({ state, label, disabled = false }: { state: SubmitState; label: string; disabled?: boolean }) { return <><button className="button button-primary" type="submit" disabled={state.loading || disabled}>{state.loading ? "正在保存…" : label}</button>{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}</>; }
+283
View File
@@ -0,0 +1,283 @@
import { requireAdminViewer } from "../cloud-auth";
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../components/Shells";
import { getAdminSnapshot, type AccountDeletionRequestRecord, type FeedbackCategory } from "@/db/repository";
import type { ControlPlaneContactState } from "@/db/device-control-plane";
import { deriveInvitationDisplayState } from "@/db/invitations";
import {
AccessRequestAction,
BetaAction,
ExemptionAction,
FeedbackAction,
GateAction,
IncidentAction,
IncidentResolveAction,
InvitationRevokeAction,
PurgeAction,
RetentionAction,
} from "./AdminActions";
export const dynamic = "force-dynamic";
const feedbackCategoryLabels: Record<FeedbackCategory, string> = {
connection_issue: "连接或接入",
bug: "功能异常",
suggestion: "使用建议",
other: "其他",
};
const daemonContactCopy: Record<
ControlPlaneContactState,
{ label: string; tone: "good" | "warn" | "danger" | "neutral" }
> = {
fresh: { label: "控制面正常", tone: "good" },
delayed: { label: "联系延迟", tone: "warn" },
stale: { label: "长时间未联系", tone: "danger" },
never: { label: "尚未联系", tone: "neutral" },
invalid: { label: "时间异常", tone: "danger" },
};
const deletionStatusCopy: Record<
AccountDeletionRequestRecord["status"],
{ label: string; tone: "good" | "warn" | "danger" | "neutral" }
> = {
requested: { label: "待核对", tone: "warn" },
cancelled: { label: "已撤回", tone: "neutral" },
processing: { label: "删除中", tone: "danger" },
relay_purged: { label: "Relay 已逻辑删除", tone: "good" },
};
const retentionStatusCopy = {
never_run: {
label: "尚未自动运行",
tone: "warn",
detail: "定时入口尚未留下首次执行证据;管理员仍可使用下方手工回退。",
},
running: {
label: "正在清理",
tone: "info",
detail: "自动任务已经取得单实例运行权,正在处理已到期技术记录。",
},
healthy: {
label: "自动清理正常",
tone: "good",
detail: "最近一次自动清理在预期时间窗内成功完成。",
},
overdue: {
label: "自动清理逾期",
tone: "warn",
detail: "最近成功已经超过 36 小时,请核对托管环境的定时触发器。",
},
failed: {
label: "自动清理失败",
tone: "danger",
detail: "最近一次自动清理失败;错误码已最小化保存,运行时错误会继续进入平台观测。",
},
stalled: {
label: "自动清理卡住",
tone: "danger",
detail: "任务运行超过 30 分钟;下一次触发可以安全接管,但应先核对 D1 与 Worker 状态。",
},
invalid: {
label: "清理记录异常",
tone: "danger",
detail: "运行状态包含无效时间、计数或触发来源,不能把它当作成功证据。",
},
} as const;
function formatPercent(value: number | null) {
return value === null ? "暂无样本" : `${value}%`;
}
function formatDuration(seconds: number | null) {
if (seconds === null) return "暂无样本";
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟`;
if (seconds < 86400) return `${Math.round((seconds / 3600) * 10) / 10} 小时`;
return `${Math.round((seconds / 86400) * 10) / 10}`;
}
export default async function AdminPage() {
const viewer = await requireAdminViewer();
const snapshot = await getAdminSnapshot();
const deferredPaidGates = snapshot.gates.filter((gate) => gate.priority === "PAID").length;
const openFeedback = snapshot.feedback.filter((item) => item.status === "open");
const pendingAccessRequests = snapshot.accessRequests.filter((item) => item.status === "requested");
const pendingAccessAccountIds = new Set(pendingAccessRequests.map((item) => item.account_id));
const proactiveInvitationAccounts = snapshot.accounts.filter((account) => !pendingAccessAccountIds.has(account.id));
const invitations = snapshot.grants.filter((grant) => grant.source === "admin_exemption");
const activeIncidents = snapshot.incidents.filter((item) => item.status === "active");
const pendingDeletionRequests = snapshot.deletionRequests.filter((item) => item.status === "requested");
const daemonContactAttention = snapshot.hostContacts.delayed
+ snapshot.hostContacts.stale
+ snapshot.hostContacts.never
+ snapshot.hostContacts.invalid;
const daemonContactTone = snapshot.hostContacts.invalid || snapshot.hostContacts.stale
? "danger"
: daemonContactAttention
? "warn"
: snapshot.hostContacts.totalActive
? "good"
: "neutral";
const retentionStatus = retentionStatusCopy[snapshot.retention.state];
const retentionLastSuccess = snapshot.retention.state === "invalid"
? "记录异常"
: formatDate(snapshot.retention.lastSuccessAt, true);
return (
<DashboardShell viewer={viewer} active="/admin">
<div className="cloud-page admin-page">
<PageHeading eyebrow="PUBLIC BETA ADMIN / 公测后台" title="免费政策、接入资格与上线门禁。" description="当前只运营免费公测。每次管理变更仍需理由、幂等键和追加式审计;报价、订单和价格发布均保持关闭。" />
<section className="admin-metrics"><article><span> P0 </span><strong>{snapshot.blockedP0}</strong><small></small></article><article><span></span><strong>{snapshot.beta?.state === "active" ? "全部免费" : "已结束"}</strong><small>{snapshot.beta?.capacity_slots === null ? "每账户不按主机槽位限额" : `每账户最多 ${snapshot.beta?.capacity_slots ?? 0} 台主机`}</small></article><article><span></span><strong>{activeIncidents.length ? `${activeIncidents.length} 个事件` : "正常"}</strong><small></small></article><article><span></span><strong>{pendingAccessRequests.length}</strong><small> {openFeedback.length} {deferredPaidGates} </small></article></section>
<section className="panel full-panel operations-panel">
<div className="panel-heading">
<div><span>DAEMON CONTROL PLANE / </span><h2></h2></div>
<StatusPill tone={daemonContactTone}>{daemonContactAttention ? `${daemonContactAttention} 台需留意` : snapshot.hostContacts.totalActive ? "全部正常" : "暂无主机"}</StatusPill>
</div>
<p className="operations-intro"> Relay daemon sealed </p>
<div className="operations-grid daemon-contact-grid">
<article><span></span><strong>{snapshot.hostContacts.totalActive}</strong><small>{snapshot.hostContacts.versionUnknown} daemon </small></article>
<article><span></span><strong>{snapshot.hostContacts.fresh}</strong><small>使 Relay </small></article>
<article><span></span><strong>{snapshot.hostContacts.delayed}</strong><small></small></article>
<article><span></span><strong>{snapshot.hostContacts.stale}</strong><small> daemon </small></article>
<article><span></span><strong>{snapshot.hostContacts.never}</strong><small> Relay </small></article>
<article><span></span><strong>{snapshot.hostContacts.invalid}</strong><small></small></article>
</div>
<div className="daemon-contact-attention">
<h3></h3>
{snapshot.hostContacts.attentionHosts.length ? (
<div className="admin-gate-table">
{snapshot.hostContacts.attentionHosts.map((host) => {
const state = daemonContactCopy[host.contact_state];
return (
<article key={host.id}>
<span className="gate-priority">{host.os === "windows" ? "W" : "L"}</span>
<div><strong>{host.name}</strong><small>{host.email} · {host.id} · {host.daemon_version || "版本待上报"}</small></div>
<StatusPill tone={state.tone}>{state.label}</StatusPill>
<span>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "没有成功查询"}</span>
</article>
);
})}
</div>
) : <div className="empty-inline"></div>}
</div>
<p className="operations-footnote"> 25 {formatDate(snapshot.hostContacts.generatedAt, true)}</p>
</section>
<section className="panel full-panel operations-panel">
<div className="panel-heading">
<div><span>ROLLING 30 DAYS / 30 </span><h2></h2></div>
<StatusPill tone="info"></StatusPill>
</div>
<p className="operations-intro"> relay </p>
<div className="operations-grid">
<article>
<span></span>
<strong>{snapshot.operations.accounts.newAccounts}</strong>
<small> {snapshot.operations.accounts.total} {snapshot.operations.accounts.withActiveHost} </small>
</article>
<article>
<span></span>
<strong>{snapshot.operations.accessRequests.submitted}</strong>
<small>{snapshot.operations.accessRequests.pending} {snapshot.operations.accessRequests.cancelled} </small>
</article>
<article>
<span></span>
<strong>{formatPercent(snapshot.operations.accessRequests.approvalRatePercent)}</strong>
<small>{snapshot.operations.accessRequests.approved}/{snapshot.operations.accessRequests.decided} {formatDuration(snapshot.operations.accessRequests.averageReviewSeconds)}</small>
</article>
<article>
<span></span>
<strong>{snapshot.operations.accessRequests.requestedSlotDemand} </strong>
<small> {snapshot.operations.accessRequests.averageRequestedSlots ?? "暂无样本"} Windows {snapshot.operations.accessRequests.windows}Linux {snapshot.operations.accessRequests.linux} {snapshot.operations.accessRequests.both}</small>
</article>
<article>
<span></span>
<strong>{formatPercent(snapshot.operations.accessRequests.postApprovalClaimRatePercent)}</strong>
<small>{snapshot.operations.accessRequests.approvedWithPostApprovalClaim}/{snapshot.operations.accessRequests.approved} relay </small>
</article>
<article>
<span></span>
<strong>{formatPercent(snapshot.operations.pairings.claimRatePercent)}</strong>
<small>{snapshot.operations.pairings.claimed}/{snapshot.operations.pairings.created} {formatDuration(snapshot.operations.pairings.averageClaimSeconds)}</small>
</article>
<article>
<span></span>
<strong>{snapshot.operations.pairings.attentionRequired}</strong>
<small>{snapshot.operations.pairings.waiting} {snapshot.operations.pairings.expired} {snapshot.operations.pairings.locked} {snapshot.operations.pairings.rejectedAttempts} {snapshot.operations.pairings.rateLimitedAttempts} </small>
</article>
<article>
<span>Relay placement </span>
<strong>{formatPercent(snapshot.operations.provisioning.successRatePercent)}</strong>
<small>{snapshot.operations.provisioning.succeeded}/{snapshot.operations.provisioning.created} placement active {formatDuration(snapshot.operations.provisioning.averageCompletionSeconds)}</small>
</article>
<article>
<span></span>
<strong>{formatPercent(snapshot.operations.support.resolutionRatePercent)}</strong>
<small>{snapshot.operations.support.resolved}/{snapshot.operations.support.created} {snapshot.operations.support.connectionIssues} {formatDuration(snapshot.operations.support.averageResolutionSeconds)}</small>
</article>
<article className="operations-unavailable">
<span></span>
<strong></strong>
<small>relay </small>
</article>
</div>
<p className="operations-footnote"> {snapshot.operations.windowDays} {formatDate(snapshot.operations.generatedAt, true)}</p>
</section>
<div className="admin-grid">
<section className="panel admin-module"><div className="panel-heading"><div><span></span><h2></h2></div><StatusPill tone={snapshot.beta?.state === "active" && snapshot.blockedP0 === 0 ? "good" : snapshot.beta?.state === "active" ? "warn" : "neutral"}>{snapshot.beta?.state === "active" && snapshot.blockedP0 > 0 ? "政策已设 / 接入冻结" : snapshot.beta?.state ?? "未设置"}</StatusPill></div><BetaAction beta={snapshot.beta} blockedP0={snapshot.blockedP0} /></section>
<section className="panel admin-module"><div className="panel-heading"><div><span></span><h2></h2></div><StatusPill tone="neutral"></StatusPill></div><p></p></section>
<section className="panel admin-module"><div className="panel-heading"><div><span> / </span><h2></h2></div></div><p></p><ExemptionAction accounts={proactiveInvitationAccounts} /></section>
<section className="panel admin-module">
<div className="panel-heading">
<div><span>DATA MINIMIZATION</span><h2></h2></div>
<StatusPill tone={retentionStatus.tone}>{retentionStatus.label}</StatusPill>
</div>
<p> 02:17 退</p>
<div className="maintenance-status" aria-label="到期数据自动清理状态">
<div><span></span><strong>{retentionLastSuccess}</strong></div>
<div><span></span><strong>{snapshot.retention.runCount}</strong></div>
<div><span></span><strong>{snapshot.retention.consecutiveFailures}</strong></div>
</div>
<p className="maintenance-status-detail">
{retentionStatus.detail}
{snapshot.retention.ageSeconds !== null ? ` 距相关状态约 ${formatDuration(snapshot.retention.ageSeconds)}` : ""}
{snapshot.retention.errorCode ? ` 错误码:${snapshot.retention.errorCode}` : ""}
</p>
<RetentionAction />
</section>
<section className="panel admin-module"><div className="panel-heading"><div><span></span><h2>线</h2></div></div><GateAction gates={snapshot.gates} /></section>
</div>
<section className="panel full-panel"><div className="panel-heading"><div><span>CLOSED BETA REQUESTS</span><h2></h2></div><StatusPill tone={pendingAccessRequests.length ? "warn" : "good"}>{pendingAccessRequests.length ? `${pendingAccessRequests.length} 条待处理` : "已清空"}</StatusPill></div><p> 13 </p><div className="admin-feedback-list">{snapshot.accessRequests.length ? snapshot.accessRequests.map((request) => { const account = snapshot.accounts.find((item) => item.id === request.account_id); return <article key={request.id}><div className="feedback-meta"><StatusPill tone={request.status === "approved" ? "good" : request.status === "requested" ? "warn" : "neutral"}>{request.status === "approved" ? "已批准" : request.status === "requested" ? "待处理" : request.status === "declined" ? "未批准" : "用户已撤回"}</StatusPill><span>{account?.email ?? request.account_id}</span><span>{request.requested_slots} · {request.preferred_os}</span><span>{formatDate(request.requested_at, true)}</span></div><p>{request.use_case}</p>{request.admin_response && <div className="feedback-response"><strong></strong><p>{request.admin_response}</p></div>}<code>{request.id}</code>{request.status === "requested" && <AccessRequestAction request={request} />}</article>; }) : <div className="empty-inline"></div>}</div></section>
<section className="panel full-panel"><div className="panel-heading"><div><span>CLOSED BETA INVITATIONS</span><h2></h2></div><StatusPill tone={invitations.some((grant) => deriveInvitationDisplayState(grant) === "active") ? "good" : "neutral"}>{invitations.filter((grant) => deriveInvitationDisplayState(grant) === "active").length} </StatusPill></div><p></p><div className="admin-feedback-list">{invitations.length ? invitations.map((invitation) => { const state = deriveInvitationDisplayState(invitation); const account = snapshot.accounts.find((item) => item.id === invitation.account_id); return <article key={invitation.id}><div className="feedback-meta"><StatusPill tone={state === "active" ? "good" : "neutral"}>{state === "active" ? "有效" : state === "expired" ? "已到期" : "已撤销"}</StatusPill><span>{account?.email ?? invitation.account_id}</span><span>{invitation.capacity_slots === null ? "不按槽位限额" : `${invitation.capacity_slots} 个槽位`}</span><span> {formatDate(invitation.ends_at, true)}</span></div><p>{invitation.reason}</p><code>{invitation.id}</code>{state === "active" && <InvitationRevokeAction invitation={invitation} />}</article>; }) : <div className="empty-inline"></div>}</div></section>
<section className="panel full-panel">
<div className="panel-heading">
<div><span>ACCOUNT LIFECYCLE</span><h2></h2></div>
<StatusPill tone={pendingDeletionRequests.length ? "warn" : "good"}>
{pendingDeletionRequests.length ? `${pendingDeletionRequests.length} 条待核对` : "无待核对"}
</StatusPill>
</div>
<p>Relay </p>
<div className="admin-deletion-list">
{snapshot.deletionRequests.length ? snapshot.deletionRequests.map((request) => {
const copy = deletionStatusCopy[request.status];
return (
<article key={request.id}>
<div>
<StatusPill tone={copy.tone}>{copy.label}</StatusPill>
<strong>{snapshot.accounts.find((account) => account.id === request.account_id)?.email ?? request.account_id}</strong>
<span>{formatDate(request.requested_at, true)}</span>
</div>
<p>{request.reason || "用户未填写补充说明。"}</p>
<code>{request.id}</code>
{request.status === "requested" && <PurgeAction request={request} />}
</article>
);
}) : <div className="empty-inline"></div>}
</div>
</section>
<section className="panel full-panel"><div className="panel-heading"><div><span>STATUS & INCIDENTS</span><h2></h2></div><StatusPill tone={activeIncidents.length ? "danger" : "good"}>{activeIncidents.length ? `${activeIncidents.length} 个处理中` : "服务正常"}</StatusPill></div><div className="admin-incident-layout"><div><h3></h3><p></p><IncidentAction /></div><div className="admin-incident-list"><h3></h3>{snapshot.incidents.length ? snapshot.incidents.map((incident) => <article key={incident.id}><div className="incident-card-heading"><StatusPill tone={incident.status === "resolved" ? "good" : incident.severity === "outage" ? "danger" : incident.severity === "degraded" ? "warn" : "info"}>{incident.status === "resolved" ? "已恢复" : incident.severity === "outage" ? "服务中断" : incident.severity === "degraded" ? "服务降级" : "计划维护"}</StatusPill><span>{formatDate(incident.started_at, true)}</span></div><h4>{incident.title}</h4><p>{incident.message}</p>{incident.status === "active" ? <IncidentResolveAction incident={incident} /> : <div className="feedback-response"><strong></strong><p>{incident.resolution}</p></div>}</article>) : <div className="empty-inline"></div>}</div></div></section>
<section className="panel full-panel"><div className="panel-heading"><div><span>FREE BETA SUPPORT</span><h2></h2></div><StatusPill tone={openFeedback.length ? "warn" : "good"}>{openFeedback.length ? `${openFeedback.length} 条待处理` : "已清空"}</StatusPill></div>{snapshot.feedback.length ? <div className="admin-feedback-list">{snapshot.feedback.map((item) => <article key={item.id}><div className="feedback-meta"><StatusPill tone={item.status === "resolved" ? "good" : "warn"}>{item.status === "resolved" ? "已回复" : "待处理"}</StatusPill><span>{feedbackCategoryLabels[item.category]}</span><span>{formatDate(item.created_at, true)}</span><span>{snapshot.accounts.find((account) => account.id === item.account_id)?.email ?? item.account_id}</span></div><p>{item.message}</p>{item.admin_response && <div className="feedback-response"><strong></strong><p>{item.admin_response}</p></div>}{item.status === "open" && <FeedbackAction feedback={item} />}</article>)}</div> : <div className="empty-inline"></div>}</section>
<section className="panel full-panel"><div className="panel-heading"><div><span>P0 / P1 / PAID</span><h2></h2></div></div><div className="admin-gate-table">{snapshot.gates.map((gate) => <article key={gate.key}><span className="gate-priority">{gate.priority}</span><div><strong>{gate.title}</strong><small>{gate.category} · {gate.key}</small></div><StatusPill tone={gate.priority === "PAID" ? "neutral" : gate.status === "passed" ? "good" : gate.status === "in_progress" ? "warn" : "danger"}>{gate.priority === "PAID" ? "以后处理" : gate.status}</StatusPill><span>{gate.owner || "待指定"}</span></article>)}</div></section>
<section className="panel full-panel"><div className="panel-heading"><div><span>APPEND ONLY</span><h2></h2></div></div><div className="audit-list">{snapshot.audits.length ? snapshot.audits.map((audit) => <article key={audit.id}><span>{formatDate(audit.created_at, true)}</span><strong>{audit.action}</strong><span>{audit.target_type} / {audit.target_id}</span><p>{audit.reason}</p><code>{audit.actor_id}</code></article>) : <div className="empty-inline"></div>}</div></section>
</div>
</DashboardShell>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { getCloudViewer } from "@/app/cloud-auth";
import {
cancelAccountDeletion,
DomainError,
getOrCreateAccount,
requestAccountDeletion,
} from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401, headers: { "cache-control": "no-store" } },
);
}
const payload = await readJsonMutation<{
action?: string;
requestId?: string;
confirmed?: boolean;
reason?: string;
idempotencyKey?: string;
}>(request);
const account = await getOrCreateAccount(viewer);
if (payload.action === "request") {
const deletionRequest = await requestAccountDeletion({
accountId: account.id,
actorId: viewer.userId,
confirmed: payload.confirmed === true,
reason: payload.reason ?? "",
idempotencyKey: payload.idempotencyKey ?? "",
});
return Response.json(
{ deletionRequest },
{ status: 201, headers: { "cache-control": "no-store" } },
);
}
if (payload.action === "cancel") {
const deletionRequest = await cancelAccountDeletion({
accountId: account.id,
actorId: viewer.userId,
requestId: payload.requestId ?? "",
});
return Response.json(
{ deletionRequest },
{ headers: { "cache-control": "no-store" } },
);
}
throw new DomainError(
"invalid_deletion_action",
"请选择有效的注销申请操作",
);
} catch (error) {
const response = apiError(error);
response.headers.set("cache-control", "no-store");
return response;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { getCloudViewer } from "@/app/cloud-auth";
import {
getAccountControlPlaneExport,
getOrCreateAccount,
} from "@/db/repository";
import { apiError } from "../../respond";
export async function GET() {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401, headers: { "cache-control": "no-store" } },
);
}
const account = await getOrCreateAccount(viewer);
const exportData = await getAccountControlPlaneExport(account);
const date = exportData.exported_at.slice(0, 10);
return new Response(`${JSON.stringify(exportData, null, 2)}\n`, {
headers: {
"cache-control": "no-store",
"content-disposition": `attachment; filename="nekonest-cloud-${date}.json"`,
"content-type": "application/json; charset=utf-8",
"x-content-type-options": "nosniff",
},
});
} catch (error) {
const response = apiError(error);
response.headers.set("cache-control", "no-store");
return response;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { resolveBetaAccessRequest } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
if (!viewer.isAdmin) {
return Response.json(
{ error: "forbidden", message: "没有商业后台权限" },
{ status: 403 },
);
}
const payload = await readJsonMutation<{
requestId?: string;
action?: "approve" | "decline";
capacitySlots?: number;
endsAt?: string;
response?: string;
reason?: string;
idempotencyKey?: string;
}>(request);
const result = await resolveBetaAccessRequest({
actorId: viewer.userId,
requestId: payload.requestId ?? "",
action: payload.action ?? "decline",
capacitySlots: payload.capacitySlots ?? 0,
endsAt: payload.endsAt ?? "",
response: payload.response ?? "",
reason: payload.reason ?? "",
idempotencyKey: payload.idempotencyKey ?? "",
});
return Response.json(result);
} catch (error) {
return apiError(error);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { setPublicBeta } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
const payload = await readJsonMutation<{ enabled?: boolean; capacitySlots?: number | null; reason?: string; idempotencyKey?: string }>(request);
const beta = await setPublicBeta({ actorId: viewer.userId, enabled: payload.enabled ?? false, capacitySlots: payload.capacitySlots ?? null, reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
return Response.json({ beta });
} catch (error) { return apiError(error); }
}
+18
View File
@@ -0,0 +1,18 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { createExemption, revokeExemption } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
const payload = await readJsonMutation<{ action?: string; grantId?: string; accountId?: string; capacitySlots?: number | null; endsAt?: string; reason?: string; idempotencyKey?: string }>(request);
if (payload.action === "revoke") {
const grant = await revokeExemption({ actorId: viewer.userId, grantId: payload.grantId ?? "", reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
return Response.json({ grant });
}
const grant = await createExemption({ actorId: viewer.userId, accountId: payload.accountId ?? "", capacitySlots: payload.capacitySlots ?? null, endsAt: payload.endsAt ?? "", reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
return Response.json({ grant }, { status: 201 });
} catch (error) { return apiError(error); }
}
+37
View File
@@ -0,0 +1,37 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { resolveFeedback } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
if (!viewer.isAdmin) {
return Response.json(
{ error: "forbidden", message: "没有公测后台权限" },
{ status: 403 },
);
}
const payload = await readJsonMutation<{
feedbackId?: string;
response?: string;
reason?: string;
idempotencyKey?: string;
}>(request);
const feedback = await resolveFeedback({
feedbackId: payload.feedbackId ?? "",
actorId: viewer.userId,
response: payload.response ?? "",
reason: payload.reason ?? "",
idempotencyKey: payload.idempotencyKey ?? "",
});
return Response.json({ feedback });
} catch (error) {
return apiError(error);
}
}
+61
View File
@@ -0,0 +1,61 @@
import { getCloudViewer } from "@/app/cloud-auth";
import {
createServiceIncident,
resolveServiceIncident,
} from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
if (!viewer.isAdmin) {
return Response.json(
{ error: "forbidden", message: "没有公测后台权限" },
{ status: 403 },
);
}
const payload = await readJsonMutation<{
action?: string;
incidentId?: string;
severity?: string;
title?: string;
message?: string;
resolution?: string;
reason?: string;
idempotencyKey?: string;
}>(request);
if (payload.action === "create") {
const incident = await createServiceIncident({
actorId: viewer.userId,
severity: payload.severity ?? "",
title: payload.title ?? "",
message: payload.message ?? "",
reason: payload.reason ?? "",
idempotencyKey: payload.idempotencyKey ?? "",
});
return Response.json({ incident }, { status: 201 });
}
if (payload.action === "resolve") {
const incident = await resolveServiceIncident({
incidentId: payload.incidentId ?? "",
actorId: viewer.userId,
resolution: payload.resolution ?? "",
reason: payload.reason ?? "",
});
return Response.json({ incident });
}
return Response.json(
{ error: "invalid_incident_action", message: "请选择有效的故障公告操作" },
{ status: 400 },
);
} catch (error) {
return apiError(error);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { updateLaunchGate, type LaunchGateRecord } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
const payload = await readJsonMutation<{ key?: string; status?: LaunchGateRecord["status"]; owner?: string; evidenceUrl?: string; notes?: string; reason?: string; idempotencyKey?: string }>(request);
const gate = await updateLaunchGate({ actorId: viewer.userId, key: payload.key ?? "", status: payload.status ?? "blocked", owner: payload.owner ?? "", evidenceUrl: payload.evidenceUrl ?? "", notes: payload.notes ?? "", reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
return Response.json({ gate });
} catch (error) { return apiError(error); }
}
+13
View File
@@ -0,0 +1,13 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { DomainError } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
await readJsonMutation<{ period?: "month" | "year"; amountMinor?: number; reason?: string; idempotencyKey?: string }>(request);
throw new DomainError("paid_features_deferred", "免费公测阶段不发布价格版本", 409);
} catch (error) { return apiError(error); }
}
+40
View File
@@ -0,0 +1,40 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { beginRelayMigration } from "@/db/relay-migrations";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error_code: "authentication_required", message: "请先登录", retryable: false },
{ status: 401 },
);
}
if (!viewer.isAdmin) {
return Response.json(
{ error_code: "forbidden", message: "没有 Relay 迁移权限", retryable: false },
{ status: 403 },
);
}
const payload = await readJsonMutation<{
tenant_id?: string;
target_node_id?: string;
reason?: string;
idempotency_key?: string;
}>(request);
const migration = await beginRelayMigration({
tenantId: payload.tenant_id ?? "",
targetNodeId: payload.target_node_id ?? "",
actorId: viewer.userId,
reason: payload.reason ?? "",
idempotencyKey: payload.idempotency_key ?? "",
});
return Response.json(
{ migration_id: migration.id, state: migration.state, started_at: migration.started_at },
{ status: 202, headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { beginRelayPurge } from "@/db/relay-purges";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error_code: "authentication_required", message: "请先登录", retryable: false },
{ status: 401 },
);
}
if (!viewer.isAdmin) {
return Response.json(
{ error_code: "forbidden", message: "没有永久删除租户数据的权限", retryable: false },
{ status: 403 },
);
}
const payload = await readJsonMutation<{
deletion_request_id?: string;
reason?: string;
confirmation?: string;
}>(request);
const purge = await beginRelayPurge({
deletionRequestId: payload.deletion_request_id ?? "",
actorId: viewer.userId,
reason: payload.reason ?? "",
confirmation: payload.confirmation ?? "",
});
return Response.json(
{ purge_id: purge.id, state: purge.state, started_at: purge.started_at },
{ status: 202, headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { runRetentionMaintenance } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
if (!viewer.isAdmin) {
return Response.json(
{ error: "forbidden", message: "没有公测后台权限" },
{ status: 403 },
);
}
const payload = await readJsonMutation<{
confirmed?: boolean;
reason?: string;
}>(request);
const retention = await runRetentionMaintenance({
actorId: viewer.userId,
confirmed: payload.confirmed === true,
reason: payload.reason ?? "",
});
return Response.json({ retention });
} catch (error) {
return apiError(error);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { getCloudViewer } from "@/app/cloud-auth";
import {
cancelBetaAccessRequest,
createBetaAccessRequest,
getOrCreateAccount,
} from "@/db/repository";
import { apiError, readJsonMutation } from "../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
const payload = await readJsonMutation<{
action?: string;
requestId?: string;
preferredOs?: string;
requestedSlots?: number;
useCase?: string;
idempotencyKey?: string;
}>(request);
const account = await getOrCreateAccount(viewer);
if (payload.action === "cancel") {
const accessRequest = await cancelBetaAccessRequest({
accountId: account.id,
actorId: viewer.userId,
requestId: payload.requestId ?? "",
idempotencyKey: payload.idempotencyKey ?? "",
});
return Response.json({ accessRequest });
}
if (payload.action !== "request") {
return Response.json(
{ error: "invalid_action", message: "请选择提交或撤回闭测申请" },
{ status: 400 },
);
}
const accessRequest = await createBetaAccessRequest({
accountId: account.id,
actorId: viewer.userId,
preferredOs: payload.preferredOs ?? "",
requestedSlots: payload.requestedSlots ?? 0,
useCase: payload.useCase ?? "",
idempotencyKey: payload.idempotencyKey ?? "",
});
return Response.json({ accessRequest }, { status: 201 });
} catch (error) {
return apiError(error);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { DomainError } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
await readJsonMutation<{
period?: "month" | "year";
quantity?: number;
idempotencyKey?: string;
}>(request);
throw new DomainError(
"paid_features_deferred",
"免费公测阶段不提供报价或订单;未来收费方案确定后会另行通知",
409,
);
} catch (error) {
return apiError(error);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { createFeedback, getOrCreateAccount } from "@/db/repository";
import { apiError, readJsonMutation } from "../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
const payload = await readJsonMutation<{
category?: string;
message?: string;
idempotencyKey?: string;
}>(request);
const account = await getOrCreateAccount(viewer);
const feedback = await createFeedback({
accountId: account.id,
actorId: viewer.userId,
category: payload.category ?? "",
message: payload.message ?? "",
idempotencyKey: payload.idempotencyKey ?? "",
});
return Response.json({ feedback }, { status: 201 });
} catch (error) {
return apiError(error);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { getCloudViewer } from "@/app/cloud-auth";
import {
cancelPairingRequest,
getOrCreateAccount,
} from "@/db/repository";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401, headers: { "cache-control": "no-store" } },
);
}
const payload = await readJsonMutation<{ pairingId?: string }>(request);
const account = await getOrCreateAccount(viewer);
const result = await cancelPairingRequest({
accountId: account.id,
pairingId: payload.pairingId ?? "",
actorId: viewer.userId,
});
return Response.json(result, {
status: 200,
headers: { "cache-control": "no-store" },
});
} catch (error) {
const response = apiError(error);
response.headers.set("cache-control", "no-store");
return response;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { getCloudViewer } from "@/app/cloud-auth";
import {
createPairingRequest,
getOrCreateAccount,
getOwnedPairingProgress,
} from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function GET(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401, headers: { "cache-control": "no-store" } },
);
}
const pairingId = new URL(request.url).searchParams.get("pairing_id") ?? "";
const account = await getOrCreateAccount(viewer);
const pairing = await getOwnedPairingProgress({
accountId: account.id,
pairingId,
});
return Response.json(
{ pairing },
{ status: 200, headers: { "cache-control": "no-store" } },
);
} catch (error) {
const response = apiError(error);
response.headers.set("cache-control", "no-store");
return response;
}
}
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
const payload = await readJsonMutation<{
requestedName?: string;
os?: "windows" | "linux";
}>(request);
const account = await getOrCreateAccount(viewer);
const pairing = await createPairingRequest({
accountId: account.id,
requestedName: payload.requestedName ?? "",
os: payload.os ?? "windows",
actorId: viewer.userId,
});
return Response.json(
{ pairing },
{ status: 201, headers: { "cache-control": "no-store" } },
);
} catch (error) {
const response = apiError(error);
response.headers.set("cache-control", "no-store");
return response;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { getCloudViewer } from "@/app/cloud-auth";
import { getOrCreateAccount, revokeHost } from "@/db/repository";
import { apiError, readJsonMutation } from "../../respond";
export async function POST(request: Request) {
try {
const viewer = await getCloudViewer();
if (!viewer) {
return Response.json(
{ error: "authentication_required", message: "请先登录" },
{ status: 401 },
);
}
const payload = await readJsonMutation<{ hostId?: string; reason?: string }>(
request,
);
const account = await getOrCreateAccount(viewer);
const result = await revokeHost({
accountId: account.id,
hostId: payload.hostId ?? "",
actorId: viewer.userId,
reason: payload.reason ?? "用户从控制台撤销主机",
});
return Response.json(result);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,25 @@
import {
authenticateRelayNode,
authorizationRevisionDelta,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
tenant_id?: string;
after_revision?: number;
}>(request);
return Response.json(
await authorizationRevisionDelta({
principal,
tenantId: payload.tenant_id ?? "",
afterRevision: Number(payload.after_revision ?? -1),
}),
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,22 @@
import {
authenticateRelayNode,
fullAuthorizationSnapshot,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
tenant_id?: string;
placement_generation?: number;
}>(request);
return Response.json(await fullAuthorizationSnapshot({
principal,
tenantId: payload.tenant_id ?? "",
placementGeneration: Number(payload.placement_generation ?? -1),
}), { headers: { "cache-control": "no-store" } });
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,23 @@
import {
authenticateRelayNode,
authorizeDeviceForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
device_id?: string;
token_hash?: string;
}>(request);
const result = await authorizeDeviceForRelay({
principal,
deviceId: payload.device_id ?? "",
tokenHash: payload.token_hash?.trim().toLowerCase() ?? "",
});
return Response.json(result, { headers: { "cache-control": "no-store" } });
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,25 @@
import {
authenticateRelayNode,
authorizePhoneRoute,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
route_handle?: string;
phone_token_hash?: string;
}>(request);
return Response.json(
await authorizePhoneRoute({
principal,
routeHandle: payload.route_handle ?? "",
phoneTokenHash: payload.phone_token_hash ?? "",
}),
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,26 @@
import {
authenticateRelayNode,
completePhoneHandoffForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
handoff_id?: string;
phone_id?: string;
phone_token_hash?: string;
route_handle_hash?: string;
}>(request);
return Response.json(await completePhoneHandoffForRelay({
principal,
handoffId: payload.handoff_id ?? "",
phoneId: payload.phone_id ?? "",
phoneTokenHash: payload.phone_token_hash ?? "",
routeHandleHash: payload.route_handle_hash ?? "",
}), { headers: { "cache-control": "no-store" } });
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,30 @@
import {
authenticateRelayNode,
consumePhoneHandoffForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
ticket?: string;
pwa_origin?: string;
name?: string;
phone_ed25519_public?: string;
phone_x25519_public?: string;
identity_fingerprint?: string;
}>(request);
return Response.json(await consumePhoneHandoffForRelay({
principal,
ticket: payload.ticket ?? "",
pwaOrigin: payload.pwa_origin ?? "",
name: payload.name ?? "",
phoneEd25519Public: payload.phone_ed25519_public ?? "",
phoneX25519Public: payload.phone_x25519_public ?? "",
identityFingerprint: payload.identity_fingerprint ?? "",
}), { headers: { "cache-control": "no-store" } });
} catch (error) {
return apiError(error);
}
}
+32
View File
@@ -0,0 +1,32 @@
import {
authenticateRelayNode,
heartbeatRelayNode,
} from "@/db/relay-control-plane";
import { relayMigrationAssignments } from "@/db/relay-migrations";
import { relayPurgeAssignments } from "@/db/relay-purges";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
generation?: number;
capacity_tenants?: number;
}>(request);
const heartbeat = await heartbeatRelayNode({
principal,
generation: Number(payload.generation ?? -1),
capacityTenants: Number(payload.capacity_tenants ?? -1),
});
const [migrations, purges] = await Promise.all([
relayMigrationAssignments(principal),
relayPurgeAssignments(principal),
]);
return Response.json(
{ ...heartbeat, migrations, purges },
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,36 @@
import { authenticateRelayNode } from "@/db/relay-control-plane";
import { advanceRelayMigration } from "@/db/relay-migrations";
import { apiError, readJsonMutation } from "../../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
migration_id?: string;
action?: "quiesced" | "copied" | "switched" | "finalized" | "failed";
backup_ref?: string;
manifest_sha256?: string;
error_code?: string;
}>(request);
if (!payload.action) {
return Response.json(
{ error_code: "invalid_relay_migration", message: "迁移动作无效", retryable: false },
{ status: 400, headers: { "cache-control": "no-store" } },
);
}
const migration = await advanceRelayMigration({
principal,
migrationId: payload.migration_id ?? "",
action: payload.action,
backupRef: payload.backup_ref,
manifestSha256: payload.manifest_sha256,
errorCode: payload.error_code,
});
return Response.json(
{ migration_id: migration.id, state: migration.state, updated_at: migration.updated_at },
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,34 @@
import { authenticateRelayNode } from "@/db/relay-control-plane";
import { advanceRelayPurge } from "@/db/relay-purges";
import { apiError, readJsonMutation } from "../../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
purge_id?: string;
action?: "completed" | "failed";
evidence_sha256?: string;
error_code?: string;
}>(request);
if (!payload.action) {
return Response.json(
{ error_code: "invalid_relay_purge", message: "租户删除动作无效", retryable: false },
{ status: 400, headers: { "cache-control": "no-store" } },
);
}
const purge = await advanceRelayPurge({
principal,
purgeId: payload.purge_id ?? "",
action: payload.action,
evidenceSha256: payload.evidence_sha256,
errorCode: payload.error_code,
});
return Response.json(
{ purge_id: purge.id, state: purge.state, updated_at: purge.updated_at },
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,45 @@
import { authenticateRelayNode } from "@/db/relay-control-plane";
import { claimDevice, DomainError } from "@/db/repository";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
await authenticateRelayNode(request);
const payload = await readJsonMutation<{
bootstrap_token?: string;
source_hash?: string;
os?: string;
ed25519_public?: string;
x25519_public?: string;
identity_fingerprint?: string;
transport_mode?: string;
registration_proof?: string;
daemon_version?: string;
registration_retry_key?: string;
}>(request);
const sourceHash = payload.source_hash?.trim().toLowerCase() ?? "";
if (!/^[0-9a-f]{64}$/u.test(sourceHash)) {
throw new DomainError("registration_rate_limited", "注册来源摘要无效", 429, true, 60);
}
const result = await claimDevice({
bootstrapToken: payload.bootstrap_token ?? "",
trustedSourceHash: sourceHash,
os: payload.os ?? "",
ed25519Public: payload.ed25519_public ?? "",
x25519Public: payload.x25519_public ?? "",
identityFingerprint: payload.identity_fingerprint ?? "",
transportMode: payload.transport_mode ?? "",
registrationProof: payload.registration_proof ?? "",
daemonVersion: payload.daemon_version ?? "",
registrationRetryKey: payload.registration_retry_key ?? "",
});
return Response.json(result, {
status: 200,
headers: { "cache-control": "no-store" },
});
} catch (error) {
const response = apiError(error);
response.headers.set("cache-control", "no-store");
return response;
}
}
@@ -0,0 +1,22 @@
import {
authenticateRelayNode,
resolveDeviceRouteForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{ device_id?: string; token_hash?: string }>(request);
return Response.json(
await resolveDeviceRouteForRelay({
principal,
deviceId: payload.device_id ?? "",
tokenHash: payload.token_hash ?? "",
}),
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,22 @@
import {
authenticateRelayNode,
resolveHandoffRouteForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{ ticket?: string; pwa_origin?: string }>(request);
return Response.json(
await resolveHandoffRouteForRelay({
principal,
ticket: payload.ticket ?? "",
pwaOrigin: payload.pwa_origin ?? "",
}),
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,25 @@
import {
authenticateRelayNode,
resolvePhoneRouteForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
route_handle?: string;
phone_token_hash?: string;
}>(request);
return Response.json(
await resolvePhoneRouteForRelay({
principal,
routeHandle: payload.route_handle ?? "",
phoneTokenHash: payload.phone_token_hash ?? "",
}),
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,25 @@
import {
authenticateRelayNode,
resolveTenantRouteForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
tenant_id?: string;
placement_generation?: number;
}>(request);
return Response.json(
await resolveTenantRouteForRelay({
principal,
tenantId: payload.tenant_id ?? "",
placementGeneration: Number(payload.placement_generation ?? -1),
}),
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
@@ -0,0 +1,24 @@
import {
authenticateRelayNode,
revokePhoneForRelay,
} from "@/db/relay-control-plane";
import { apiError, readJsonMutation } from "../../../respond";
export async function POST(request: Request) {
try {
const principal = await authenticateRelayNode(request);
const payload = await readJsonMutation<{
tenant_id?: string;
phone_id?: string;
reason?: string;
}>(request);
return Response.json(await revokePhoneForRelay({
principal,
tenantId: payload.tenant_id ?? "",
phoneId: payload.phone_id ?? "",
reason: payload.reason ?? "",
}), { headers: { "cache-control": "no-store" } });
} catch (error) {
return apiError(error);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { env } from "cloudflare:workers";
import { getCloudViewer } from "@/app/cloud-auth";
import { createPhoneHandoffTicket } from "@/db/relay-control-plane";
import { DomainError, getOrCreateAccount } from "@/db/repository";
import { apiError } from "../../respond";
export async function POST() {
try {
const viewer = await getCloudViewer();
if (!viewer) throw new DomainError("authentication_required", "请先登录", 401);
const pwaOrigin = env.NEKONEST_CLOUD_PWA_ORIGIN?.trim() ?? "";
if (!pwaOrigin) {
throw new DomainError("pwa_handoff_unavailable", "Cloud PWA 地址尚未配置", 503, true, 30);
}
const account = await getOrCreateAccount(viewer);
return Response.json(
await createPhoneHandoffTicket({ accountId: account.id, pwaOrigin }),
{ status: 201, headers: { "cache-control": "no-store" } },
);
} catch (error) {
return apiError(error);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { DomainError } from "@/db/repository";
function requireJsonMutation(request: Request): void {
const contentType = request.headers
.get("content-type")
?.split(";", 1)[0]
.trim()
.toLowerCase();
if (contentType !== "application/json") {
throw new DomainError(
"json_required",
"写操作只接受 application/json",
415,
);
}
const contentLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(contentLength) && contentLength > 32_768) {
throw new DomainError("request_too_large", "请求内容过大", 413);
}
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) {
throw new DomainError("invalid_origin", "拒绝跨站写操作", 403);
}
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite && fetchSite !== "same-origin") {
throw new DomainError("invalid_fetch_site", "拒绝跨站写操作", 403);
}
}
export async function readJsonMutation<T>(request: Request): Promise<T> {
requireJsonMutation(request);
const body = await request.text();
if (new TextEncoder().encode(body).byteLength > 32_768) {
throw new DomainError("request_too_large", "请求内容过大", 413);
}
try {
return JSON.parse(body) as T;
} catch {
throw new DomainError("invalid_json", "请求不是有效 JSON", 400);
}
}
export function apiError(error: unknown) {
if (error instanceof DomainError) {
const headers = new Headers({ "cache-control": "no-store" });
if (error.retryAfterSeconds !== undefined) {
headers.set("retry-after", String(error.retryAfterSeconds));
}
return Response.json({
error_code: error.code,
error: error.code,
message: error.message,
retryable: error.retryable,
...(error.retryAfterSeconds === undefined
? {}
: { retry_after_seconds: error.retryAfterSeconds }),
...(error.actionUrl === undefined ? {} : { action_url: error.actionUrl }),
}, { status: error.status, headers });
}
console.error("Unhandled NekoNest Cloud API error", error);
return Response.json(
{
error_code: "internal_error",
error: "internal_error",
message: "服务暂时不可用,请稍后重试",
retryable: true,
},
{ status: 500, headers: { "cache-control": "no-store" } },
);
}
+15
View File
@@ -0,0 +1,15 @@
import { getServiceStatusSnapshot } from "@/db/repository";
import { apiError } from "../respond";
export async function GET() {
try {
const snapshot = await getServiceStatusSnapshot();
return Response.json(snapshot, {
headers: { "cache-control": "no-store" },
});
} catch (error) {
const response = apiError(error);
response.headers.set("cache-control", "no-store");
return response;
}
}
+90
View File
@@ -0,0 +1,90 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
userId: string;
displayName: string;
email: string;
fullName: string | null;
};
const USER_ID_HEADER = "oai-authenticated-user-id";
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const userId = requestHeaders.get(USER_ID_HEADER);
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!userId || !email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
userId,
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { env } from "cloudflare:workers";
import { redirect } from "next/navigation";
import {
getChatGPTUser,
requireChatGPTUser,
type ChatGPTUser,
} from "./chatgpt-auth";
import type { CloudIdentity } from "@/db/repository";
export type CloudViewer = CloudIdentity & {
isLocalDemo: boolean;
isAdmin: boolean;
};
function toIdentity(user: ChatGPTUser): CloudIdentity {
return {
userId: user.userId,
email: user.email,
displayName: user.displayName,
};
}
function localDemoIdentity(): CloudIdentity | null {
if (process.env.NODE_ENV === "production") return null;
return {
userId: "local-demo-user",
email: "demo@nekonest.local",
displayName: "本地演示账户",
};
}
function isAdminEmail(email: string, isLocalDemo: boolean): boolean {
if (isLocalDemo) return true;
const configured = env.NEKONEST_CLOUD_ADMIN_EMAILS ?? "";
const allowed = configured
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
return allowed.includes(email.toLowerCase());
}
export async function getCloudViewer(): Promise<CloudViewer | null> {
const signedIn = await getChatGPTUser();
const demo = signedIn ? null : localDemoIdentity();
const identity = signedIn ? toIdentity(signedIn) : demo;
if (!identity) return null;
const isLocalDemo = Boolean(demo);
return {
...identity,
isLocalDemo,
isAdmin: isAdminEmail(identity.email, isLocalDemo),
};
}
export async function requireCloudViewer(returnTo: string): Promise<CloudViewer> {
const viewer = await getCloudViewer();
if (viewer) return viewer;
const user = await requireChatGPTUser(returnTo);
const identity = toIdentity(user);
return {
...identity,
isLocalDemo: false,
isAdmin: isAdminEmail(identity.email, false),
};
}
export async function requireAdminViewer(returnTo = "/admin"): Promise<CloudViewer> {
const viewer = await requireCloudViewer(returnTo);
if (!viewer.isAdmin) redirect("/dashboard?admin=denied");
return viewer;
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useSyncExternalStore } from "react";
function subscribeToConnectivity(onStoreChange: () => void) {
const handleOnline = () => onStoreChange();
const handleOffline = () => onStoreChange();
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}
function getOfflineSnapshot() {
return !navigator.onLine;
}
function getServerOfflineSnapshot() {
return false;
}
export function ConnectivityBanner() {
const offline = useSyncExternalStore(
subscribeToConnectivity,
getOfflineSnapshot,
getServerOfflineSnapshot,
);
if (!offline) return null;
return (
<div className="connectivity-banner" role="status" aria-live="polite">
<strong>线</strong>
<span>
</span>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
export function RouteLoadingState({
area,
description,
}: {
area: string;
description: string;
}) {
return (
<main className="route-state-shell" aria-busy="true" aria-live="polite">
<section className="route-state-card route-loading-card">
<span className="route-state-kicker">{area}</span>
<h1></h1>
<p>{description}</p>
<div className="route-loading-lines" aria-hidden="true">
<span />
<span />
<span />
</div>
</section>
</main>
);
}
export function RouteErrorState({
area,
title,
description,
reset,
dashboard = false,
}: {
area: string;
title: string;
description: string;
reset: () => void;
dashboard?: boolean;
}) {
return (
<main className="route-state-shell">
<section className="route-state-card route-error-card" role="alert">
<span className="route-state-kicker">{area}</span>
<h1>{title}</h1>
<p>{description}</p>
<p className="route-state-note">
线
</p>
<div className="route-state-actions">
<button className="button button-primary" type="button" onClick={reset}>
</button>
<Link className="button button-secondary" href="/status">
</Link>
<Link
className="route-state-link"
href={dashboard ? "/dashboard/feedback" : "/"}
>
{dashboard ? "仍有问题,提交反馈" : "返回首页"}
</Link>
</div>
</section>
</main>
);
}
+257
View File
@@ -0,0 +1,257 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { chatGPTSignInPath, chatGPTSignOutPath } from "../chatgpt-auth";
import { getCloudViewer, type CloudViewer } from "../cloud-auth";
import {
getActiveBeta,
getServiceStatusSnapshot,
type ServiceStatusSnapshot,
} from "@/db/repository";
import { ConnectivityBanner } from "./ConnectivityBanner";
const serviceStatusLabels = {
operational: "服务正常",
maintenance: "计划维护",
degraded: "服务降级",
outage: "服务中断",
} as const;
export function Brand({ compact = false }: { compact?: boolean }) {
return (
<span className="brand-lockup">
<span className="brand-mark" aria-hidden="true">
<svg viewBox="0 0 40 40" role="img">
<path d="M8 16 5 6l11 6h8L35 6l-3 10v10c0 6-5 10-12 10S8 32 8 26V16Z" />
<path d="M14 23h.1M26 23h.1M16 29c2.4 1.8 5.6 1.8 8 0" />
</svg>
</span>
{!compact && (
<span className="brand-type">
<strong>NekoNest</strong>
<span>Cloud</span>
</span>
)}
</span>
);
}
export function StatusPill({
tone = "neutral",
children,
}: {
tone?: "good" | "warn" | "danger" | "neutral" | "info";
children: ReactNode;
}) {
return <span className={`status-pill status-${tone}`}>{children}</span>;
}
export async function PublicShell({
children,
serviceStatus: providedServiceStatus,
}: {
children: ReactNode;
serviceStatus?: ServiceStatusSnapshot;
}) {
const [viewer, beta, serviceStatus] = await Promise.all([
getCloudViewer(),
getActiveBeta(),
providedServiceStatus ?? getServiceStatusSnapshot(),
]);
return (
<div className="site-shell">
<header className="public-header">
<div className="public-header-inner">
<Link className="brand-link" href="/" aria-label="NekoNest Cloud 首页">
<Brand />
</Link>
<nav className="public-nav" aria-label="主要导航">
<Link href="/#how"></Link>
<Link href="/download"></Link>
<Link href="/pricing"></Link>
<Link href="/trust"></Link>
<Link href="/privacy"></Link>
<Link href="/readiness">线</Link>
<Link href="/status"></Link>
</nav>
<div className="header-actions">
<span className="beta-chip"> · {beta ? "公测免费" : "公测已结束"}</span>
{viewer ? (
<Link className="button button-small button-primary" href="/dashboard">
</Link>
) : (
<Link
className="button button-small button-primary"
href={chatGPTSignInPath("/dashboard")}
>
</Link>
)}
</div>
</div>
</header>
<ConnectivityBanner />
<main>{children}</main>
<footer className="public-footer">
<div className="footer-grid">
<div>
<Brand />
<p> coding-agent</p>
</div>
<div>
<strong></strong>
<Link href="/download"></Link>
<Link href="/pricing"></Link>
<Link href="/trust"></Link>
<Link href="/privacy"></Link>
<Link href="/readiness">线</Link>
<Link href="/status"></Link>
</div>
<div>
<strong></strong>
<p>Cloud Token</p>
</div>
<div>
<strong></strong>
<p></p>
</div>
</div>
<div className="footer-bottom">
<span>© 2026 NekoNest Cloud prototype</span>
<span>{serviceStatusLabels[serviceStatus.status]} · </span>
</div>
</footer>
</div>
);
}
const dashboardNav = [
{ href: "/dashboard", label: "总览", icon: "⌂" },
{ href: "/dashboard/hosts", label: "主机", icon: "▣" },
{ href: "/dashboard/billing", label: "公测权益", icon: "○" },
{ href: "/dashboard/security", label: "安全与设备", icon: "◇" },
{ href: "/dashboard/feedback", label: "问题反馈", icon: "?" },
] as const;
export async function DashboardShell({
viewer,
active,
children,
serviceStatus: providedServiceStatus,
}: {
viewer: CloudViewer;
active: string;
children: ReactNode;
serviceStatus?: ServiceStatusSnapshot;
}) {
const serviceStatus =
providedServiceStatus ?? (await getServiceStatusSnapshot());
return (
<div className="cloud-shell">
<aside className="cloud-sidebar">
<Link className="brand-link cloud-brand" href="/">
<Brand />
</Link>
<div className="prototype-notice">
<span className="notice-dot" />
</div>
<nav className="cloud-nav" aria-label="控制台导航">
{dashboardNav.map((item) => (
<Link
className={active === item.href ? "active" : undefined}
href={item.href}
key={item.href}
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
</Link>
))}
{viewer.isAdmin && (
<Link className={active === "/admin" ? "active" : undefined} href="/admin">
<span aria-hidden="true"></span>
</Link>
)}
</nav>
<div className="sidebar-account">
<span className="account-avatar" aria-hidden="true">
{viewer.displayName.slice(0, 1).toUpperCase()}
</span>
<span>
<strong>{viewer.displayName}</strong>
<small>{viewer.isLocalDemo ? "本地演示身份" : viewer.email}</small>
</span>
{!viewer.isLocalDemo && (
<Link href={chatGPTSignOutPath("/")} aria-label="退出登录">
</Link>
)}
</div>
</aside>
<div className="cloud-main">
<ConnectivityBanner />
{serviceStatus.activeIncidents.length > 0 && (
<div
className={`service-incident-banner incident-${serviceStatus.status}`}
role="status"
>
<span>
<strong>{serviceStatusLabels[serviceStatus.status]}</strong>
{serviceStatus.activeIncidents[0].title}
</span>
<Link href="/status"> </Link>
</div>
)}
{viewer.isLocalDemo && (
<div className="demo-banner" role="status">
使线
</div>
)}
{children}
</div>
</div>
);
}
export function PageHeading({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<header className="page-heading">
<div>
{eyebrow && <span className="eyebrow">{eyebrow}</span>}
<h1>{title}</h1>
<p>{description}</p>
</div>
{actions && <div className="page-actions">{actions}</div>}
</header>
);
}
export function formatMoney(amountMinor: number, currency = "CNY") {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amountMinor / 100);
}
export function formatDate(value: string | null, includeTime = false) {
if (!value) return "未设定";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "short",
day: "numeric",
...(includeTime ? { hour: "2-digit", minute: "2-digit" } : {}),
}).format(new Date(value));
}
+138
View File
@@ -0,0 +1,138 @@
import { DAEMON_RELEASE_POLICY } from "../release/daemon-policy.mjs";
import {
compareStableVersions,
MINIMUM_CLOUD_DAEMON_VERSION,
parseStableVersion,
} from "../release/daemon-version.ts";
export { MINIMUM_CLOUD_DAEMON_VERSION };
type ReleaseEnvironment = Partial<{
NEKONEST_CLOUD_DAEMON_RELEASE_VERSION: string;
NEKONEST_CLOUD_DAEMON_RELEASE_BASE_URL: string;
NEKONEST_CLOUD_DAEMON_WINDOWS_AMD64_SHA256: string;
NEKONEST_CLOUD_DAEMON_LINUX_AMD64_SHA256: string;
NEKONEST_CLOUD_DAEMON_LINUX_ARM64_SHA256: string;
}>;
export type DaemonReleaseAsset = {
platform: "windows" | "linux";
architecture: "amd64" | "arm64";
label: string;
filename: string;
downloadUrl: string;
sha256: string;
};
export type DaemonReleaseState =
| {
available: false;
reason: "not_configured" | "invalid_config" | "incompatible_version";
minimumVersion: string;
}
| {
available: true;
version: string;
minimumVersion: string;
releasePageUrl: string;
checksumsUrl: string;
assets: DaemonReleaseAsset[];
};
const ASSETS = DAEMON_RELEASE_POLICY.assets;
function safeReleaseBaseUrl(raw: string | undefined, version: string): string {
const fallback = `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/download/v${version}`;
const parsed = new URL(raw?.trim() || fallback);
if (
parsed.protocol !== "https:" ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
) {
throw new Error("unsafe_release_base_url");
}
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
return parsed.toString().replace(/\/$/, "");
}
function normalizedChecksum(value: string | undefined): string {
const checksum = value?.trim().toLowerCase() ?? "";
if (!/^[0-9a-f]{64}$/.test(checksum)) throw new Error("invalid_release_checksum");
return checksum;
}
export function parseDaemonReleaseEnvironment(config: ReleaseEnvironment): DaemonReleaseState {
const version = config.NEKONEST_CLOUD_DAEMON_RELEASE_VERSION?.trim() ?? "";
if (!version) {
return {
available: false,
reason: "not_configured",
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
};
}
const parsedVersion = parseStableVersion(version);
const minimumVersion = parseStableVersion(MINIMUM_CLOUD_DAEMON_VERSION);
if (!parsedVersion || !minimumVersion) {
return {
available: false,
reason: "invalid_config",
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
};
}
if (compareStableVersions(parsedVersion, minimumVersion) < 0) {
return {
available: false,
reason: "incompatible_version",
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
};
}
try {
const baseUrl = safeReleaseBaseUrl(
config.NEKONEST_CLOUD_DAEMON_RELEASE_BASE_URL,
version,
);
const assets = ASSETS.map((asset) => ({
platform: asset.platform as DaemonReleaseAsset["platform"],
architecture: asset.architecture as DaemonReleaseAsset["architecture"],
label: asset.label,
filename: asset.filename,
downloadUrl: `${baseUrl}/${asset.filename}`,
sha256: normalizedChecksum(config[asset.checksumEnv as keyof ReleaseEnvironment]),
}));
return {
available: true,
version,
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
releasePageUrl: `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/tag/v${version}`,
checksumsUrl: `${baseUrl}/checksums.txt`,
assets,
};
} catch {
return {
available: false,
reason: "invalid_config",
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
};
}
}
export async function getDaemonReleaseState(): Promise<DaemonReleaseState> {
const { env } = await import("cloudflare:workers");
return parseDaemonReleaseEnvironment(env);
}
export function checksumVerificationCommand(asset: DaemonReleaseAsset): string {
if (asset.platform === "windows") {
return [
`$expected = '${asset.sha256}'`,
`$actual = (Get-FileHash -LiteralPath '.\\${asset.filename}' -Algorithm SHA256).Hash.ToLowerInvariant()`,
`if ($actual -ne $expected) { throw 'SHA-256 不匹配,请删除文件' }`,
`Write-Host 'SHA-256 校验通过'`,
].join("\n");
}
return `printf '%s %s\\n' '${asset.sha256}' '${asset.filename}' | sha256sum -c -`;
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
export function OpenPwaButton() {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function openPwa() {
setPending(true);
setError("");
try {
const response = await fetch("/api/pwa/handoff", { method: "POST" });
const payload = await response.json() as {
pwa_url?: string;
message?: string;
};
if (!response.ok || !payload.pwa_url) {
throw new Error(payload.message || "暂时无法打开手机端");
}
window.location.assign(payload.pwa_url);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "暂时无法打开手机端");
setPending(false);
}
}
return (
<span>
<button className="button button-primary" type="button" disabled={pending} onClick={openPwa}>
{pending ? "正在创建安全入口…" : "打开 NekoNest PWA"}
</button>
{error && <small className="form-error" role="alert">{error}</small>}
</span>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { BetaAccessRequestRecord } from "@/db/repository";
type SubmitState = { loading: boolean; message: string; error: boolean };
export function AccessRequestForm({
pendingRequest,
}: {
pendingRequest: BetaAccessRequestRecord | null;
}) {
const router = useRouter();
const pendingKey = useRef<string | null>(null);
const [preferredOs, setPreferredOs] = useState("windows");
const [requestedSlots, setRequestedSlots] = useState(1);
const [useCase, setUseCase] = useState("");
const [state, setState] = useState<SubmitState>({ loading: false, message: "", error: false });
async function post(payload: Record<string, unknown>) {
pendingKey.current ??= crypto.randomUUID();
const response = await fetch("/api/beta-access", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, idempotencyKey: pendingKey.current }),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "操作失败");
pendingKey.current = null;
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await post({ action: "request", preferredOs, requestedSlots, useCase });
setState({ loading: false, message: "申请已提交,处理结果会显示在本页。", error: false });
router.refresh();
} catch (error) {
setState({ loading: false, message: error instanceof Error ? error.message : "提交失败", error: true });
}
}
async function cancel() {
if (!pendingRequest) return;
setState({ loading: true, message: "", error: false });
try {
await post({ action: "cancel", requestId: pendingRequest.id });
setState({ loading: false, message: "申请已撤回。", error: false });
router.refresh();
} catch (error) {
setState({ loading: false, message: error instanceof Error ? error.message : "撤回失败", error: true });
}
}
if (pendingRequest) {
return (
<div className="cloud-form feedback-form">
<p></p>
<button className="button button-secondary" type="button" onClick={cancel} disabled={state.loading}>
{state.loading ? "正在撤回…" : "撤回这条申请"}
</button>
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
</div>
);
}
return (
<form className="cloud-form feedback-form" onSubmit={submit}>
<div className="admin-form-grid">
<label>
<span>使</span>
<select value={preferredOs} onChange={(event) => setPreferredOs(event.target.value)}>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="both">Windows Linux</option>
</select>
</label>
<label>
<span></span>
<input type="number" min={1} max={3} value={requestedSlots} onChange={(event) => setRequestedSlots(Number(event.target.value))} required />
<small> 13 </small>
</label>
</div>
<label>
<span>使 NekoNest</span>
<textarea minLength={20} maxLength={1000} value={useCase} onChange={(event) => setUseCase(event.target.value)} placeholder="例如:我主要在 Windows 主机上使用 Codex,希望从手机查看并继续已有任务。请勿粘贴令牌、密码、项目代码或私密会话内容。" required />
<small>{useCase.length}/1000 · 使</small>
</label>
<button className="button button-primary" type="submit" disabled={state.loading}>
{state.loading ? "正在提交…" : "申请免费闭测资格"}
</button>
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
</form>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { requireCloudViewer } from "../../cloud-auth";
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { getBillingEntitlementPresentation } from "@/db/domain";
import { deriveInvitationDisplayState } from "@/db/invitations";
import { AccessRequestForm } from "./AccessRequestForm";
export const dynamic = "force-dynamic";
export default async function BillingPage() {
const viewer = await requireCloudViewer("/dashboard/billing");
const account = await getOrCreateAccount(viewer);
const snapshot = await getDashboardSnapshot(account);
const billingCopy = getBillingEntitlementPresentation(
snapshot.entitlement.mode,
snapshot.entitlement.publicBetaState,
);
const now = new Date();
const nowIso = now.toISOString();
const invitations = snapshot.grants.filter((grant) => grant.source === "admin_exemption");
const pendingAccessRequest = snapshot.accessRequests.find((request) => request.status === "requested") ?? null;
const endingSoon = invitations.find((invitation) => {
if (deriveInvitationDisplayState(invitation, nowIso) !== "active" || !invitation.ends_at) return false;
const remaining = new Date(invitation.ends_at).getTime() - now.getTime();
return remaining > 0 && remaining <= 7 * 24 * 60 * 60_000;
});
const available = snapshot.entitlement.unlimited
? "当前不按槽位限额"
: `${snapshot.entitlement.availableSlots ?? 0}`;
return (
<DashboardShell viewer={viewer} active="/dashboard/billing">
<div className="cloud-page">
<PageHeading
eyebrow="PUBLIC BETA / 公测权益"
title={snapshot.entitlement.mode === "none" ? "这里说明当前免费资格状态。" : "当前免费,不需要处理账单。"}
description="这里仅显示公测资格、主机占用和透明容量规则。报价、订单与支付功能暂不开放。"
/>
<section className="billing-status-panel">
<div>
<StatusPill tone={billingCopy.tone}>{billingCopy.status}</StatusPill>
<h2>{billingCopy.title}</h2>
<p>{billingCopy.description}</p>
</div>
<dl>
<div><dt></dt><dd>{snapshot.entitlement.activeSlots}</dd></div>
<div><dt></dt><dd>{snapshot.entitlement.reservedSlots}</dd></div>
<div><dt></dt><dd>{available}</dd></div>
<div><dt></dt><dd>{formatDate(snapshot.entitlement.effectiveUntil)}</dd></div>
</dl>
</section>
{endingSoon && (
<section className="panel full-panel attention-panel" role="status">
<div className="panel-heading"><div><span>CLOSED BETA NOTICE</span><h2></h2></div><StatusPill tone="warn"></StatusPill></div>
<p> {formatDate(endingSoon.ends_at, true)} </p>
</section>
)}
<div className="dashboard-columns billing-columns">
<section className="panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="billing-rules">
<span> </span>
<span> </span>
<span> </span>
<span> </span>
</div>
</section>
<section className="panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<p>使</p>
<div className="empty-inline"></div>
</section>
</div>
<section className="panel full-panel">
<div className="panel-heading">
<div><span>CLOSED BETA / </span><h2></h2></div>
<StatusPill tone={pendingAccessRequest ? "warn" : snapshot.entitlement.mode === "none" ? "neutral" : "good"}>{pendingAccessRequest ? "等待处理" : snapshot.entitlement.mode === "none" ? "可申请" : "已有资格"}</StatusPill>
</div>
{snapshot.entitlement.mode !== "none" ? (
<div className="empty-inline"></div>
) : (
<AccessRequestForm pendingRequest={pendingAccessRequest} />
)}
{snapshot.accessRequests.length > 0 && (
<div className="admin-gate-table invitation-history">
{snapshot.accessRequests.map((request) => (
<article key={request.id}>
<StatusPill tone={request.status === "approved" ? "good" : request.status === "requested" ? "warn" : "neutral"}>{request.status === "approved" ? "已批准" : request.status === "requested" ? "审核中" : request.status === "declined" ? "暂未批准" : "已撤回"}</StatusPill>
<div><strong>{request.requested_slots} · {request.preferred_os === "both" ? "Windows 和 Linux" : request.preferred_os === "windows" ? "Windows" : "Linux"}</strong><small>{request.id}</small></div>
<span>{formatDate(request.requested_at, true)}</span>
<span>{request.admin_response || "尚无处理说明"}</span>
</article>
))}
</div>
)}
</section>
<section className="panel full-panel">
<div className="panel-heading"><div><span>CLOSED BETA / </span><h2></h2></div><StatusPill tone={invitations.some((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active") ? "good" : "neutral"}>{invitations.filter((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active").length} </StatusPill></div>
<p></p>
<div className="admin-gate-table invitation-history">
{invitations.length ? invitations.map((invitation) => {
const state = deriveInvitationDisplayState(invitation, nowIso);
return <article key={invitation.id}><StatusPill tone={state === "active" ? "good" : "neutral"}>{state === "active" ? "有效" : state === "expired" ? "已到期" : "已撤销"}</StatusPill><div><strong>{invitation.capacity_slots === null ? "当前不按主机槽位限额" : `${invitation.capacity_slots} 个主机槽位`}</strong><small>{invitation.id}</small></div><span>{formatDate(invitation.starts_at, true)}</span><span>{formatDate(invitation.ends_at, true)}</span></article>;
}) : <div className="empty-inline"></div>}
</div>
</section>
<section className="panel full-panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="billing-rules">
<span>1. </span>
<span>2. </span>
<span>3. </span>
<span>4. 退</span>
</div>
</section>
</div>
</DashboardShell>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { TenantConnectionSummary } from "@/db/repository";
export type ConnectionCopy = {
label: string;
detail: string;
nextStep: string;
tone: "good" | "warn" | "danger" | "neutral" | "info";
};
const connectionCopy: Record<TenantConnectionSummary["state"], ConnectionCopy> = {
provisioning: {
label: "正在分配中继",
detail: "主机已认领;租户正在分配 home region 与共享 Relay 节点。",
nextStep: "保持 daemon 的服务地址与设备令牌不变,它会在同一地址自动重试。",
tone: "info",
},
ready: {
label: "中继已就绪",
detail: "当前 placement generation 已分配到健康的共享 Relay 节点。",
nextStep: "daemon 继续连接注册时使用的稳定服务地址;节点位置不会暴露给客户端。",
tone: "good",
},
suspended: {
label: "租户已暂停",
detail: "运行时或公测资格当前处于暂停状态,设备记录仍被保留。",
nextStep: "在恢复前不要删除本地主机配置。",
tone: "warn",
},
unavailable: {
label: "状态待核实",
detail: "控制面缺少完整 placement 或授权状态,因此不会允许建立连接。",
nextStep: "稍后重试;持续出现时联系公测维护者。",
tone: "warn",
},
};
export function getConnectionCopy(state: TenantConnectionSummary["state"]): ConnectionCopy {
return connectionCopy[state];
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { RouteErrorState } from "../components/RouteStates";
export default function DashboardError({ reset }: { reset: () => void }) {
return (
<RouteErrorState
area="控制台"
title="控制台暂时没有加载出来"
description="账户或主机状态这次未能安全读取,因此页面没有猜测或沿用旧结果。"
reset={reset}
dashboard
/>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { FeedbackCategory } from "@/db/repository";
type SubmitState = { loading: boolean; message: string; error: boolean };
export function FeedbackForm() {
const router = useRouter();
const pendingKey = useRef<string | null>(null);
const [category, setCategory] = useState<FeedbackCategory>("connection_issue");
const [message, setMessage] = useState("");
const [state, setState] = useState<SubmitState>({
loading: false,
message: "",
error: false,
});
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
pendingKey.current ??= crypto.randomUUID();
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
category,
message,
idempotencyKey: pendingKey.current,
}),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "提交失败");
pendingKey.current = null;
setMessage("");
setState({
loading: false,
message: "已收到。处理结果会显示在本页,不需要重复提交。",
error: false,
});
router.refresh();
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "提交失败",
error: true,
});
}
}
return (
<form className="cloud-form feedback-form" onSubmit={submit}>
<label>
<span></span>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
>
<option value="connection_issue"></option>
<option value="bug"></option>
<option value="suggestion">使</option>
<option value="other"></option>
</select>
</label>
<label>
<span></span>
<textarea
minLength={10}
maxLength={2000}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="请写清出现了什么、你原本想完成什么;不要粘贴令牌、密码或私密会话内容。"
required
/>
<small>{message.length}/2000 · </small>
</label>
<button className="button button-primary" type="submit" disabled={state.loading}>
{state.loading ? "正在提交…" : "提交反馈"}
</button>
{state.message && (
<p className={state.error ? "form-error" : "form-success"} role="status">
{state.message}
</p>
)}
</form>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { requireCloudViewer } from "../../cloud-auth";
import {
DashboardShell,
PageHeading,
StatusPill,
formatDate,
} from "../../components/Shells";
import {
getOrCreateAccount,
listAccountFeedback,
type FeedbackCategory,
} from "@/db/repository";
import { FeedbackForm } from "./FeedbackForm";
export const dynamic = "force-dynamic";
const categoryLabels: Record<FeedbackCategory, string> = {
connection_issue: "连接或接入",
bug: "功能异常",
suggestion: "使用建议",
other: "其他",
};
export default async function FeedbackPage() {
const viewer = await requireCloudViewer("/dashboard/feedback");
const account = await getOrCreateAccount(viewer);
const feedback = await listAccountFeedback(account.id);
return (
<DashboardShell viewer={viewer} active="/dashboard/feedback">
<div className="cloud-page narrow-cloud-page">
<PageHeading
eyebrow="BETA FEEDBACK / 公测反馈"
title="遇到问题,直接告诉我们。"
description="这是免费公测期的站内反馈通道。后台回复会保存在这里;当前不承诺即时客服,但接入故障会优先处理。"
/>
<div className="feedback-layout">
<section className="panel form-panel">
<div className="panel-heading">
<div><span></span><h2></h2></div>
</div>
<FeedbackForm />
</section>
<aside className="form-aside feedback-aside">
<span className="eyebrow"></span>
<h2></h2>
<ol>
<li><strong></strong><p></p></li>
<li><strong></strong><p> daemon </p></li>
<li><strong></strong><p></p></li>
</ol>
</aside>
</div>
<section className="panel full-panel">
<div className="panel-heading">
<div><span></span><h2></h2></div>
<StatusPill tone="info">{feedback.length} </StatusPill>
</div>
{feedback.length ? (
<div className="feedback-list">
{feedback.map((item) => (
<article key={item.id}>
<div className="feedback-meta">
<StatusPill tone={item.status === "resolved" ? "good" : "warn"}>
{item.status === "resolved" ? "已回复" : "待处理"}
</StatusPill>
<span>{categoryLabels[item.category]}</span>
<time>{formatDate(item.created_at, true)}</time>
</div>
<p>{item.message}</p>
{item.admin_response && (
<div className="feedback-response">
<strong></strong>
<p>{item.admin_response}</p>
</div>
)}
</article>
))}
</div>
) : (
<div className="empty-inline"></div>
)}
</section>
</div>
</DashboardShell>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function HostRevokeButton({ hostId }: { hostId: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function revoke() {
if (!window.confirm("撤销后 daemon 令牌立即失效并释放槽位。保留主机 identity.json 时可通过新的配对码安全恢复。确定继续?")) return;
setBusy(true);
setError("");
try {
const response = await fetch("/api/hosts/revoke", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId, reason: "用户从主机列表撤销设备凭据" }),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "撤销失败");
router.refresh();
} catch (revokeError) {
setError(revokeError instanceof Error ? revokeError.message : "撤销失败");
} finally {
setBusy(false);
}
}
return (
<div>
<button className="button button-secondary" type="button" onClick={revoke} disabled={busy}>
{busy ? "正在撤销…" : "撤销主机"}
</button>
{error && <small className="form-error" role="alert">{error}</small>}
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function PairingCancelButton({
pairingId,
onCancelled,
}: {
pairingId: string;
onCancelled?: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function cancel() {
if (
!window.confirm(
"取消后这枚配对凭证立即失效并释放占位。若 daemon 正在认领,最终状态以主机列表为准。确定取消?",
)
) {
return;
}
setBusy(true);
setError("");
try {
const response = await fetch("/api/hosts/pairing/cancel", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ pairingId }),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) {
throw new Error(body.message || "取消配对请求失败");
}
onCancelled?.();
router.refresh();
} catch (cancelError) {
setError(
cancelError instanceof Error ? cancelError.message : "取消配对请求失败",
);
} finally {
setBusy(false);
}
}
return (
<div className="pairing-cancel-action">
<button
className="button button-secondary"
type="button"
onClick={cancel}
disabled={busy}
>
{busy ? "正在取消…" : "取消配对"}
</button>
{error && (
<small className="form-error" role="alert">
{error}
</small>
)}
</div>
);
}
+363
View File
@@ -0,0 +1,363 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { PairingCancelButton } from "../PairingCancelButton";
import {
buildDaemonRegistrationCommand,
daemonStartCommand,
type SupportedHostOS,
} from "./onboarding";
import type { PairingAccessState } from "../../onboarding";
type PairingResult = { id: string; bootstrapToken: string; expiresAt: string };
type PairingView = PairingResult & {
requestedName: string;
os: SupportedHostOS;
};
type PairingProgress = {
id: string;
status: "waiting" | "claimed" | "expired" | "locked" | "cancelled";
expiresAt: string;
claimedHostId: string | null;
claimedAt: string | null;
claimAttemptState: "not_seen" | "seen" | "invalid";
lastClaimAttemptAt: string | null;
};
type PairingTerminal = Exclude<PairingProgress["status"], "waiting" | "claimed">;
type PairingCompletion = {
hostId: string;
claimedAt: string;
};
type CopyTarget = "command" | "token" | "start";
const unavailableCopy: Record<Exclude<PairingAccessState, "available">, { title: string; detail: string; href: string; action: string; readiness?: boolean }> = {
request_pending: {
title: "免费闭测申请正在审核",
detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。",
href: "/dashboard/billing",
action: "查看申请进度",
},
gated: {
title: "公开接入仍由安全门禁阻止",
detail: "免费政策已经预设,但 P0 证据尚未齐全。可以申请小范围免费闭测资格。",
href: "/dashboard/billing",
action: "申请免费闭测",
readiness: true,
},
inactive: {
title: "公开公测当前未开放",
detail: "该账户没有有效的免费公测或闭测邀请;可以提交闭测申请,不会因此创建订单或要求付款。",
href: "/dashboard/billing",
action: "申请免费闭测",
},
full: {
title: "当前没有可用主机槽位",
detail: "已有主机和等待中的配对请求已经占满当前免费容量,请先取消旧请求或联系管理员调整邀请。",
href: "/dashboard/hosts",
action: "管理主机和配对",
},
};
const terminalCopy: Record<PairingTerminal, { title: string; detail: string }> = {
expired: {
title: "这枚配对码已经过期",
detail: "十分钟有效期已经结束,原码不能恢复。确认 daemon 已准备好后再生成一枚新码。",
},
locked: {
title: "这枚配对码已经锁定",
detail: "错误尝试次数已达到上限,原码不能继续使用。请核对 daemon 和复制步骤后重新生成。",
},
cancelled: {
title: "这枚配对码已经取消",
detail: "原码已失效并释放预留容量;需要接入时可以重新生成。",
},
};
export function PairingForm({
accessState,
releaseAvailable,
minimumDaemonVersion,
connectOrigin,
}: {
accessState: PairingAccessState;
releaseAvailable: boolean;
minimumDaemonVersion: string;
connectOrigin: string;
}) {
const [name, setName] = useState("");
const [os, setOs] = useState<SupportedHostOS>("windows");
const [pairing, setPairing] = useState<PairingView | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState<CopyTarget | null>(null);
const [copyError, setCopyError] = useState("");
const [closedBetaBuildConfirmed, setClosedBetaBuildConfirmed] = useState(false);
const [progressError, setProgressError] = useState("");
const [completion, setCompletion] = useState<PairingCompletion | null>(null);
const [terminal, setTerminal] = useState<PairingTerminal | null>(null);
const [claimAttempt, setClaimAttempt] = useState<Pick<
PairingProgress,
"claimAttemptState" | "lastClaimAttemptAt"
>>({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
const resultHeading = useRef<HTMLHeadingElement>(null);
const pairingId = pairing?.id ?? null;
useEffect(() => {
if (pairing) resultHeading.current?.focus();
}, [pairing]);
useEffect(() => {
if (!pairingId) return;
let active = true;
let timer: number | undefined;
async function poll() {
try {
const response = await fetch(
`/api/hosts/pairing?pairing_id=${encodeURIComponent(pairingId!)}`,
{ cache: "no-store" },
);
const body = (await response.json()) as {
pairing?: PairingProgress;
message?: string;
};
if (!active) return;
if (!response.ok || !body.pairing) {
throw new Error(body.message || "暂时无法确认配对状态");
}
setProgressError("");
setClaimAttempt({
claimAttemptState: body.pairing.claimAttemptState,
lastClaimAttemptAt: body.pairing.lastClaimAttemptAt,
});
if (body.pairing.status === "claimed" && body.pairing.claimedHostId && body.pairing.claimedAt) {
setCompletion({
hostId: body.pairing.claimedHostId,
claimedAt: body.pairing.claimedAt,
});
setPairing(null);
setCopied(null);
setCopyError("");
return;
}
if (
body.pairing.status === "expired"
|| body.pairing.status === "locked"
|| body.pairing.status === "cancelled"
) {
setTerminal(body.pairing.status);
setPairing(null);
setCopied(null);
setCopyError("");
return;
}
timer = window.setTimeout(poll, 2_000);
} catch (pollError) {
if (!active) return;
setProgressError(
pollError instanceof Error
? `${pollError.message};页面会继续重试。`
: "暂时无法确认配对状态;页面会继续重试。",
);
timer = window.setTimeout(poll, 5_000);
}
}
timer = window.setTimeout(poll, 1_000);
return () => {
active = false;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [pairingId]);
if (accessState !== "available") {
const copy = unavailableCopy[accessState];
return (
<div className="empty-state pairing-access-blocked" role="status">
<span className="empty-symbol">×</span>
<h2>{copy.title}</h2>
<p>{copy.detail}</p>
<div className="pairing-result-actions">
<a className="button button-primary" href={copy.href}>{copy.action}</a>
{copy.readiness && <a className="button button-secondary" href="/readiness"></a>}
<a className="button button-secondary" href="/dashboard"></a>
</div>
</div>
);
}
async function copyText(value: string, target: CopyTarget) {
setCopyError("");
try {
if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable");
await navigator.clipboard.writeText(value);
setCopied(target);
} catch {
setCopied(null);
setCopyError("浏览器未允许自动复制。请点入对应文本框,使用 Ctrl+C 或系统复制操作手动复制。");
}
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setTerminal(null);
setCompletion(null);
if (!releaseAvailable && !closedBetaBuildConfirmed) {
setError("公开下载尚未就绪。请先取得并核验兼容的闭测构建,再确认后生成配对码。");
return;
}
setLoading(true);
try {
const response = await fetch("/api/hosts/pairing", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requestedName: name, os }),
});
const body = (await response.json()) as { pairing?: PairingResult; message?: string };
if (!response.ok || !body.pairing) throw new Error(body.message || "无法创建配对请求");
setPairing({
...body.pairing,
requestedName: name.trim(),
os,
});
setCopied(null);
setCopyError("");
setProgressError("");
setClaimAttempt({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "无法创建配对请求");
} finally {
setLoading(false);
}
}
if (completion) {
return (
<div className="empty-state pairing-complete" role="status">
<span className="empty-symbol"></span>
<h2></h2>
<p> revisiondaemon </p>
<small>{new Date(completion.claimedAt).toLocaleString("zh-CN")} · {completion.hostId}</small>
<div className="pairing-result-actions">
<a className="button button-primary" href="/dashboard/hosts"></a>
<button className="button button-secondary" type="button" onClick={() => setCompletion(null)}></button>
</div>
</div>
);
}
if (terminal) {
const copy = terminalCopy[terminal];
return (
<div className="empty-state pairing-terminal" role="status">
<span className="empty-symbol">×</span>
<h2>{copy.title}</h2>
<p>{copy.detail}</p>
<div className="pairing-result-actions">
<button className="button button-primary" type="button" onClick={() => setTerminal(null)}></button>
<a className="button button-secondary" href="/dashboard/hosts"></a>
</div>
</div>
);
}
if (pairing) {
const registrationCommand = buildDaemonRegistrationCommand({
os: pairing.os,
connectOrigin,
hostName: pairing.requestedName,
});
const startCommand = daemonStartCommand(pairing.os);
return (
<div className="pairing-result">
<span className="eyebrow">PAIRING REQUEST CREATED</span>
<h2 ref={resultHeading} tabIndex={-1}> {pairing.os === "windows" ? "Windows" : "Linux"} </h2>
<p> Cloud Connect URL </p>
<div className="pairing-progress" role="status" aria-live="polite">
<strong>{claimAttempt.claimAttemptState === "seen" ? "Cloud 已收到请求,但尚未认领" : claimAttempt.claimAttemptState === "invalid" ? "认领尝试时间待核实" : "尚未收到注册请求"}</strong>
<span>{claimAttempt.claimAttemptState === "seen"
? `最近一次尝试:${new Date(claimAttempt.lastClaimAttemptAt!).toLocaleString("zh-CN")}。这只证明 Cloud 收到过针对该配对 ID 的请求,不证明来源一定是你的 daemon。请先查看终端错误,并核对系统类型、配对码、daemon 版本和 identity.json;当前码有效时无需反复生成。`
: claimAttempt.claimAttemptState === "invalid"
? "Cloud 找到过针对这枚配对请求的记录,但时间异常,不能判断先后;请查看终端错误或提交反馈。"
: "请先在目标主机运行注册命令并粘贴一次性码。页面每两秒确认一次,尚未收到请求通常表示命令未运行、地址不可达或请求还没发出。"}</span>
</div>
{progressError && <p className="form-error" role="alert">{progressError}</p>}
<a className="text-link pairing-download-link" href="/download"> daemon SHA-256 </a>
<ol className="registration-steps">
<li>
<strong> daemon </strong>
<label className="pairing-copy-field">
<span>{pairing.os === "windows" ? "PowerShell" : "Bash"} </span>
<textarea
readOnly
rows={pairing.os === "windows" ? 12 : 10}
value={registrationCommand}
onFocus={(event) => event.currentTarget.select()}
aria-describedby="registration-command-note"
/>
</label>
<button className="button button-secondary" type="button" onClick={() => copyText(registrationCommand, "command")}>{copied === "command" ? "命令已复制" : "复制注册命令"}</button>
<small id="registration-command-note"></small>
</li>
<li>
<strong></strong>
<label className="pairing-copy-field">
<span></span>
<input readOnly value={pairing.bootstrapToken} onFocus={(event) => event.currentTarget.select()} autoComplete="off" spellCheck={false} />
</label>
<button className="button button-secondary" type="button" onClick={() => copyText(pairing.bootstrapToken, "token")}>{copied === "token" ? "配对码已复制" : "复制一次性配对码"}</button>
<small> {new Date(pairing.expiresAt).toLocaleString("zh-CN")}</small>
</li>
<li>
<strong> daemon</strong>
<label className="pairing-copy-field compact">
<span></span>
<input readOnly value={startCommand} onFocus={(event) => event.currentTarget.select()} />
</label>
<button className="button button-secondary" type="button" onClick={() => copyText(startCommand, "start")}>{copied === "start" ? "启动命令已复制" : "复制启动命令"}</button>
<small> daemon Relay </small>
</li>
</ol>
{copyError && <p className="form-error" role="alert">{copyError}</p>}
{copied && !copyError && <p className="form-success" role="status">使</p>}
<div className="prototype-callout"><strong>使</strong><span></span></div>
<div className="prototype-callout"><strong> daemon</strong><span> identity.json config.json Ed25519 daemon </span></div>
<div className="prototype-callout"><strong></strong><span></span></div>
<div className="pairing-result-actions">
<a className="button button-secondary" href="/dashboard/hosts"></a>
<PairingCancelButton
pairingId={pairing.id}
onCancelled={() => {
setPairing(null);
setTerminal("cancelled");
setCopied(null);
setCopyError("");
setProgressError("");
}}
/>
</div>
</div>
);
}
return (
<form className="cloud-form pairing-form" onSubmit={submit}>
{!releaseAvailable && (
<div className="prototype-callout daemon-preflight" role="group" aria-labelledby="daemon-preflight-title">
<strong id="daemon-preflight-title"> daemon </strong>
<span> SHA-256 </span>
<a className="text-link" href="/download"> v{minimumDaemonVersion} </a>
<label className="switch-row">
<input type="checkbox" checked={closedBetaBuildConfirmed} onChange={(event) => setClosedBetaBuildConfirmed(event.target.checked)} />
<span><strong></strong><small> v{minimumDaemonVersion} SHA-256 </small></span>
</label>
</div>
)}
<label><span></span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:家里工作站" minLength={2} maxLength={48} required /><small></small></label>
<fieldset><legend></legend><div className="choice-grid"><label className={os === "windows" ? "selected" : ""}><input type="radio" name="os" value="windows" checked={os === "windows"} onChange={() => setOs("windows")} /><strong>Windows</strong><small>amd64 · </small></label><label className={os === "linux" ? "selected" : ""}><input type="radio" name="os" value="linux" checked={os === "linux"} onChange={() => setOs("linux")} /><strong>Linux</strong><small>amd64 / arm64 · </small></label></div></fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
<button className="button button-primary" type="submit" disabled={loading || (!releaseAvailable && !closedBetaBuildConfirmed)}>{loading ? "正在创建…" : "生成 10 分钟配对码"}</button>
</form>
);
}
+72
View File
@@ -0,0 +1,72 @@
export type SupportedHostOS = "windows" | "linux";
export function assertSafeConnectOrigin(value: string): string {
const origin = value.trim().replace(/\/$/, "");
const parsed = new URL(origin);
const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
if (parsed.origin !== origin || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback))) {
throw new Error("Cloud Connect 服务必须使用 HTTPS origin(本机开发地址除外)");
}
return origin;
}
export function resolveConnectOrigin(
configured: string | undefined,
development = process.env.NODE_ENV !== "production",
): string {
const value = configured?.trim() ?? "";
if (value) return assertSafeConnectOrigin(value);
if (development) return "http://127.0.0.1:3000";
throw new Error("NEKONEST_CLOUD_CONNECT_ORIGIN is required outside development");
}
export function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function quoteBash(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function buildDaemonRegistrationCommand(input: {
os: SupportedHostOS;
connectOrigin: string;
hostName: string;
}): string {
const connectOrigin = assertSafeConnectOrigin(input.connectOrigin);
const hostName = input.hostName.trim();
if (!hostName) throw new Error("主机名称不能为空");
if (input.os === "windows") {
return [
`$env:NEKONEST_SERVER = ${quotePowerShell(connectOrigin)}`,
`$env:NEKONEST_TRANSPORT_MODE = 'sealed'`,
`$secureToken = Read-Host '粘贴一次性配对码' -AsSecureString`,
`$tokenPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)`,
"try {",
` $env:NEKONEST_BOOTSTRAP_TOKEN = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPtr)`,
` if ([string]::IsNullOrWhiteSpace($env:NEKONEST_BOOTSTRAP_TOKEN)) { throw '未读取到配对码' }`,
` & '.\\nekonest-daemon.exe' -register -name ${quotePowerShell(hostName)}`,
"} finally {",
" Remove-Item Env:NEKONEST_BOOTSTRAP_TOKEN -ErrorAction SilentlyContinue",
" [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPtr)",
"}",
].join("\n");
}
return [
"(",
" read -rsp '粘贴一次性配对码: ' NEKONEST_BOOTSTRAP_TOKEN",
" printf '\\n'",
` if [ -z "$NEKONEST_BOOTSTRAP_TOKEN" ]; then printf '未读取到配对码\\n' >&2; exit 1; fi`,
` export NEKONEST_SERVER=${quoteBash(connectOrigin)}`,
" export NEKONEST_TRANSPORT_MODE='sealed'",
" export NEKONEST_BOOTSTRAP_TOKEN",
` ./nekonest-daemon -register -name ${quoteBash(hostName)}`,
")",
].join("\n");
}
export function daemonStartCommand(os: SupportedHostOS): string {
return os === "windows" ? ".\\nekonest-daemon.exe" : "./nekonest-daemon";
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import { requireCloudViewer } from "../../../cloud-auth";
import { DashboardShell, PageHeading } from "../../../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { getDaemonReleaseState } from "../../../daemon-release";
import { deriveBetaOnboarding } from "../../onboarding";
import { PairingForm } from "./PairingForm";
import { resolveConnectOrigin } from "./onboarding";
import { env } from "cloudflare:workers";
export const dynamic = "force-dynamic";
export default async function NewHostPage() {
const viewer = await requireCloudViewer("/dashboard/hosts/new");
const account = await getOrCreateAccount(viewer);
const [snapshot, daemonRelease] = await Promise.all([
getDashboardSnapshot(account),
getDaemonReleaseState(),
]);
const onboarding = deriveBetaOnboarding({
entitlement: snapshot.entitlement,
hasPendingRequest: snapshot.accessRequests.some((request) => request.status === "requested"),
});
const connectOrigin = resolveConnectOrigin(
env.NEKONEST_CLOUD_CONNECT_ORIGIN,
process.env.NODE_ENV !== "production",
);
return (
<DashboardShell viewer={viewer} active="/dashboard/hosts">
<div className="cloud-page narrow-cloud-page">
<PageHeading eyebrow="ADD OR RECOVER HOST / 添加或恢复主机" title="先创建一次性配对请求。" description="新主机和已撤销主机的安全恢复共用这一步;家里不需要打开入站端口。" actions={<Link className="button button-secondary" href="/download"> daemon</Link>} />
<div className="form-layout">
<section className="panel form-panel"><PairingForm accessState={onboarding.pairingAccessState} releaseAvailable={daemonRelease.available} minimumDaemonVersion={daemonRelease.minimumVersion} connectOrigin={connectOrigin} /></section>
<aside className="form-aside"><span className="eyebrow"></span><ol><li><strong></strong><p> SHA-256 </p></li><li><strong>daemon </strong><p>daemon </p></li><li><strong></strong><p> daemon </p></li><li><strong></strong><p></p></li></ol></aside>
</div>
</div>
</DashboardShell>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from "next/link";
import { requireCloudViewer } from "../../cloud-auth";
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { HostRevokeButton } from "./HostRevokeButton";
import { PairingCancelButton } from "./PairingCancelButton";
import { getConnectionCopy } from "../connection-copy";
import { deriveControlPlaneContact } from "@/db/device-control-plane";
export const dynamic = "force-dynamic";
function lifecycleLabel(value: string) {
return value === "active" ? "已启用" : value === "deactivated" ? "已撤销" : value;
}
function slotLabel(value: string) {
return value === "active" ? "占用中" : value === "released" ? "已释放" : value;
}
export default async function HostsPage() {
const viewer = await requireCloudViewer("/dashboard/hosts");
const account = await getOrCreateAccount(viewer);
const snapshot = await getDashboardSnapshot(account);
const connection = getConnectionCopy(snapshot.connection.state);
return (
<DashboardShell viewer={viewer} active="/dashboard/hosts">
<div className="cloud-page">
<PageHeading
eyebrow="HOSTS / 主机"
title="主机与槽位"
description="只有启用的持久主机记录占槽位。离线不释放;明确停用或撤销后才释放。"
actions={<Link className="button button-primary" href="/dashboard/hosts/new"></Link>}
/>
<div className="entitlement-bar">
<div><span></span><strong>{snapshot.entitlement.mode === "public_beta" ? "公开公测" : snapshot.entitlement.mode === "grant" ? "闭测邀请" : "无"}</strong></div>
<div><span></span><strong>{snapshot.entitlement.activeSlots}</strong></div>
<div><span></span><strong>{snapshot.entitlement.reservedSlots}</strong></div>
<div><span></span><strong>{snapshot.entitlement.unlimited ? "不按槽位限额" : snapshot.entitlement.availableSlots}</strong></div>
</div>
<section className="panel full-panel">
<div className="panel-heading"><div><span></span><h2></h2></div><StatusPill tone={connection.tone}>{connection.label}</StatusPill></div>
<p>{connection.detail} {connection.nextStep}</p>
<p className="host-contact-note"> Cloud 线 Relay </p>
{snapshot.hosts.length ? (
<div className="host-list">
{snapshot.hosts.map((host) => {
const contact = deriveControlPlaneContact(host.control_plane_last_seen_at);
return (
<article className="host-row detailed-host" key={host.id}>
<span className={`host-icon host-${host.os}`}>{host.os === "windows" ? "W" : "L"}</span>
<div><strong>{host.name}</strong><small>{host.id}</small></div>
<div><span className="row-label"></span><strong>{slotLabel(host.slot_state)}</strong></div>
<div>
<span className="row-label">Daemon / </span>
<strong>{host.daemon_version || "版本待上报"}</strong>
<small>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "等待首次 Relay 授权"}</small>
</div>
<StatusPill tone={host.lifecycle === "active" ? contact.tone : "neutral"}>{host.lifecycle === "active" ? contact.label : lifecycleLabel(host.lifecycle)}</StatusPill>
{host.lifecycle === "active" && <HostRevokeButton hostId={host.id} />}
{host.lifecycle === "deactivated" && <Link className="button button-secondary" href="/dashboard/hosts/new"></Link>}
</article>
);
})}
</div>
) : (
<div className="empty-state"><span className="empty-symbol"></span><h3></h3><p>线</p><Link className="button button-primary" href="/dashboard/hosts/new"></Link></div>
)}
</section>
<section className="panel full-panel recovery-guide">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="recovery-options">
<article><StatusPill tone="good">identity.json </StatusPill><h3></h3><p> identity.json config.json使 daemon daemon Cloud ID </p></article>
<article><StatusPill tone="warn"></StatusPill><h3></h3><p> ID</p></article>
</div>
</section>
<section className="panel full-panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
{snapshot.pairings.length ? (
<div className="pairing-list">
{snapshot.pairings.map((pairing) => (
<article key={pairing.id}><div><strong>{pairing.requested_name}</strong><small>{pairing.os.toUpperCase()} · {pairing.id}</small></div><StatusPill tone="warn"> daemon</StatusPill><span>{formatDate(pairing.expires_at, true)}</span><PairingCancelButton pairingId={pairing.id} /></article>
))}
</div>
) : (
<div className="empty-inline"></div>
)}
</section>
</div>
</DashboardShell>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { RouteLoadingState } from "../components/RouteStates";
export default function DashboardLoading() {
return (
<RouteLoadingState
area="控制台"
description="正在核对账户、公测权益、主机和服务状态,请稍候。"
/>
);
}
+83
View File
@@ -0,0 +1,83 @@
export type PairingAccessState =
| "available"
| "request_pending"
| "gated"
| "inactive"
| "full";
export type BetaOnboardingState =
| "request_needed"
| "request_pending"
| "ready"
| "full";
export type BetaOnboarding = {
state: BetaOnboardingState;
pairingAccessState: PairingAccessState;
primaryHref: string;
primaryLabel: string;
tone: "good" | "warn" | "neutral" | "info";
title: string;
detail: string;
};
export function deriveBetaOnboarding(input: {
entitlement: {
mode: "public_beta" | "grant" | "none";
publicBetaState: "open" | "gated" | "inactive";
unlimited: boolean;
availableSlots: number | null;
};
hasPendingRequest: boolean;
}): BetaOnboarding {
const { entitlement } = input;
if (entitlement.mode === "none") {
if (input.hasPendingRequest) {
return {
state: "request_pending",
pairingAccessState: "request_pending",
primaryHref: "/dashboard/billing",
primaryLabel: "查看申请进度",
tone: "warn",
title: "免费闭测申请正在审核",
detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。",
};
}
const gated = entitlement.publicBetaState === "gated";
return {
state: "request_needed",
pairingAccessState: gated ? "gated" : "inactive",
primaryHref: "/dashboard/billing",
primaryLabel: "申请免费闭测",
tone: gated ? "warn" : "neutral",
title: gated ? "公开接入尚未开放" : "当前没有免费测试资格",
detail: gated
? "安全门禁仍在核对,可以先申请小范围免费闭测。"
: "可以提交免费闭测申请;不会绑定支付方式或自动转为付费。",
};
}
if (!entitlement.unlimited && (entitlement.availableSlots ?? 0) < 1) {
return {
state: "full",
pairingAccessState: "full",
primaryHref: "/dashboard/hosts",
primaryLabel: "管理已接入主机",
tone: "neutral",
title: "当前免费主机名额已用完",
detail: "可以取消等待中的配对、撤销不再使用的主机,或等待管理员调整闭测名额。",
};
}
return {
state: "ready",
pairingAccessState: "available",
primaryHref: "/dashboard/hosts/new",
primaryLabel: "连接主机",
tone: "good",
title: entitlement.mode === "grant" ? "闭测资格已生效" : "免费公测资格已生效",
detail: entitlement.unlimited
? "当前策略不按主机槽位限额,可以开始连接主机。"
: `还可以连接 ${entitlement.availableSlots ?? 0} 台主机。`,
};
}
+138
View File
@@ -0,0 +1,138 @@
import Link from "next/link";
import { requireCloudViewer } from "../cloud-auth";
import {
DashboardShell,
PageHeading,
StatusPill,
formatDate,
} from "../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { getConnectionCopy } from "./connection-copy";
import { OpenPwaButton } from "./OpenPwaButton";
import { deriveControlPlaneContact } from "@/db/device-control-plane";
import { deriveBetaOnboarding } from "./onboarding";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const viewer = await requireCloudViewer("/dashboard");
const account = await getOrCreateAccount(viewer);
const snapshot = await getDashboardSnapshot(account);
const activeHosts = snapshot.hosts.filter((host) => host.lifecycle === "active");
const entitlement = snapshot.entitlement;
const connection = getConnectionCopy(snapshot.connection.state);
const onboarding = deriveBetaOnboarding({
entitlement,
hasPendingRequest: snapshot.accessRequests.some((request) => request.status === "requested"),
});
const entitlementLabel = entitlement.mode === "public_beta"
? "公测免费"
: entitlement.mode === "grant"
? "闭测邀请"
: entitlement.publicBetaState === "gated"
? "等待安全门禁"
: "无有效权益";
const entitlementDetail = entitlement.mode === "none" && entitlement.publicBetaState === "gated"
? `公开接入仍有 ${entitlement.blockedP0} 项 P0 未通过;管理员邀请账户可继续闭测`
: entitlement.unlimited
? "费用全免;当前策略未按槽位限额"
: `可用 ${entitlement.availableSlots ?? 0} 个槽位`;
return (
<DashboardShell
viewer={viewer}
active="/dashboard"
serviceStatus={snapshot.serviceStatus}
>
<div className="cloud-page">
<PageHeading
eyebrow="OVERVIEW / 总览"
title={`晚上好,${viewer.displayName}`}
description="这里显示控制平面真实保存的公测接入状态;原生会话仍由你的主机和 NekoNest PWA 提供。"
actions={snapshot.connection.state === "ready"
? <OpenPwaButton />
: <Link className="button button-primary" href={onboarding.primaryHref}>{onboarding.primaryLabel}</Link>}
/>
<section className="metric-grid" aria-label="账户概览">
<article className="metric-card accent-card">
<span></span>
<strong>{entitlementLabel}</strong>
<small>{entitlementDetail}</small>
</article>
<article className="metric-card">
<span></span>
<strong>{entitlement.activeSlots}</strong>
<small>{entitlement.reservedSlots ? `${entitlement.reservedSlots} 个配对请求占位中` : "没有等待配对的占位"}</small>
</article>
<article className="metric-card">
<span></span>
<strong>{connection.label}</strong>
<small>{snapshot.connection.homeRegion
? `${snapshot.connection.homeRegion} · generation ${snapshot.connection.placementGeneration ?? "-"}`
: "等待 home region 分配"}</small>
</article>
<article className="metric-card">
<span></span>
<strong></strong>
<small></small>
</article>
</section>
<div className="dashboard-columns">
<section className="panel">
<div className="panel-heading">
<div><span></span><h2></h2></div>
<Link href="/dashboard/hosts"> </Link>
</div>
{activeHosts.length ? (
<div className="host-list">
{activeHosts.slice(0, 4).map((host) => {
const contact = deriveControlPlaneContact(host.control_plane_last_seen_at);
return (
<article className="host-row" key={host.id}>
<span className={`host-icon host-${host.os}`}>{host.os === "windows" ? "W" : "L"}</span>
<div><strong>{host.name}</strong><small>{host.daemon_version || "daemon 版本待上报"}</small></div>
<div className="host-state">
<StatusPill tone={contact.tone}>{contact.label}</StatusPill>
<small>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "等待首次 Relay 授权"}</small>
</div>
</article>
);
})}
</div>
) : (
<div className="empty-state compact-empty">
<span className="empty-symbol"></span>
<h3></h3>
<p>daemon Relay </p>
<Link className="button button-secondary" href="/dashboard/hosts/new"></Link>
</div>
)}
</section>
<section className="panel attention-panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="attention-list">
{onboarding.state !== "ready" && <article><StatusPill tone={onboarding.tone}>{onboarding.state === "request_pending" ? "审核中" : onboarding.state === "request_needed" ? "先申请" : "容量已满"}</StatusPill><div><strong>{onboarding.title}</strong><p>{onboarding.detail} <Link className="text-link" href={onboarding.primaryHref}>{onboarding.primaryLabel} </Link></p></div></article>}
<article><StatusPill tone={connection.tone}>{connection.label}</StatusPill><div><strong>{connection.detail}</strong><p>{connection.nextStep}</p></div></article>
{entitlement.publicBetaState === "gated" && <article><StatusPill tone="warn"></StatusPill><div><strong>P0 </strong><p></p></div></article>}
<article><StatusPill tone="info"></StatusPill><div><strong></strong><p></p></div></article>
<article><StatusPill tone="info"></StatusPill><div><strong></strong><p></p></div></article>
</div>
</section>
</div>
<section className="path-panel">
<div><span className="eyebrow">CONNECTION PATH</span><h2></h2></div>
<ol>
<li className={entitlement.mode !== "none" ? "done" : "current"}><span>01</span><div><strong></strong><p>{onboarding.title}{onboarding.detail}</p></div></li>
<li className={snapshot.pairings.length || activeHosts.length ? "done" : onboarding.state === "ready" ? "current" : undefined}><span>02</span><div><strong></strong><p></p></div></li>
<li className={activeHosts.length ? "done" : snapshot.pairings.length ? "current" : undefined}><span>03</span><div><strong>daemon </strong><p> authorization revision</p></div></li>
<li className={snapshot.connection.state === "ready" ? "done" : activeHosts.length ? "current" : undefined}><span>04</span><div><strong> sealed Relay </strong><p>{snapshot.connection.state === "ready" ? "daemon 保持稳定服务地址即可连接。" : "等待租户 placement 指向健康节点;不会向客户端释放后端节点 URL。"}</p></div></li>
</ol>
</section>
</div>
</DashboardShell>
);
}
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import type { AccountDeletionRequestRecord } from "@/db/repository";
type SubmitState = { loading: boolean; message: string; error: boolean };
const idle: SubmitState = { loading: false, message: "", error: false };
async function postDeletion(payload: Record<string, unknown>) {
const response = await fetch("/api/account/deletion", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...payload,
idempotencyKey: crypto.randomUUID(),
}),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "操作失败");
}
function formatRequestDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
export function AccountDeletionAction({
requests,
}: {
requests: AccountDeletionRequestRecord[];
}) {
const active = requests.find((request) => request.status === "requested");
const processing = requests.find((request) =>
request.status === "processing" || request.status === "relay_purged"
);
const [confirmed, setConfirmed] = useState(false);
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submitRequest(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postDeletion({ action: "request", confirmed, reason });
setState({ loading: false, message: "注销申请已记录。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
async function cancelRequest() {
if (!active) return;
setState({ loading: true, message: "", error: false });
try {
await postDeletion({ action: "cancel", requestId: active.id });
setState({ loading: false, message: "注销申请已撤回。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
if (active) {
return (
<div className="deletion-request-state">
<span></span>
<small>{formatRequestDate(active.requested_at)}</small>
{active.reason && <p>{active.reason}</p>}
<button
className="button button-secondary"
type="button"
onClick={cancelRequest}
disabled={state.loading}
>
{state.loading ? "正在撤回…" : "撤回注销申请"}
</button>
{state.message && (
<p className={state.error ? "form-error" : "form-success"} role="status">
{state.message}
</p>
)}
</div>
);
}
if (processing) {
return (
<div className="deletion-request-state">
<span>{processing.status === "relay_purged" ? "Relay 数据已逻辑删除" : "正在永久删除 Relay 数据"}</span>
<small>{formatRequestDate(processing.requested_at)}</small>
<p>
{processing.status === "relay_purged"
? "实时 Relay 数据、附件与备份已删除;账户身份和依法保留记录仍按最终退出政策处理。"
: "访问已经暂停,删除期间不能撤回申请。"}
</p>
</div>
);
}
return (
<form className="deletion-request-form" onSubmit={submitRequest}>
<label>
<span></span>
<textarea
maxLength={500}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="例如:暂时不再参加公测"
/>
</label>
<label className="deletion-confirmation">
<input
type="checkbox"
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
required
/>
<span></span>
</label>
<button
className="button button-secondary"
type="submit"
disabled={state.loading || !confirmed}
>
{state.loading ? "正在提交…" : "提交注销申请"}
</button>
{state.message && (
<p className={state.error ? "form-error" : "form-success"} role="status">
{state.message}
</p>
)}
</form>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link";
import { requireCloudViewer } from "../../cloud-auth";
import { DashboardShell, PageHeading, StatusPill } from "../../components/Shells";
import {
getDashboardSnapshot,
getOrCreateAccount,
listAccountDeletionRequests,
} from "@/db/repository";
import { AccountDeletionAction } from "./AccountLifecycleActions";
export const dynamic = "force-dynamic";
export default async function SecurityPage() {
const viewer = await requireCloudViewer("/dashboard/security");
const account = await getOrCreateAccount(viewer);
const [snapshot, deletionRequests] = await Promise.all([
getDashboardSnapshot(account),
listAccountDeletionRequests(account.id),
]);
return (
<DashboardShell viewer={viewer} active="/dashboard/security" serviceStatus={snapshot.serviceStatus}>
<div className="cloud-page">
<PageHeading eyebrow="SECURITY / 安全与设备" title="把身份、设备和内容边界分开。" description="用户、手机、主机和管理员使用不同身份;控制平面不把登录成功当作跨租户授权。" />
<div className="security-grid">
<section className="panel security-card"><span className="card-index">01</span><StatusPill tone="good"></StatusPill><h2>{viewer.email}</h2><p>{viewer.isLocalDemo ? "本地开发演示身份;部署后不会存在。" : "由 Sites 的 ChatGPT 登录识别;正式公共身份提供商仍需上线前确认。"}</p></section>
<section className="panel security-card"><span className="card-index">02</span><StatusPill tone={snapshot.connection.state === "ready" ? "good" : "warn"}></StatusPill><h2>{snapshot.tenant?.slug}</h2><p> Relay {snapshot.connection.state} revision {snapshot.connection.authorizationRevision} phone device grant</p></section>
<section className="panel security-card"><span className="card-index">03</span><StatusPill tone="danger"></StatusPill><h2>sealed attachments</h2><p></p></section>
</div>
<section className="panel full-panel"><div className="panel-heading"><div><span></span><h2></h2></div></div><div className="safety-actions"><article><strong></strong><p></p><Link className="button button-secondary" href="/dashboard/hosts"></Link></article><article><strong></strong><p> Cloud </p><a className="button button-secondary" href="/api/account/export" download> JSON </a></article><article className="account-lifecycle-card"><strong></strong><p></p><AccountDeletionAction requests={deletionRequests} /></article></div></section>
<div className="inline-callout"><div><strong></strong><p> PWA </p></div><Link className="button button-secondary" href="/trust"></Link></div>
</div>
</DashboardShell>
);
}
+106
View File
@@ -0,0 +1,106 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PublicShell, StatusPill } from "../components/Shells";
import {
checksumVerificationCommand,
getDaemonReleaseState,
} from "../daemon-release";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "下载主机端 daemon",
description: "下载并核验用于 NekoNest Cloud 免费公测的 Windows 或 Linux 主机端 daemon。",
};
const unavailableCopy = {
not_configured: {
title: "兼容 Cloud 的公开构建尚未上架。",
body: "下载清单还没有同时配置兼容版本、三个平台包和各自 SHA-256,因此入口保持关闭。闭测参与者可以继续使用单独提供并完成核验的构建。",
},
invalid_config: {
title: "下载清单未通过安全校验。",
body: "版本、HTTPS 下载地址或 SHA-256 有一项不完整。修复前不会把用户送到不确定的二进制文件。",
},
incompatible_version: {
title: "现有发布版本不兼容 Cloud 接入。",
body: "下载入口只接受实现控制面激活交接的 daemon。旧版仍可用于自托管,但不会作为 Cloud 客户端展示。",
},
} as const;
export default async function DownloadPage() {
const release = await getDaemonReleaseState();
return (
<PublicShell>
<div className="public-page download-page">
<header className="download-hero">
<StatusPill tone={release.available ? "good" : "warn"}>
{release.available ? `CLOUD DAEMON v${release.version}` : "DOWNLOAD GATED"}
</StatusPill>
<h1>{release.available ? "下载、核验,再连接。" : unavailableCopy[release.reason].title}</h1>
<p>
{release.available
? "选择主机平台,下载固定版本压缩包,并在解压和运行前核对 SHA-256。主机无需开放入站端口。"
: unavailableCopy[release.reason].body}
</p>
<small>Cloud v{release.minimumVersion} · macOS </small>
</header>
{release.available ? (
<>
<section className="download-section" aria-labelledby="download-platform-title">
<div className="download-heading">
<span className="eyebrow">CHOOSE PLATFORM / </span>
<h2 id="download-platform-title"></h2>
<p>Linux <code>uname -m</code> Windows x64</p>
</div>
<div className="download-grid">
{release.assets.map((asset) => (
<article className="download-card" key={`${asset.platform}-${asset.architecture}`}>
<span className="download-platform">{asset.platform === "windows" ? "WINDOWS" : "LINUX"}</span>
<h3>{asset.label}</h3>
<code className="download-filename">{asset.filename}</code>
<a className="button button-primary" href={asset.downloadUrl} rel="noopener noreferrer"> v{release.version}</a>
<div className="checksum-block">
<strong></strong>
<code>{asset.sha256}</code>
</div>
<details>
<summary></summary>
<pre><code>{checksumVerificationCommand(asset)}</code></pre>
</details>
</article>
))}
</div>
</section>
<section className="download-proof">
<div>
<span className="eyebrow">VERIFY THE RELEASE / </span>
<h2></h2>
<p>SHA-256 线 Windows Authenticode </p>
</div>
<div className="download-proof-actions">
<a className="button button-secondary" href={release.checksumsUrl} rel="noopener noreferrer"> checksums.txt</a>
<a className="button button-secondary" href={release.releasePageUrl} rel="noopener noreferrer"> v{release.version} </a>
<Link className="button button-primary" href="/dashboard/hosts/new"></Link>
</div>
</section>
</>
) : (
<section className="download-gated" aria-labelledby="download-gated-title">
<div>
<span className="eyebrow">FAIL CLOSED / </span>
<h2 id="download-gated-title"></h2>
<p>Release Cloud HTTPS SHA-256 </p>
</div>
<div className="download-gated-actions">
<a className="button button-secondary" href="https://github.com/klarkxy/nekonest/releases" rel="noopener noreferrer"></a>
<Link className="button button-primary" href="/readiness"></Link>
</div>
</section>
)}
</div>
</PublicShell>
);
}
+904
View File
@@ -0,0 +1,904 @@
@import "tailwindcss";
:root {
--ink: #141727;
--ink-2: #20243a;
--paper: #f3f3ec;
--paper-2: #e7e7dc;
--white: #fffefa;
--muted: #686b78;
--line: #d2d1c4;
--lime: #caff69;
--lime-deep: #7cbb22;
--coral: #ff7968;
--blue: #6e8cff;
--cyan: #60d5c9;
--danger: #c33c48;
--shadow: 0 22px 70px rgb(13 16 29 / 14%);
--radius-sm: 10px;
--radius-md: 18px;
--radius-lg: 28px;
--max: 1240px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
text-rendering: optimizeLegibility;
}
a { color: inherit; text-decoration: none; }
button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 3px solid var(--blue);
outline-offset: 3px;
}
.site-shell { min-height: 100vh; overflow: clip; }
.public-header {
position: sticky;
top: 0;
z-index: 50;
color: white;
background: rgb(18 21 34 / 92%);
border-bottom: 1px solid rgb(255 255 255 / 10%);
backdrop-filter: blur(16px);
}
.public-header-inner {
width: min(var(--max), calc(100% - 40px));
min-height: 72px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 34px;
}
.brand-link { display: inline-flex; flex: none; }
.brand-lockup { display: inline-flex; align-items: center; gap: 11px; }
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
color: var(--ink);
background: var(--lime);
border-radius: 12px 12px 14px 14px;
transform: rotate(-2deg);
}
.brand-mark svg { width: 32px; height: 32px; fill: currentColor; }
.brand-mark svg path:last-child { fill: none; stroke: var(--lime); stroke-width: 2.2; stroke-linecap: round; }
.brand-type { display: flex; align-items: baseline; gap: 6px; letter-spacing: -.02em; }
.brand-type strong { font-size: 18px; }
.brand-type span { font-size: 13px; color: #aeb1bf; text-transform: uppercase; letter-spacing: .12em; }
.public-nav { display: flex; align-items: center; gap: 26px; margin-left: auto; }
.public-nav a { color: #c6c8d0; font-size: 14px; }
.public-nav a:hover { color: white; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.beta-chip { color: var(--lime); font-size: 12px; border-left: 1px solid #44485d; padding-left: 16px; }
.button {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
border: 1px solid transparent;
border-radius: 999px;
padding: 0 22px;
font-weight: 720;
cursor: pointer;
transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease;
}
.button:hover { transform: translateY(-2px); }
.button-small { min-height: 40px; padding: 0 18px; font-size: 14px; }
.button-large { min-height: 54px; padding: 0 27px; }
.button-primary { color: var(--ink); background: var(--lime); box-shadow: 0 10px 30px rgb(202 255 105 / 16%); }
.button-primary:hover { background: #d8ff8f; box-shadow: 0 14px 36px rgb(202 255 105 / 25%); }
.button-ghost { color: white; border-color: rgb(255 255 255 / 26%); background: transparent; }
.button-ghost:hover { border-color: white; }
.button-light { color: var(--ink); background: var(--white); }
.button-secondary { color: var(--ink); border-color: var(--line); background: var(--white); }
.button-danger { color: white; background: var(--danger); }
.button[disabled] { opacity: .48; cursor: not-allowed; transform: none; box-shadow: none; }
.status-pill {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
min-height: 26px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 760;
line-height: 1;
white-space: nowrap;
}
.status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-good { color: #2e6317; background: #dff9b8; }
.status-warn { color: #7b4a00; background: #ffe3a6; }
.status-danger { color: #8d2834; background: #ffd1d4; }
.status-info { color: #304fab; background: #dbe3ff; }
.status-neutral { color: #555867; background: #e8e8e1; }
.hero-section {
position: relative;
color: white;
background:
radial-gradient(circle at 82% 30%, rgb(110 140 255 / 16%), transparent 32%),
radial-gradient(circle at 4% 90%, rgb(96 213 201 / 10%), transparent 35%),
var(--ink);
padding: 86px 0 96px;
}
.hero-section::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: .2;
background-image: linear-gradient(rgb(255 255 255 / 5%) 1px, transparent 1px), linear-gradient(90deg, rgb(255 255 255 / 5%) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: linear-gradient(to bottom, black, transparent 82%);
}
.hero-grid {
position: relative;
z-index: 1;
width: min(var(--max), calc(100% - 40px));
margin: 0 auto;
display: grid;
grid-template-columns: .86fr 1.14fr;
align-items: center;
gap: 56px;
}
.hero-kicker { display: flex; align-items: center; gap: 12px; color: #acafbd; font-size: 13px; margin-bottom: 28px; }
.hero-copy h1 {
margin: 0;
max-width: 700px;
font-size: clamp(54px, 6.2vw, 92px);
font-weight: 780;
line-height: .98;
letter-spacing: -.065em;
}
.hero-copy h1 em { color: var(--lime); font-style: normal; font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; font-size: .8em; letter-spacing: -.055em; }
.hero-lead { max-width: 620px; margin: 30px 0 0; color: #c1c4d0; font-size: 18px; line-height: 1.75; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 36px; }
.hero-facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 44px; color: #999dac; font-size: 12px; }
.hero-facts strong { color: white; font-size: 18px; margin-right: 5px; }
.hero-console { min-width: 0; border: 1px solid #3c4157; border-radius: 22px; background: #0c0f19; box-shadow: 0 38px 100px rgb(0 0 0 / 42%); overflow: hidden; transform: perspective(1600px) rotateY(-2deg) rotateX(1deg); }
.console-topbar { min-height: 54px; display: flex; align-items: center; gap: 14px; padding: 0 18px; border-bottom: 1px solid #2a2e40; color: #b9bdca; font-size: 12px; }
.console-topbar > span:nth-child(2) { flex: 1; }
.console-dots { display: flex; gap: 6px; }
.console-dots i { width: 8px; height: 8px; border-radius: 50%; background: #454a60; }
.console-dots i:first-child { background: var(--coral); }
.console-dots i:nth-child(2) { background: #f6c75d; }
.console-dots i:nth-child(3) { background: var(--cyan); }
.console-layout { min-height: 475px; display: grid; grid-template-columns: 205px 1fr; }
.console-tree { padding: 20px 14px; border-right: 1px solid #292d3f; background: #111522; color: #a8adbd; font-size: 11px; }
.console-tree > strong { display: block; padding: 8px 8px 16px; color: white; font-family: ui-monospace, Consolas, monospace; font-size: 10px; overflow-wrap: anywhere; }
.tree-label { display: block; padding: 0 8px; color: #6f7487; text-transform: uppercase; letter-spacing: .12em; }
.tree-agent, .tree-thread { min-height: 32px; display: flex; align-items: center; gap: 7px; padding: 7px 8px; border-radius: 7px; margin-bottom: 3px; }
.tree-agent small { margin-left: auto; color: #6f7487; font-size: 9px; }
.tree-agent.active { color: white; background: #20263a; }
.tree-thread { padding-left: 28px; color: #7f8497; }
.tree-thread.active { color: var(--lime); background: rgb(202 255 105 / 8%); }
.console-chat { min-width: 0; display: flex; flex-direction: column; padding: 18px; }
.chat-meta { display: flex; align-items: center; justify-content: space-between; color: #757a8d; font-size: 10px; padding-bottom: 20px; }
.message { display: flex; gap: 10px; margin-bottom: 14px; }
.message p { width: fit-content; max-width: 88%; margin: 0; padding: 13px 15px; border-radius: 8px 16px 16px 16px; color: #d9dce5; background: #1a1f30; font-size: 12px; line-height: 1.7; }
.message.user { justify-content: flex-end; }
.message.user p { color: var(--ink); background: var(--lime); border-radius: 16px 8px 16px 16px; }
.message-avatar { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border-radius: 8px; background: #6e8cff; color: white; font-weight: 800; font-size: 11px; }
.delivery-row { margin: auto 0 12px; padding: 10px 12px; display: flex; align-items: center; gap: 9px; border: 1px solid #282d3f; border-radius: 8px; color: #8f94a4; font-size: 9px; }
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 0 4px rgb(96 213 201 / 10%); }
.composer-mock { min-height: 52px; display: flex; align-items: center; gap: 12px; border: 1px solid #363b50; border-radius: 12px; padding: 7px 7px 7px 14px; color: #6f7486; font-size: 11px; }
.composer-mock span { flex: 1; }
.composer-mock button { width: 36px; height: 36px; border: 0; border-radius: 9px; color: var(--ink); background: var(--lime); }
.console-caption { padding: 10px 16px; border-top: 1px solid #24283a; color: #696e80; background: #0a0d16; font-size: 9px; }
.proof-strip { display: flex; justify-content: center; flex-wrap: wrap; gap: 0; color: #404454; background: var(--lime); border-bottom: 1px solid #acd94f; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
.proof-strip span { padding: 18px 28px; border-left: 1px solid rgb(20 23 39 / 16%); }
.proof-strip span:last-child { border-right: 1px solid rgb(20 23 39 / 16%); }
.section { padding: 104px 0; }
.section-heading, .relay-diagram, .steps-grid, .feature-ledger, .price-callout, .trust-grid { width: min(var(--max), calc(100% - 40px)); margin-left: auto; margin-right: auto; }
.split-heading { display: grid; grid-template-columns: 1fr .72fr; gap: 70px; align-items: end; margin-bottom: 64px; }
.section-heading h2, .price-copy h2, .trust-title h2, .readiness-banner h2 { margin: 8px 0 0; font-size: clamp(40px, 5vw, 68px); line-height: 1.02; letter-spacing: -.055em; }
.section-heading p, .price-copy > p, .trust-title > p { margin: 0; color: var(--muted); font-size: 17px; line-height: 1.75; }
.eyebrow { display: block; color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.light-section { background: var(--paper); }
.relay-diagram { display: grid; grid-template-columns: 1fr 165px 1.15fr 165px 1fr; align-items: center; }
.relay-node { min-height: 210px; display: flex; flex-direction: column; justify-content: flex-end; position: relative; padding: 26px; border: 1px solid var(--line); background: var(--white); }
.relay-node:first-child { border-radius: 28px 8px 8px 28px; }
.relay-node:last-child { border-radius: 8px 28px 28px 8px; }
.relay-node strong { font-size: 23px; }
.relay-node small { margin-top: 8px; color: var(--muted); line-height: 1.5; }
.relay-node b { position: absolute; top: 24px; right: 24px; color: var(--danger); font-size: 11px; border: 1px solid #edb4b8; padding: 5px 8px; border-radius: 999px; }
.node-number { position: absolute; top: 22px; left: 24px; color: #b2b2a8; font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
.cloud-node { color: white; background: var(--ink); border-color: var(--ink); transform: scale(1.035); z-index: 1; border-radius: 16px; box-shadow: var(--shadow); }
.cloud-node small { color: #aeb2c1; }
.relay-arrow { display: flex; flex-direction: column; align-items: center; gap: 9px; color: #737682; font-size: 10px; text-align: center; }
.relay-arrow i { width: 100%; height: 1px; position: relative; background: #a9aa9e; }
.relay-arrow i::after { content: ""; position: absolute; right: 0; top: -4px; border-width: 4px 0 4px 7px; border-style: solid; border-color: transparent transparent transparent #a9aa9e; }
.steps-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin-top: 68px; background: var(--line); border: 1px solid var(--line); }
.steps-grid article { min-height: 240px; padding: 32px; background: var(--paper); }
.steps-grid article > span { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: var(--ink); background: var(--lime); font-weight: 800; }
.steps-grid h3 { margin: 34px 0 10px; font-size: 22px; }
.steps-grid p { margin: 0; color: var(--muted); line-height: 1.7; }
.dark-section { color: white; background: var(--ink); }
.inverse .eyebrow { color: var(--lime); }
.inverse p { color: #aeb2c1; }
.feature-ledger { border-top: 1px solid #34384b; }
.feature-ledger article { display: grid; grid-template-columns: 100px 1fr; gap: 24px; align-items: start; padding: 36px 0; border-bottom: 1px solid #34384b; }
.ledger-index { color: var(--lime); font-family: ui-monospace, Consolas, monospace; font-size: 15px; }
.feature-ledger article div { display: grid; grid-template-columns: .55fr 1fr; gap: 50px; }
.feature-ledger h3 { margin: 0; font-size: 25px; }
.feature-ledger p { max-width: 720px; margin: 0; color: #aeb2c1; line-height: 1.75; }
.price-section { background: var(--paper-2); }
.price-callout { display: grid; grid-template-columns: 1fr .85fr; border: 1px solid #c6c6b9; border-radius: var(--radius-lg); overflow: hidden; background: var(--white); box-shadow: var(--shadow); }
.price-copy { padding: 58px; }
.price-copy h2 { font-size: clamp(40px, 4vw, 60px); }
.price-copy > p { margin-top: 22px; }
.check-list { list-style: none; padding: 0; margin: 34px 0 0; }
.check-list li { padding: 12px 0 12px 28px; position: relative; border-bottom: 1px solid #e6e5dc; }
.check-list li::before { content: "✓"; position: absolute; left: 0; color: var(--lime-deep); font-weight: 900; }
.price-board { display: flex; flex-direction: column; justify-content: center; padding: 42px; color: white; background: var(--ink); }
.beta-price-row, .catalog-price-row { display: grid; grid-template-columns: 1fr auto; align-items: baseline; padding: 20px 0; border-bottom: 1px solid #35394b; }
.price-board strong { font-size: 34px; letter-spacing: -.04em; }
.price-board small { grid-column: 2; color: #9498a8; }
.beta-price-row strong { color: var(--lime); font-size: 62px; }
.price-board > a { margin-top: 30px; color: var(--lime); font-weight: 750; }
.trust-section { background: var(--white); }
.trust-grid { display: grid; grid-template-columns: .78fr 1.22fr; gap: 80px; align-items: start; }
.trust-title h2 { font-size: clamp(40px, 4.4vw, 64px); }
.trust-title > p { margin-top: 24px; }
.text-link { display: inline-flex; margin-top: 24px; font-weight: 750; border-bottom: 2px solid var(--lime-deep); }
.visibility-table { border-top: 2px solid var(--ink); }
.visibility-table > div { min-height: 64px; display: grid; grid-template-columns: 1.3fr .7fr 1fr; align-items: center; gap: 20px; border-bottom: 1px solid var(--line); }
.visibility-table span { font-weight: 700; }
.visibility-table strong { color: var(--lime-deep); }
.visibility-table em { color: var(--muted); font-style: normal; }
.visibility-head { min-height: 44px !important; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; }
.visibility-table > small { display: block; margin-top: 16px; color: var(--muted); }
.readiness-banner { width: 100%; display: flex; justify-content: space-between; align-items: flex-end; gap: 48px; padding: 70px max(40px, calc((100vw - var(--max)) / 2)); color: white; background: var(--coral); }
.readiness-banner h2 { max-width: 750px; margin-top: 20px; font-size: clamp(38px, 4.2vw, 60px); }
.readiness-banner p { max-width: 760px; margin: 18px 0 0; line-height: 1.7; color: #fff7f5; }
.public-footer { padding: 70px max(20px, calc((100vw - var(--max)) / 2)) 28px; color: #babdca; background: #0b0d16; }
.footer-grid { display: grid; grid-template-columns: 1.5fr .7fr 1fr 1fr; gap: 50px; }
.footer-grid > div { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
.footer-grid p { margin: 0; font-size: 13px; line-height: 1.7; }
.footer-grid strong { color: white; font-size: 13px; }
.footer-grid a { font-size: 13px; }
.footer-grid a:hover { color: white; }
.footer-bottom { display: flex; justify-content: space-between; gap: 20px; margin-top: 60px; padding-top: 22px; border-top: 1px solid #252838; font-size: 11px; }
.subpage-hero { color: white; background: var(--ink); border-bottom: 1px solid #35394b; }
.subpage-hero-inner { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; padding: 100px 0 90px; }
.subpage-hero h1 { max-width: 950px; margin: 18px 0 0; font-size: clamp(58px, 8vw, 108px); line-height: .95; letter-spacing: -.07em; }
.subpage-hero p { max-width: 760px; margin: 30px 0 0; color: #b7bac7; font-size: 18px; line-height: 1.8; }
.pricing-hero { background: var(--ink); }
.pricing-catalog-section { background: var(--paper-2); }
.pricing-catalog { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); align-items: stretch; gap: 16px; }
.pricing-card { min-width: 0; display: flex; flex-direction: column; padding: 36px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--white); }
.pricing-card-top { display: flex; justify-content: space-between; align-items: center; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
.pricing-card h2 { margin: 38px 0 0; font-size: 62px; letter-spacing: -.06em; }
.pricing-card h2 small { display: block; margin-top: 6px; color: var(--muted); font-size: 14px; font-weight: 600; letter-spacing: 0; }
.pricing-card > p { min-height: 78px; margin: 18px 0 0; color: var(--muted); line-height: 1.7; }
.pricing-card .button { width: 100%; margin-top: auto; }
.beta-card { border-color: #a7d84b; box-shadow: inset 0 5px 0 var(--lime); }
.featured-card { color: white; background: var(--ink); border-color: var(--ink); box-shadow: var(--shadow); }
.featured-card .plain-list li { border-color: #34384b; }
.featured-card .pricing-card-top, .featured-card > p { color: #aeb2c1; }
.plain-list { list-style: none; padding: 0; margin: 28px 0 34px; }
.plain-list li { position: relative; padding: 11px 0 11px 25px; border-bottom: 1px solid #e3e2d9; line-height: 1.5; }
.plain-list li::before { content: "—"; position: absolute; left: 0; color: var(--lime-deep); font-weight: 900; }
.policy-section { background: var(--white); }
.policy-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: .55fr 1fr; gap: 90px; }
.policy-grid h2 { margin: 12px 0 0; font-size: 58px; letter-spacing: -.055em; }
.rules-list { margin: 0; border-top: 2px solid var(--ink); }
.rules-list > div { display: grid; grid-template-columns: 180px 1fr; gap: 30px; padding: 24px 0; border-bottom: 1px solid var(--line); }
.rules-list dt { font-weight: 800; }
.rules-list dd { margin: 0; color: var(--muted); line-height: 1.7; }
.comparison-section { background: var(--paper); }
.comparison-table { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; border-top: 2px solid var(--ink); }
.comparison-table > div { min-height: 72px; display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: 30px; align-items: center; border-bottom: 1px solid var(--line); }
.comparison-table b { font-weight: 650; }
.comparison-head { min-height: 48px !important; color: var(--muted); font-size: 12px; }
.comparison-head strong { color: var(--ink); }
.fine-print { width: min(var(--max), calc(100% - 40px)); margin: 16px auto 0; color: var(--muted); font-size: 12px; }
.trust-hero { background: #121522; }
.boundary-section { background: var(--paper); }
.boundary-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.boundary-card { min-height: 420px; display: flex; flex-direction: column; padding: 34px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--white); }
.boundary-card h2 { margin: auto 0 0; font-size: 32px; letter-spacing: -.04em; }
.boundary-card .plain-list { margin-bottom: 0; }
.boundary-icon { width: fit-content; padding: 7px 10px; border: 1px solid currentColor; border-radius: 999px; font-family: ui-monospace, Consolas, monospace; font-size: 11px; font-weight: 800; letter-spacing: .1em; }
.cloud-boundary-card { color: white; background: var(--ink); border-color: var(--ink); }
.cloud-boundary-card .plain-list li { border-color: #363a4c; }
.evidence-card { background: var(--lime); border-color: #a6d84c; }
.evidence-card p { color: #3e462d; line-height: 1.7; }
.mutable-pwa-section { color: white; background: #2c365e; }
.mutable-pwa-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: .82fr 1.18fr; gap: 90px; }
.mutable-pwa-grid .eyebrow { color: var(--lime); }
.mutable-pwa-grid h2 { margin: 14px 0 0; font-size: clamp(46px, 5vw, 72px); line-height: 1; letter-spacing: -.06em; }
.mutable-pwa-grid p { margin: 0; color: #cbd1e8; font-size: 18px; line-height: 1.85; }
.evidence-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 32px; }
.evidence-checks span { padding: 14px; border: 1px solid rgb(255 255 255 / 18%); border-radius: 9px; color: #e4e8f6; font-size: 13px; }
.claim-section { background: var(--white); }
.claim-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); border: 1px solid var(--line); }
.claim-grid > div { min-height: 360px; display: flex; flex-direction: column; justify-content: space-between; padding: 42px; background: var(--white); }
.claim-grid span { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
.claim-grid h3 { margin: 0; font-size: 31px; line-height: 1.3; letter-spacing: -.035em; }
.claim-do { box-shadow: inset 0 7px 0 var(--lime); }
.claim-dont { box-shadow: inset 0 7px 0 var(--coral); }
.trust-cta { padding: 70px max(20px, calc((100vw - var(--max)) / 2)); display: flex; align-items: center; justify-content: space-between; gap: 40px; color: white; background: var(--ink); }
.trust-cta h2 { margin: 0; font-size: 48px; letter-spacing: -.05em; }
.trust-cta p { margin: 10px 0 0; color: #aeb2c1; }
.trust-cta-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.privacy-hero { background: radial-gradient(circle at 78% 20%, rgb(202 255 105 / 24%), transparent 30%), var(--ink); }
.privacy-hero h1 { color: white; }
.privacy-hero p { color: #c7cad5; }
.data-map-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.data-map-card { min-height: 520px; display: flex; flex-direction: column; padding: 30px; border: 1px solid var(--line); border-radius: 14px; background: white; }
.data-map-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.data-map-card-heading > span { color: var(--muted); font-family: ui-monospace, Consolas, monospace; font-size: 10px; font-weight: 800; letter-spacing: .08em; }
.data-map-card h3 { margin: 34px 0 10px; font-size: 30px; letter-spacing: -.04em; }
.data-map-card > p { margin: 0; color: var(--muted); line-height: 1.7; }
.data-map-dormant { background: #f3f2eb; }
.data-example-list { display: flex; flex-wrap: wrap; gap: 7px; margin: 24px 0; padding: 0; list-style: none; }
.data-example-list li { padding: 7px 9px; border: 1px solid #d7d7cc; border-radius: 999px; color: #454852; background: #f8f8f3; font-size: 10px; }
.data-map-details { margin: auto 0 0; border-top: 1px solid var(--line); }
.data-map-details > div { display: grid; grid-template-columns: 74px 1fr; gap: 18px; padding: 16px 0; border-bottom: 1px solid #e8e7de; }
.data-map-details dt { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
.data-map-details dd { margin: 0; font-size: 12px; line-height: 1.65; }
.not-collected-section { background: var(--ink); color: white; }
.not-collected-section .eyebrow { color: var(--lime); }
.not-collected-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid #454959; }
.not-collected-list li { position: relative; padding: 18px 0 18px 34px; border-bottom: 1px solid #343847; color: #eef0f6; }
.not-collected-list li::before { content: "×"; position: absolute; left: 3px; top: 17px; color: var(--coral); font-size: 20px; font-weight: 800; }
.not-collected-list strong, .not-collected-list span { display: block; }
.not-collected-list span { margin-top: 5px; color: #aeb2c1; font-size: 12px; line-height: 1.6; }
.privacy-boundary-note { margin: 24px 0 0; color: #adb1bf; font-size: 12px; line-height: 1.7; }
.privacy-action-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.privacy-action-grid article { min-height: 280px; display: flex; flex-direction: column; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: #f8f8f2; }
.privacy-action-grid article > span { color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; font-weight: 800; }
.privacy-action-grid h3 { margin: 46px 0 10px; font-size: 24px; letter-spacing: -.035em; }
.privacy-action-grid p { margin: 0; color: var(--muted); line-height: 1.7; }
.privacy-action-grid a { margin-top: auto; padding-top: 22px; font-weight: 800; }
.privacy-open-items { width: min(var(--max), calc(100% - 40px)); margin: 0 auto 100px; display: grid; grid-template-columns: .9fr 1.1fr; gap: 80px; padding: 40px; border-left: 6px solid var(--coral); background: #fff0eb; }
.privacy-open-items h2 { margin: 15px 0 0; font-size: 36px; letter-spacing: -.045em; }
.privacy-open-items > p { margin: 0; color: #65463f; line-height: 1.8; }
.readiness-hero { background: var(--coral); }
.readiness-hero .status-pill { margin-bottom: 18px; }
.readiness-hero p { color: #fff5f2; }
.gates-section { background: var(--paper); }
.gates-heading { width: min(var(--max), calc(100% - 40px)); margin: 0 auto 58px; display: grid; grid-template-columns: 1fr .7fr; gap: 70px; align-items: end; }
.gates-heading h2 { margin: 12px 0 0; font-size: 64px; letter-spacing: -.06em; }
.gates-heading p { margin: 0; color: var(--muted); line-height: 1.75; }
.gates-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.gate-card { min-height: 250px; padding: 28px; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--white); }
.gate-card-top { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.gate-card h3 { margin: 34px 0 28px; font-size: 23px; line-height: 1.3; letter-spacing: -.025em; }
.gate-card dl { margin: auto 0 0; }
.gate-card dl div { display: grid; grid-template-columns: 70px 1fr; padding-top: 8px; border-top: 1px solid #e5e4db; font-size: 12px; }
.gate-card dt { color: var(--muted); }
.gate-card dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
.gate-card dd a { color: #3b5cc0; text-decoration: underline; }
.legal-section { background: var(--white); }
.legal-ledger { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; border-top: 2px solid var(--ink); }
.legal-ledger article { min-height: 310px; padding: 34px; border-bottom: 1px solid var(--line); }
.legal-ledger article:nth-child(odd) { border-right: 1px solid var(--line); }
.legal-ledger article > span { color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; font-weight: 800; }
.legal-ledger h3 { margin: 35px 0 12px; font-size: 26px; }
.legal-ledger p { color: var(--muted); line-height: 1.7; }
.legal-ledger a { display: inline-block; margin-top: 16px; font-weight: 750; border-bottom: 1px solid currentColor; }
.cloud-shell { min-height: 100vh; display: grid; grid-template-columns: 250px 1fr; background: #ecece4; }
.cloud-sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 24px 18px; color: #c6c9d4; background: #111421; border-right: 1px solid #2a2e3e; }
.cloud-brand { padding: 0 8px; color: white; }
.prototype-notice { margin: 24px 8px 18px; display: flex; align-items: center; gap: 9px; color: #8d92a3; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.notice-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); box-shadow: 0 0 0 4px rgb(255 121 104 / 10%); }
.cloud-nav { display: flex; flex-direction: column; gap: 4px; }
.cloud-nav a { min-height: 46px; display: flex; align-items: center; gap: 12px; padding: 0 13px; border-radius: 9px; color: #9499aa; font-size: 14px; font-weight: 650; }
.cloud-nav a > span { width: 22px; color: #686e82; font-family: ui-monospace, Consolas, monospace; text-align: center; }
.cloud-nav a:hover { color: white; background: #1a1e2d; }
.cloud-nav a.active { color: var(--ink); background: var(--lime); }
.cloud-nav a.active > span { color: var(--ink); }
.sidebar-account { margin-top: auto; min-width: 0; display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 14px 8px 0; border-top: 1px solid #2a2e3e; }
.account-avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 11px; color: var(--ink); background: var(--cyan); font-weight: 850; }
.sidebar-account > span:nth-child(2) { min-width: 0; }
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-account strong { color: white; font-size: 12px; }
.sidebar-account small { margin-top: 3px; color: #767b8e; font-size: 10px; }
.sidebar-account > a { color: #757a8d; }
.cloud-main { min-width: 0; }
.connectivity-banner { min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 10px 24px; color: #604600; background: #fff0b8; border-bottom: 1px solid #d7b94f; font-size: 12px; text-align: center; }
.connectivity-banner strong { flex: none; text-transform: uppercase; letter-spacing: .05em; }
.connectivity-banner span { line-height: 1.5; }
.demo-banner { min-height: 38px; display: flex; align-items: center; justify-content: center; padding: 8px 20px; color: #3f4e18; background: #e0f7b5; border-bottom: 1px solid #c1de88; font-size: 11px; text-align: center; }
.service-incident-banner { min-height: 46px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 10px 24px; border-bottom: 1px solid; font-size: 12px; }
.service-incident-banner span { display: flex; align-items: center; gap: 12px; min-width: 0; }
.service-incident-banner strong { flex: none; text-transform: uppercase; letter-spacing: .06em; }
.service-incident-banner a { flex: none; color: inherit; font-weight: 750; text-decoration: none; }
.service-incident-banner.incident-maintenance { color: #3e4c77; background: #eef1ff; border-color: #cbd2f0; }
.service-incident-banner.incident-degraded { color: #684b0c; background: #fff2ca; border-color: #e6cd78; }
.service-incident-banner.incident-outage { color: #81291f; background: #ffe5df; border-color: #e6aaa0; }
.cloud-page { width: min(1260px, calc(100% - 64px)); margin: 0 auto; padding: 50px 0 90px; }
.narrow-cloud-page { max-width: 1040px; }
.page-heading { min-height: 150px; display: flex; justify-content: space-between; align-items: flex-end; gap: 34px; margin-bottom: 38px; }
.page-heading h1 { margin: 10px 0 0; font-size: clamp(42px, 5vw, 70px); line-height: 1; letter-spacing: -.06em; }
.page-heading p { max-width: 720px; margin: 15px 0 0; color: var(--muted); line-height: 1.7; }
.page-actions { flex: none; padding-bottom: 4px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.metric-card { min-height: 180px; display: flex; flex-direction: column; padding: 24px; border: 1px solid #d2d2c6; border-radius: 14px; background: var(--white); }
.metric-card > span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.metric-card strong { margin-top: auto; font-size: 32px; letter-spacing: -.04em; }
.metric-card small { margin-top: 7px; color: var(--muted); line-height: 1.45; }
.accent-card { background: var(--lime); border-color: #a8d853; }
.accent-card > span, .accent-card small { color: #425223; }
.dashboard-columns { display: grid; grid-template-columns: 1.2fr .8fr; gap: 12px; margin-top: 12px; }
.panel { min-width: 0; padding: 26px; border: 1px solid #d2d2c6; border-radius: 14px; background: var(--white); }
.full-panel { margin-top: 12px; }
.panel-heading { min-height: 58px; display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid #e3e2d9; }
.panel-heading span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }
.panel-heading h2 { margin: 5px 0 0; font-size: 24px; letter-spacing: -.035em; }
.panel-heading > a { color: #4059ac; font-size: 12px; font-weight: 750; }
.host-list { display: flex; flex-direction: column; }
.host-row { min-width: 0; min-height: 82px; display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: 14px; align-items: center; border-bottom: 1px solid #e8e7df; }
.host-row:last-child { border-bottom: 0; }
.host-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; font-family: ui-monospace, Consolas, monospace; font-weight: 850; }
.host-windows { color: #27419b; background: #d9e1ff; }
.host-linux { color: #2f6b2a; background: #dbf2c8; }
.host-row > div { min-width: 0; }
.host-row strong, .host-row small { display: block; }
.host-row > div:nth-child(2) strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.host-state { text-align: right; }
.host-state .status-pill { margin-left: auto; }
.attention-panel { background: #f8f7f0; }
.attention-list article { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: start; padding: 18px 0; border-bottom: 1px solid #e3e2d9; }
.attention-list article:last-child { border-bottom: 0; }
.attention-list strong { font-size: 14px; }
.attention-list p { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.empty-state { min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center; }
.compact-empty { min-height: 330px; }
.empty-symbol { width: 52px; height: 52px; display: grid; place-items: center; margin-bottom: 18px; border: 1px solid var(--line); border-radius: 16px; color: var(--muted); background: var(--paper); font-size: 25px; }
.empty-state h3 { margin: 0; font-size: 20px; }
.empty-state p { max-width: 500px; margin: 10px 0 22px; color: var(--muted); line-height: 1.6; }
.empty-inline { padding: 34px 0 12px; color: var(--muted); text-align: center; }
.path-panel { display: grid; grid-template-columns: .72fr 1.28fr; gap: 70px; margin-top: 12px; padding: 42px; color: white; background: var(--ink); border-radius: 14px; }
.path-panel h2 { margin: 14px 0 0; font-size: 38px; line-height: 1.1; letter-spacing: -.05em; }
.path-panel ol { list-style: none; padding: 0; margin: 0; }
.path-panel li { display: grid; grid-template-columns: 40px 1fr; gap: 12px; padding: 18px 0; border-bottom: 1px solid #34384a; opacity: .55; }
.path-panel li.current, .path-panel li.done { opacity: 1; }
.path-panel li > span { color: var(--lime); font-family: ui-monospace, Consolas, monospace; }
.path-panel li p { margin: 5px 0 0; color: #979bac; font-size: 12px; }
.entitlement-bar { display: grid; grid-template-columns: 1.4fr repeat(3, .7fr); margin-bottom: 12px; color: white; background: var(--ink); border-radius: 14px; }
.entitlement-bar > div { min-height: 96px; display: flex; flex-direction: column; justify-content: center; padding: 18px 24px; border-right: 1px solid #35394c; }
.entitlement-bar > div:last-child { border-right: 0; }
.entitlement-bar span { color: #888d9f; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.entitlement-bar strong { margin-top: 7px; font-size: 20px; }
.detailed-host { grid-template-columns: 44px minmax(150px, 1fr) .55fr .8fr auto auto; }
.row-label { display: block; color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }
.detailed-host > div strong { margin-top: 4px; font-size: 12px; }
.host-contact-note { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
.pairing-list article, .order-list article { min-height: 78px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 22px; align-items: center; border-bottom: 1px solid #e5e4dc; }
.pairing-list article:last-child, .order-list article:last-child { border-bottom: 0; }
.pairing-list strong, .pairing-list small, .order-list strong, .order-list small { display: block; }
.recovery-options { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin-top: 22px; border: 1px solid var(--line); background: var(--line); }
.recovery-options article { min-height: 220px; padding: 24px; background: var(--white); }
.recovery-options h3 { margin: 22px 0 8px; font-size: 20px; }
.recovery-options p { margin: 0; color: var(--muted); line-height: 1.65; }
.pairing-list small, .order-list small, .pairing-list article > span, .order-list article > span { margin-top: 4px; color: var(--muted); font-size: 10px; }
.pairing-cancel-action { min-width: 0; }
.pairing-cancel-action .button { white-space: nowrap; }
.pairing-cancel-action .form-error { display: block; max-width: 260px; margin-top: 8px; white-space: normal; }
.form-layout { display: grid; grid-template-columns: 1fr .72fr; gap: 12px; align-items: start; }
.form-panel { padding: 36px; }
.cloud-form { display: flex; flex-direction: column; gap: 26px; }
.cloud-form label > span, .cloud-form legend, .quote-controls label > span { display: block; margin-bottom: 9px; font-size: 12px; font-weight: 780; }
.cloud-form input, .cloud-form select, .cloud-form textarea, .quote-controls input, .quote-controls select { width: 100%; min-height: 48px; padding: 0 14px; border: 1px solid #c9c9be; border-radius: 9px; color: var(--ink); background: white; }
.cloud-form textarea { min-height: 180px; padding-block: 13px; resize: vertical; line-height: 1.6; }
.cloud-form label > small { display: block; margin-top: 8px; color: var(--muted); line-height: 1.5; }
.cloud-form fieldset { padding: 0; border: 0; }
.choice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.choice-grid label { min-height: 120px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; border: 1px solid #cecec3; border-radius: 10px; cursor: pointer; }
.choice-grid label.selected { border-color: #91bc3f; background: #f0ffda; box-shadow: inset 0 0 0 1px #91bc3f; }
.choice-grid input { position: absolute; opacity: 0; pointer-events: none; }
.choice-grid strong { font-size: 18px; }
.choice-grid small { margin-top: 4px; color: var(--muted); }
.form-error { margin: 0; padding: 12px 14px; border-radius: 8px; color: #8d2834; background: #ffe0e2; }
.form-aside { padding: 34px; color: white; background: var(--ink); border-radius: 14px; }
.form-aside ol { list-style: none; padding: 0; margin: 30px 0 0; }
.form-aside li { padding: 20px 0; border-bottom: 1px solid #34384a; }
.form-aside li::marker { color: var(--lime); }
.form-aside p { margin: 7px 0 0; color: #9da1b1; font-size: 12px; line-height: 1.6; }
.pairing-result { display: flex; flex-direction: column; align-items: flex-start; }
.pairing-result h2 { margin: 12px 0; font-size: 34px; }
.pairing-result > p { color: var(--muted); line-height: 1.6; }
.pairing-progress { width: 100%; display: flex; flex-direction: column; gap: 5px; margin: 10px 0 4px; padding: 13px 15px; border-left: 4px solid var(--lime-deep); background: #f0f7df; }
.pairing-progress span, .pairing-complete > small { color: var(--muted); font-size: 12px; line-height: 1.55; }
.pairing-complete > small { margin: -10px 0 22px; overflow-wrap: anywhere; }
.pairing-result-actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px; }
.registration-steps { width: 100%; margin: 18px 0 4px; padding: 0; list-style: none; counter-reset: registration-step; }
.registration-steps > li { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 24px 0; border-top: 1px solid var(--line); counter-increment: registration-step; }
.registration-steps > li > strong::before { content: counter(registration-step) ". "; color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; }
.registration-steps small { color: var(--muted); line-height: 1.55; }
.pairing-copy-field { display: flex; width: 100%; flex-direction: column; gap: 7px; }
.pairing-copy-field > span { color: var(--muted); font-size: 12px; font-weight: 750; }
.pairing-copy-field input, .pairing-copy-field textarea { width: 100%; padding: 14px; border: 1px solid #b7b9ae; border-radius: 9px; color: var(--ink); background: #f7f7f0; font: 12px/1.6 ui-monospace, "SFMono-Regular", Consolas, monospace; resize: vertical; }
.pairing-copy-field input { font-size: 14px; font-weight: 750; overflow-wrap: anywhere; }
.pairing-copy-field.compact { max-width: 420px; }
.pairing-copy-field input:focus, .pairing-copy-field textarea:focus { outline: 3px solid color-mix(in srgb, var(--lime) 44%, transparent); outline-offset: 2px; }
.pairing-download-link { margin-top: 0; }
.prototype-callout { display: flex; flex-direction: column; gap: 6px; margin: 18px 0 24px; padding: 16px; border-left: 4px solid var(--coral); background: #fff0ed; }
.prototype-callout span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.billing-status-panel { display: grid; grid-template-columns: 1fr .8fr; gap: 60px; align-items: center; padding: 36px; color: white; background: var(--ink); border-radius: 14px; }
.billing-status-panel h2 { margin: 15px 0 6px; font-size: 42px; letter-spacing: -.05em; }
.billing-status-panel p { margin: 0; color: #aeb2c1; }
.billing-status-panel dl { margin: 0; }
.billing-status-panel dl div { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #363a4b; }
.billing-status-panel dt { color: #898e9f; }
.billing-status-panel dd { margin: 0; font-weight: 750; }
.billing-columns { grid-template-columns: .85fr 1.15fr; }
.mini-price-list { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin-top: 20px; background: var(--line); border: 1px solid var(--line); }
.mini-price-list article { padding: 20px; background: var(--white); }
.mini-price-list span, .mini-price-list small { display: block; color: var(--muted); font-size: 10px; }
.mini-price-list strong { display: block; margin: 12px 0 3px; font-size: 30px; }
.billing-rules { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 22px; color: var(--muted); font-size: 11px; }
.quote-builder { padding-top: 20px; }
.quote-controls { display: grid; grid-template-columns: 1fr .6fr 1fr; gap: 12px; align-items: end; }
.quote-total { min-height: 80px; display: flex; flex-direction: column; justify-content: center; padding: 10px 16px; border-left: 3px solid var(--lime-deep); background: #f1f1e9; }
.quote-total span, .quote-total small { color: var(--muted); font-size: 9px; }
.quote-total strong { margin: 4px 0; font-size: 25px; }
.quote-builder > .button { width: 100%; margin-top: 18px; }
.form-note { margin: 12px 0 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
.quote-result { margin-top: 18px; display: grid; grid-template-columns: 1fr auto; gap: 15px; padding: 18px; border: 1px solid #bbd887; border-radius: 10px; background: #f1ffdd; }
.quote-result > div { min-width: 0; }
.quote-result strong, .quote-result span { display: block; }
.quote-result span { margin-top: 5px; color: var(--muted); font-size: 9px; overflow-wrap: anywhere; }
.quote-result .button { grid-column: 1 / -1; width: 100%; }
.security-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.security-card { min-height: 300px; display: flex; flex-direction: column; }
.card-index { margin-bottom: 24px; color: #aaaca3; font-family: ui-monospace, Consolas, monospace; }
.security-card h2 { margin: auto 0 12px; font-size: 24px; overflow-wrap: anywhere; }
.security-card p { margin: 0; color: var(--muted); line-height: 1.65; }
.safety-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin-top: 20px; background: var(--line); border: 1px solid var(--line); }
.safety-actions article { min-height: 230px; display: flex; flex-direction: column; padding: 24px; background: var(--white); }
.safety-actions p { color: var(--muted); line-height: 1.6; }
.safety-actions button { min-height: 42px; margin-top: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: #eee; }
.safety-actions article > .button { margin-top: auto; }
.deletion-request-form, .deletion-request-state { display: flex; flex-direction: column; gap: 12px; margin-top: auto; }
.deletion-request-form label > span { display: block; margin-bottom: 6px; color: #424551; font-size: 10px; font-weight: 750; }
.deletion-request-form textarea { width: 100%; min-height: 72px; padding: 10px; border: 1px solid #c9c9be; border-radius: 8px; resize: vertical; }
.deletion-request-form .deletion-confirmation { display: flex; align-items: flex-start; gap: 8px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.deletion-request-form .deletion-confirmation span { margin: 0; font-weight: 500; }
.deletion-request-form .deletion-confirmation input { margin-top: 2px; flex: none; }
.deletion-request-state > span { align-self: flex-start; padding: 5px 8px; border-radius: 999px; color: #76570e; background: #fff0be; font-size: 9px; font-weight: 800; }
.deletion-request-state small { color: var(--muted); }
.deletion-request-state p { margin: 0; overflow-wrap: anywhere; }
.inline-callout { margin-top: 12px; display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 24px; border: 1px solid #cfd0c4; border-radius: 14px; background: #f8f8f2; }
.inline-callout p { margin: 5px 0 0; color: var(--muted); }
.admin-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 12px; border: 1px solid #cfd0c4; border-radius: 14px; overflow: hidden; background: #cfd0c4; }
.admin-metrics article { min-height: 140px; display: flex; flex-direction: column; padding: 22px; background: var(--ink); color: white; }
.admin-metrics span { color: #8f94a6; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.admin-metrics strong { margin-top: auto; font-size: 27px; letter-spacing: -.035em; }
.admin-metrics small { margin-top: 6px; color: #9297a7; line-height: 1.4; }
.operations-panel { margin-bottom: 12px; }
.operations-intro { max-width: 920px; color: var(--muted); line-height: 1.7; }
.operations-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 24px; }
.operations-grid article { min-height: 190px; display: flex; flex-direction: column; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #f8f8f2; }
.operations-grid article > span { color: var(--muted); font-size: 10px; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; }
.operations-grid article > strong { margin-top: auto; font-size: 30px; letter-spacing: -.04em; }
.operations-grid article > small { margin-top: 9px; color: var(--muted); line-height: 1.55; }
.operations-grid .operations-unavailable { color: white; background: var(--ink); border-color: var(--ink); }
.operations-grid .operations-unavailable > span, .operations-grid .operations-unavailable > small { color: #aeb1bf; }
.daemon-contact-grid article:nth-child(2) { background: #f0f8e2; border-color: #c7dda3; }
.daemon-contact-grid article:nth-child(4), .daemon-contact-grid article:nth-child(6) { background: #fff0ec; border-color: #efc1b8; }
.daemon-contact-attention { margin-top: 26px; }
.daemon-contact-attention h3 { margin: 0 0 10px; font-size: 16px; }
.operations-footnote { margin: 18px 0 0; color: var(--muted); font-size: 10px; line-height: 1.6; }
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.admin-module { min-height: 550px; }
.maintenance-status { display: grid; grid-template-columns: 1fr 1fr 90px; gap: 8px; margin-top: 18px; }
.maintenance-status > div { min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 9px; background: #f8f8f2; }
.maintenance-status span, .maintenance-status strong { display: block; }
.maintenance-status span { color: var(--muted); font-size: 9px; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; }
.maintenance-status strong { margin-top: 6px; font-size: 12px; overflow-wrap: anywhere; }
.maintenance-status-detail { margin: 10px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.admin-form { display: flex; flex-direction: column; gap: 17px; padding-top: 22px; }
.admin-form label > span { display: block; margin-bottom: 7px; color: #424551; font-size: 11px; font-weight: 750; }
.admin-form input:not([type="checkbox"]), .admin-form select, .admin-form textarea { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid #c9c9be; border-radius: 8px; color: var(--ink); background: white; }
.admin-form textarea { min-height: 80px; resize: vertical; line-height: 1.5; }
.retention-scope-list { display: grid; gap: 7px; }
.retention-scope-list span { padding: 10px 12px; border-left: 3px solid var(--lime-deep); color: #4b4e59; background: #f4f4ed; font-size: 11px; line-height: 1.5; }
.admin-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.switch-row { min-height: 76px; display: flex; align-items: center; gap: 13px; padding: 14px; border: 1px solid #d4d4c8; border-radius: 9px; background: #f6f6ef; }
.switch-row input { width: 42px; height: 24px; flex: none; accent-color: var(--lime-deep); }
.switch-row span { margin: 0 !important; }
.switch-row strong, .switch-row small { display: block; }
.switch-row small { margin-top: 4px; color: var(--muted); font-weight: 400; }
.form-success { margin: 0; padding: 12px 14px; border-radius: 8px; color: #2e6317; background: #e1f6c3; }
.feedback-layout { display: grid; grid-template-columns: 1fr .62fr; gap: 12px; align-items: start; }
.feedback-form { padding-top: 24px; }
.feedback-aside h2 { margin: 14px 0 0; font-size: 30px; letter-spacing: -.04em; }
.feedback-list > article, .admin-feedback-list > article { padding: 24px 0; border-bottom: 1px solid #e4e3da; }
.feedback-list > article:last-child, .admin-feedback-list > article:last-child { border-bottom: 0; }
.feedback-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; color: var(--muted); font-size: 11px; }
.feedback-list > article > p, .admin-feedback-list > article > p { margin: 16px 0 0; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.feedback-response { margin-top: 18px; padding: 16px 18px; border-left: 4px solid var(--lime-deep); background: #f0f8e2; }
.feedback-response strong { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.feedback-response p { margin: 7px 0 0; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
.feedback-admin-form { max-width: 720px; padding: 18px; margin-top: 18px; border: 1px solid #dddcd2; border-radius: 10px; background: #f8f8f2; }
.admin-gate-table article { min-height: 66px; display: grid; grid-template-columns: 46px minmax(0, 1fr) auto 120px; gap: 16px; align-items: center; border-bottom: 1px solid #e4e3da; }
.admin-gate-table article:last-child { border-bottom: 0; }
.gate-priority { font-family: ui-monospace, Consolas, monospace; font-weight: 850; }
.admin-gate-table strong, .admin-gate-table small { display: block; }
.admin-gate-table small { margin-top: 4px; color: var(--muted); font-size: 9px; }
.admin-gate-table article > span:last-child { color: var(--muted); font-size: 11px; text-align: right; }
.audit-list article { min-height: 68px; display: grid; grid-template-columns: 130px 160px minmax(150px, 1fr) 1fr 150px; gap: 14px; align-items: center; border-bottom: 1px solid #e4e3da; font-size: 11px; }
.audit-list article:last-child { border-bottom: 0; }
.audit-list span { color: var(--muted); overflow-wrap: anywhere; }
.audit-list p { margin: 0; line-height: 1.5; }
.audit-list code { color: #495897; overflow-wrap: anywhere; }
.status-page { padding-bottom: 90px; background: #f7f7f1; }
.status-hero { min-height: 430px; display: flex; flex-direction: column; justify-content: flex-end; padding: 80px max(40px, calc((100vw - var(--max)) / 2)); border-bottom: 1px solid var(--line); }
.status-hero-operational { background: linear-gradient(135deg, #ecf8d9 0%, #f8f8f1 70%); }
.status-hero-maintenance { background: linear-gradient(135deg, #e6ebff 0%, #f8f8f1 70%); }
.status-hero-degraded { background: linear-gradient(135deg, #fff0b9 0%, #f8f8f1 70%); }
.status-hero-outage { background: linear-gradient(135deg, #ffd8d0 0%, #f8f8f1 70%); }
.status-hero h1 { max-width: 900px; margin: 22px 0 0; font-size: clamp(42px, 6vw, 78px); line-height: 1.02; letter-spacing: -.055em; }
.status-hero p { max-width: 780px; margin: 22px 0 0; color: var(--muted); font-size: 16px; line-height: 1.75; }
.status-hero small { margin-top: 30px; color: var(--muted); }
.status-section { width: min(var(--max), calc(100% - 40px)); margin: 70px auto 0; }
.download-page { padding-bottom: 0; background: var(--paper); }
.download-hero { min-height: 500px; display: flex; flex-direction: column; justify-content: flex-end; padding: 90px max(40px, calc((100vw - var(--max)) / 2)); color: white; background: radial-gradient(circle at 78% 22%, rgb(202 255 105 / 22%), transparent 30%), var(--ink); }
.download-hero h1 { max-width: 900px; margin: 24px 0 0; font-size: clamp(48px, 7vw, 88px); line-height: .98; letter-spacing: -.06em; }
.download-hero p { max-width: 780px; margin: 24px 0 0; color: #c4c7d3; font-size: 17px; line-height: 1.75; }
.download-hero small { margin-top: 28px; color: #9ca0af; }
.download-section { padding: 90px max(20px, calc((100vw - var(--max)) / 2)); }
.download-heading { display: grid; grid-template-columns: 1fr .8fr; gap: 16px 60px; align-items: end; margin-bottom: 46px; }
.download-heading .eyebrow { grid-column: 1 / -1; }
.download-heading h2 { margin: 0; font-size: clamp(38px, 4vw, 60px); line-height: 1; letter-spacing: -.05em; }
.download-heading p { margin: 0; color: var(--muted); line-height: 1.7; }
.download-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.download-card { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: white; box-shadow: 0 14px 36px rgb(18 21 34 / 6%); }
.download-platform { color: var(--lime-deep); font: 800 11px/1 ui-monospace, Consolas, monospace; letter-spacing: .12em; }
.download-card h3 { margin: 18px 0 8px; font-size: 24px; }
.download-filename { min-height: 44px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
.download-card > .button { width: 100%; margin-top: 24px; }
.checksum-block { width: 100%; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line); }
.checksum-block strong { display: block; margin-bottom: 8px; font-size: 12px; }
.checksum-block code { display: block; color: var(--muted); font-size: 10px; line-height: 1.5; overflow-wrap: anywhere; }
.download-card details { width: 100%; margin-top: 18px; }
.download-card summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 750; }
.download-card pre { max-width: 100%; margin: 10px 0 0; padding: 14px; overflow: auto; border-radius: 8px; color: #dfe8d1; background: #171a24; font-size: 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.download-proof, .download-gated { display: flex; align-items: center; justify-content: space-between; gap: 60px; padding: 70px max(20px, calc((100vw - var(--max)) / 2)); color: white; background: var(--ink); }
.download-proof > div:first-child, .download-gated > div:first-child { max-width: 720px; }
.download-proof h2, .download-gated h2 { margin: 12px 0 0; font-size: clamp(36px, 4vw, 56px); letter-spacing: -.05em; }
.download-proof p, .download-gated p { margin: 16px 0 0; color: #adb1c0; line-height: 1.7; }
.download-proof-actions, .download-gated-actions { display: flex; min-width: 260px; flex-direction: column; gap: 10px; }
.download-gated { min-height: 420px; color: var(--ink); background: #f0eadb; }
.download-gated p { color: #655f53; }
.compact-heading { width: auto; margin: 0 0 24px; }
.compact-heading h2 { font-size: clamp(34px, 4vw, 52px); }
.incident-list { display: grid; gap: 12px; }
.incident-card { padding: 28px; border: 1px solid var(--line); border-left-width: 5px; border-radius: 14px; background: white; }
.incident-card.incident-maintenance { border-left-color: #7184c1; }
.incident-card.incident-degraded { border-left-color: #c79322; }
.incident-card.incident-outage { border-left-color: var(--coral); }
.incident-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; color: var(--muted); font-size: 11px; }
.incident-card h3 { margin: 24px 0 10px; font-size: 28px; letter-spacing: -.035em; }
.incident-card > p { max-width: 850px; margin: 0; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; }
.status-empty { min-height: 150px; display: flex; align-items: center; gap: 20px; padding: 28px; border: 1px solid #cbd8ae; border-radius: 14px; background: #eff8df; }
.status-empty > span { width: 50px; height: 50px; display: grid; place-items: center; flex: none; border-radius: 50%; color: white; background: var(--lime-deep); font-size: 24px; }
.status-empty strong { font-size: 20px; }
.status-empty p { margin: 7px 0 0; color: var(--muted); }
.incident-history-list { border-top: 1px solid var(--line); }
.incident-history-list article { padding: 24px 0; border-bottom: 1px solid var(--line); }
.incident-history-list article > div { display: flex; align-items: baseline; justify-content: space-between; gap: 18px; }
.incident-history-list span, .status-history-empty { color: var(--muted); font-size: 11px; }
.incident-history-list p { margin: 10px 0 0; color: var(--muted); line-height: 1.65; }
.admin-incident-layout { display: grid; grid-template-columns: .75fr 1.25fr; gap: 36px; padding-top: 24px; }
.admin-incident-layout h3 { margin: 0; font-size: 22px; }
.admin-incident-layout > div > p { color: var(--muted); line-height: 1.6; }
.admin-incident-list > article { padding: 22px 0; border-bottom: 1px solid var(--line); }
.admin-incident-list > article:last-child { border-bottom: 0; }
.admin-incident-list h4 { margin: 18px 0 8px; font-size: 18px; }
.admin-incident-list article > p { margin: 0; line-height: 1.65; white-space: pre-wrap; }
.incident-resolve-form { margin-top: 16px; padding: 16px; border: 1px solid #dddcd2; border-radius: 10px; background: #f8f8f2; }
.admin-deletion-list { margin-top: 20px; border-top: 1px solid var(--line); }
.admin-deletion-list article { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(240px, 1fr) 300px; gap: 18px; align-items: center; padding: 20px 0; border-bottom: 1px solid var(--line); }
.admin-deletion-list article > div { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.admin-deletion-list article > div span:last-child { width: 100%; color: var(--muted); font-size: 10px; }
.admin-deletion-list article > p { margin: 0; line-height: 1.55; overflow-wrap: anywhere; }
.admin-deletion-list code { color: #495897; font-size: 10px; overflow-wrap: anywhere; }
.route-state-shell { min-height: 100vh; display: grid; place-items: center; padding: 40px 20px; background: radial-gradient(circle at 20% 15%, rgb(202 255 105 / 20%), transparent 28%), var(--paper); }
.route-state-card { width: min(680px, 100%); padding: clamp(30px, 6vw, 58px); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--white); box-shadow: var(--shadow); }
.route-state-kicker { display: block; margin-bottom: 18px; color: #697443; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .13em; }
.route-state-card h1 { margin: 0; font-size: clamp(34px, 6vw, 58px); line-height: 1.04; letter-spacing: -.05em; }
.route-state-card > p { max-width: 570px; margin: 20px 0 0; color: var(--muted); line-height: 1.7; }
.route-state-note { padding: 15px 17px; border-left: 4px solid #d4a92c; background: #fff6d7; color: #59491e !important; font-size: 12px; }
.route-state-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 30px; }
.route-state-link { min-height: 44px; display: inline-flex; align-items: center; padding: 0 8px; color: #3f579d; font-size: 13px; font-weight: 750; text-decoration: underline; text-underline-offset: 3px; }
.route-loading-lines { display: grid; gap: 10px; margin-top: 34px; }
.route-loading-lines span { height: 13px; border-radius: 999px; background: linear-gradient(90deg, #e7e7dc 20%, #f6f6ee 45%, #e7e7dc 70%); background-size: 220% 100%; animation: route-loading 1.5s ease-in-out infinite; }
.route-loading-lines span:nth-child(2) { width: 82%; }
.route-loading-lines span:nth-child(3) { width: 56%; }
@keyframes route-loading { from { background-position: 100% 0; } to { background-position: -100% 0; } }
@media (max-width: 1050px) {
.public-nav { display: none; }
.hero-grid { grid-template-columns: 1fr; }
.hero-console { transform: none; }
.relay-diagram { grid-template-columns: 1fr; gap: 10px; }
.relay-arrow { min-height: 50px; justify-content: center; }
.relay-arrow i { width: 1px; height: 26px; }
.relay-arrow i::after { right: -3px; top: auto; bottom: 0; border-width: 7px 4px 0; border-color: #a9aa9e transparent transparent; }
.relay-node, .relay-node:first-child, .relay-node:last-child { border-radius: 18px; transform: none; }
.trust-grid { grid-template-columns: 1fr; gap: 50px; }
.footer-grid { grid-template-columns: 1.2fr 1fr; }
.pricing-catalog { grid-template-columns: 1fr; }
.pricing-card > p { min-height: auto; }
.boundary-grid { grid-template-columns: 1fr; }
.boundary-card { min-height: 320px; }
.gates-grid { grid-template-columns: 1fr 1fr; }
.cloud-shell { grid-template-columns: 82px 1fr; }
.cloud-sidebar { padding: 22px 12px; }
.cloud-sidebar .brand-type, .prototype-notice, .cloud-nav a { font-size: 0; }
.cloud-nav a { justify-content: center; padding: 0; }
.cloud-nav a > span { width: auto; font-size: 15px; }
.sidebar-account { grid-template-columns: 1fr; }
.sidebar-account > span:nth-child(2), .sidebar-account > a { display: none; }
.account-avatar { margin: 0 auto; }
.metric-grid { grid-template-columns: 1fr 1fr; }
.dashboard-columns { grid-template-columns: 1fr; }
.admin-incident-layout { grid-template-columns: 1fr; }
.admin-deletion-list article { grid-template-columns: 1fr; }
.feedback-layout { grid-template-columns: 1fr; }
.recovery-options { grid-template-columns: 1fr; }
.path-panel { grid-template-columns: 1fr; gap: 28px; }
.form-layout { grid-template-columns: 1fr; }
.security-grid { grid-template-columns: 1fr; }
.admin-metrics { grid-template-columns: 1fr 1fr; }
.operations-grid { grid-template-columns: 1fr 1fr; }
.admin-grid { grid-template-columns: 1fr; }
.maintenance-status { grid-template-columns: 1fr; }
.audit-list article { grid-template-columns: 110px 150px 1fr; padding: 12px 0; }
.audit-list p, .audit-list code { grid-column: 2 / -1; }
}
@media (max-width: 760px) {
.public-header-inner { width: min(100% - 24px, var(--max)); min-height: 64px; }
.brand-type span, .beta-chip { display: none; }
.header-actions { margin-left: auto; }
.hero-section { padding: 58px 0 64px; }
.hero-grid, .section-heading, .relay-diagram, .steps-grid, .feature-ledger, .price-callout, .trust-grid { width: min(var(--max), calc(100% - 24px)); }
.hero-copy h1 { font-size: clamp(46px, 14vw, 68px); }
.hero-kicker { align-items: flex-start; flex-direction: column; }
.hero-actions .button { width: 100%; }
.hero-facts { gap: 16px; }
.console-layout { grid-template-columns: 1fr; }
.console-tree { display: none; }
.console-chat { min-height: 420px; }
.proof-strip { justify-content: flex-start; overflow-x: auto; flex-wrap: nowrap; }
.proof-strip span { flex: none; padding: 16px 18px; }
.section { padding: 72px 0; }
.split-heading { grid-template-columns: 1fr; gap: 20px; margin-bottom: 42px; }
.section-heading h2, .price-copy h2, .trust-title h2 { font-size: 42px; }
.steps-grid { grid-template-columns: 1fr; }
.steps-grid article { min-height: auto; }
.feature-ledger article { grid-template-columns: 45px 1fr; }
.feature-ledger article div { grid-template-columns: 1fr; gap: 10px; }
.price-callout { grid-template-columns: 1fr; }
.price-copy, .price-board { padding: 32px 24px; }
.visibility-table > div { grid-template-columns: 1.2fr .8fr; padding: 12px 0; }
.visibility-table > div > :nth-child(3) { grid-column: 1 / -1; }
.visibility-head > :nth-child(3) { display: none; }
.readiness-banner { align-items: stretch; flex-direction: column; padding: 54px 20px; }
.readiness-banner .button { width: 100%; }
.footer-grid { grid-template-columns: 1fr; gap: 34px; }
.footer-bottom { flex-direction: column; }
.subpage-hero-inner { width: calc(100% - 24px); padding: 72px 0 64px; }
.subpage-hero h1 { font-size: 54px; }
.pricing-catalog, .policy-grid, .comparison-table, .fine-print, .boundary-grid, .mutable-pwa-grid, .claim-grid, .gates-heading, .gates-grid, .legal-ledger, .data-map-grid, .privacy-action-grid, .privacy-open-items { width: calc(100% - 24px); }
.pricing-card { padding: 26px; }
.policy-grid, .mutable-pwa-grid, .gates-heading { grid-template-columns: 1fr; gap: 34px; }
.rules-list > div { grid-template-columns: 1fr; gap: 8px; }
.comparison-table > div { grid-template-columns: 1fr 1fr; padding: 16px 0; }
.comparison-table > div > :nth-child(3) { grid-column: 2; }
.comparison-table > div > :first-child { grid-row: 1 / span 2; }
.comparison-head > :first-child { grid-row: auto; }
.evidence-checks { grid-template-columns: 1fr; }
.claim-grid { grid-template-columns: 1fr; }
.trust-cta { align-items: stretch; flex-direction: column; }
.trust-cta-actions { width: 100%; justify-content: stretch; }
.trust-cta .button { width: 100%; }
.gates-grid { grid-template-columns: 1fr; }
.legal-ledger { grid-template-columns: 1fr; }
.legal-ledger article:nth-child(odd) { border-right: 0; }
.data-map-grid, .privacy-action-grid, .privacy-open-items { grid-template-columns: 1fr; }
.data-map-card { min-height: auto; padding: 24px; }
.privacy-open-items { gap: 26px; padding: 28px 22px; }
.cloud-shell { display: block; }
.cloud-sidebar { position: sticky; top: 0; z-index: 40; width: 100%; height: auto; display: grid; grid-template-columns: auto 1fr auto; align-items: center; padding: 10px 12px; }
.cloud-brand { padding: 0; }
.cloud-sidebar .brand-mark { width: 34px; height: 34px; }
.cloud-nav { flex-direction: row; justify-content: center; }
.cloud-nav a { width: 44px; min-height: 44px; }
.sidebar-account { margin: 0; padding: 0; border: 0; }
.account-avatar { width: 34px; height: 34px; }
.cloud-main { min-height: calc(100vh - 54px); }
.cloud-page { width: calc(100% - 24px); padding: 26px 0 70px; }
.page-heading { min-height: auto; align-items: stretch; flex-direction: column; margin-bottom: 26px; }
.page-heading h1 { font-size: 44px; }
.page-actions .button { width: 100%; }
.metric-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 145px; }
.panel { padding: 20px; }
.entitlement-bar { grid-template-columns: 1fr 1fr; }
.entitlement-bar > div:nth-child(2) { border-right: 0; }
.entitlement-bar > div { border-bottom: 1px solid #35394c; }
.detailed-host { grid-template-columns: 44px 1fr auto; padding: 12px 0; }
.detailed-host > div:nth-child(3), .detailed-host > div:nth-child(4) { grid-column: 2 / -1; }
.pairing-list article, .order-list article { grid-template-columns: 1fr auto; padding: 14px 0; }
.pairing-list article > span, .order-list article > span { grid-column: 1 / -1; }
.pairing-list .pairing-cancel-action { grid-column: 1 / -1; }
.pairing-list .pairing-cancel-action .button { width: 100%; }
.pairing-result-actions, .pairing-result-actions > *, .pairing-result-actions .button { width: 100%; }
.choice-grid { grid-template-columns: 1fr; }
.registration-steps .button { width: 100%; }
.billing-status-panel { grid-template-columns: 1fr; gap: 30px; padding: 24px; }
.mini-price-list, .billing-rules { grid-template-columns: 1fr; }
.quote-controls { grid-template-columns: 1fr; }
.safety-actions { grid-template-columns: 1fr; }
.inline-callout { align-items: stretch; flex-direction: column; }
.inline-callout .button { width: 100%; }
.admin-metrics { grid-template-columns: 1fr; }
.operations-grid { grid-template-columns: 1fr; }
.admin-module { min-height: auto; }
.admin-form-grid { grid-template-columns: 1fr; }
.service-incident-banner { align-items: flex-start; flex-direction: column; gap: 6px; padding: 12px 16px; }
.service-incident-banner span { align-items: flex-start; flex-direction: column; gap: 4px; }
.connectivity-banner { align-items: flex-start; flex-direction: column; gap: 4px; padding: 12px 16px; text-align: left; }
.route-state-actions { align-items: stretch; flex-direction: column; }
.route-state-actions .button, .route-state-link { width: 100%; }
.route-state-link { justify-content: center; }
.status-hero { min-height: 390px; padding: 64px 20px; }
.download-hero { min-height: 440px; padding: 64px 20px; }
.download-heading { grid-template-columns: 1fr; }
.download-grid { grid-template-columns: 1fr; }
.download-proof, .download-gated { align-items: stretch; flex-direction: column; padding: 54px 20px; }
.download-proof-actions, .download-gated-actions { min-width: 0; width: 100%; }
.download-proof-actions .button, .download-gated-actions .button { width: 100%; }
.status-section { width: calc(100% - 24px); margin-top: 48px; }
.incident-history-list article > div { align-items: flex-start; flex-direction: column; gap: 6px; }
.admin-gate-table article { grid-template-columns: 38px 1fr auto; padding: 12px 0; }
.admin-gate-table article > span:last-child { grid-column: 2 / -1; text-align: left; }
.audit-list article { grid-template-columns: 1fr; gap: 5px; }
.audit-list p, .audit-list code { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.route-loading-lines span { animation: none; }
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "NekoNest Cloud|把本地 coding-agent 接回手机",
template: "%sNekoNest Cloud",
},
description:
"托管的私人 coding-agent 工作中继。项目、凭据和原生会话留在你的主机,手机负责继续与控制。",
openGraph: {
type: "website",
locale: "zh_CN",
title: "NekoNest Cloud|把本地 coding-agent 接回手机",
description:
"项目、CLI 凭据和原生会话留在你的主机;Cloud 负责托管中转与运维。",
images: [
{
url: "/og.png",
width: 1730,
height: 909,
alt: "手机通过 NekoNest Cloud 中继连接本地 coding-agent 工作站",
},
],
},
twitter: {
card: "summary_large_image",
title: "NekoNest Cloud",
description: "把电脑上的 coding-agent,接回手机继续。",
images: ["/og.png"],
},
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#121522",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
+240
View File
@@ -0,0 +1,240 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PublicShell, StatusPill } from "./components/Shells";
import { getPublicCommercialSnapshot } from "@/db/repository";
import { getPublicBetaPresentation } from "@/db/domain";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "把本地 coding-agent 接回手机",
description:
"NekoNest Cloud 让 Windows 与 Linux 主机主动出站连接,在手机上继续真实的本地 Codex、Claude Code、Kimi CLI 与 Grok Build 会话。",
};
export default async function Home() {
const commercial = await getPublicCommercialSnapshot();
const betaActive = Boolean(commercial.beta);
const publicBetaOpen = betaActive && commercial.blockedP0 === 0;
const betaCopy = getPublicBetaPresentation(betaActive);
return (
<PublicShell>
<section className="hero-section">
<div className="hero-grid">
<div className="hero-copy">
<div className="hero-kicker">
<StatusPill tone={publicBetaOpen ? "good" : betaActive ? "warn" : "neutral"}>
{publicBetaOpen ? betaCopy.status : betaActive ? "闭测免费 · 公开接入冻结" : betaCopy.status}
</StatusPill>
<span>{betaActive && !publicBetaOpen ? "免费政策已经预设;当前仅向明确受邀账户开放。" : betaCopy.subline}</span>
</div>
<h1>
<br />
<em>coding-agent</em>
<br />
</h1>
<p className="hero-lead">
CLI Windows Linux NekoNest Cloud
</p>
<div className="hero-actions">
<Link className="button button-primary button-large" href="/dashboard">
{publicBetaOpen ? "连接自己的主机" : "进入控制台"}
<span aria-hidden="true"></span>
</Link>
<Link className="button button-ghost button-large" href="/trust">
</Link>
</div>
<div className="hero-facts" aria-label="产品关键事实">
<span><strong>0</strong> </span>
<span><strong>4</strong> Agent</span>
<span><strong>{betaCopy.factValue}</strong> {betaCopy.factLabel}</span>
</div>
</div>
<div className="hero-console" aria-label="移动控制台示意">
<div className="console-topbar">
<span className="console-dots" aria-hidden="true"><i /><i /><i /></span>
<span> / home-lab</span>
<StatusPill tone="good">线</StatusPill>
</div>
<div className="console-layout">
<div className="console-tree">
<span className="tree-label"></span>
<strong>D:\work\nekonest</strong>
<div className="tree-agent active"><span></span> Codex <small></small></div>
<div className="tree-thread active"></div>
<div className="tree-thread"> daemon </div>
<div className="tree-agent"><span></span> Claude Code <small></small></div>
<div className="tree-agent"><span></span> Kimi CLI <small></small></div>
</div>
<div className="console-chat">
<div className="chat-meta">
<span>Codex · 21:48</span>
<StatusPill tone="info"></StatusPill>
</div>
<div className="message assistant">
<span className="message-avatar">N</span>
<p></p>
</div>
<div className="message user"><p>线</p></div>
<div className="delivery-row">
<span className="pulse-dot" />
WebSocket
</div>
<div className="composer-mock">
<span>线</span>
<button type="button" aria-label="发送示意" disabled></button>
</div>
</div>
</div>
<div className="console-caption"> Cloud Agent </div>
</div>
</div>
</section>
<section className="proof-strip" aria-label="核心边界">
<span></span>
<span> store </span>
<span> Agent 线</span>
<span>Codex </span>
<span></span>
</section>
<section className="section light-section" id="how">
<div className="section-heading split-heading">
<div>
<span className="eyebrow">HOW IT WORKS / </span>
<h2>Cloud </h2>
</div>
<p>
API Key沿
</p>
</div>
<div className="relay-diagram">
<div className="relay-node phone-node">
<span className="node-number">01</span>
<strong> PWA</strong>
<small> · · · </small>
</div>
<div className="relay-arrow"><span>HTTPS / WSS</span><i /></div>
<div className="relay-node cloud-node">
<span className="node-number">02</span>
<strong>NekoNest Cloud</strong>
<small> · · </small>
<b></b>
</div>
<div className="relay-arrow"><span></span><i /></div>
<div className="relay-node host-node">
<span className="node-number">03</span>
<strong></strong>
<small>Daemon · CLI · store · </small>
</div>
</div>
<div className="steps-grid">
<article><span>1</span><h3></h3><p>Windows Linux IP</p></article>
<article><span>2</span><h3></h3><p></p></article>
<article><span>3</span><h3>线</h3><p>Agent 线</p></article>
</div>
</section>
<section className="section dark-section">
<div className="section-heading split-heading inverse">
<div>
<span className="eyebrow">BUILT FOR CONTINUATION</span>
<h2></h2>
</div>
<p> agent 线</p>
</div>
<div className="feature-ledger">
<article>
<span className="ledger-index">A</span>
<div><h3></h3><p> Agent </p></div>
</article>
<article>
<span className="ledger-index">B</span>
<div><h3> Agent </h3><p>Codex Claude CodeKimi CLIGrok Build </p></div>
</article>
<article>
<span className="ledger-index">C</span>
<div><h3></h3><p>线 daemon ghost thread</p></div>
</article>
<article>
<span className="ledger-index">D</span>
<div><h3></h3><p>线</p></div>
</article>
</div>
</section>
<section className="section price-section">
<div className="price-callout">
<div className="price-copy">
<span className="eyebrow">FREE PUBLIC BETA</span>
<h2></h2>
<p>{publicBetaOpen ? "当前面向国内个人用户免费测试。无需支付方式,不生成报价或订单,也不会在结束时自动扣款。" : betaActive ? "免费测试政策已经预设,但公开接入仍由 P0 安全门禁冻结;受邀闭测账户免费,不需要支付方式。" : "免费公测政策已经结束,但收费功能仍未开放;后续决定会另行通知。"}</p>
<ul className="check-list">
<li></li>
<li> Token </li>
<li></li>
</ul>
</div>
<div className="price-board">
<div className="beta-price-row"><span>{betaCopy.priceHeading}</span><strong>{betaCopy.priceValue}</strong><small>{betaCopy.priceDetail}</small></div>
<div className="catalog-price-row">
<span></span>
<strong></strong>
<small></small>
</div>
<div className="catalog-price-row">
<span></span>
<strong></strong>
<small>使</small>
</div>
<Link href="/pricing"> </Link>
</div>
</div>
</section>
<section className="section trust-section">
<div className="trust-grid">
<div className="trust-title">
<span className="eyebrow">TRUST IS A BOUNDARY</span>
<h2></h2>
<p> Cloud </p>
<Link className="text-link" href="/trust"> </Link>
</div>
<div className="visibility-table" role="table" aria-label="数据可见性边界">
<div role="row" className="visibility-head"><span></span><span></span><span>Cloud </span></div>
<div role="row"><span> / CLI </span><strong></strong><em></em></div>
<div role="row"><span></span><strong></strong><em></em></div>
<div role="row"><span> / / </span><strong></strong><em></em></div>
<div role="row"><span> / / </span><strong></strong><em>*</em></div>
<small>* sealed </small>
</div>
</div>
</section>
<section className="readiness-banner">
<div>
<StatusPill tone={commercial.blockedP0 ? "danger" : "good"}>{commercial.blockedP0 ? "免费公测尚未开放" : "公测门禁已通过"}</StatusPill>
<h2>{commercial.blockedP0 ? "控制台能跑,不等于托管链路已经可用。" : "免费公测已经具备基础开放条件。"}</h2>
<p>
{commercial.blockedP0
? `当前还有 ${commercial.blockedP0} 项免费公测 P0 门禁未通过,包括密封传输、租户隔离、主机认领、主体/域名路径和隐私保存。`
: "P0 证据已经齐全;仍需按邀请范围和运营容量逐步开放。"}
</p>
</div>
<Link className="button button-light button-large" href="/readiness">
线
<span aria-hidden="true"></span>
</Link>
</section>
</PublicShell>
);
}
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PublicShell, StatusPill } from "../components/Shells";
import { getPublicCommercialSnapshot } from "@/db/repository";
import { getPublicBetaPresentation } from "@/db/domain";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "免费公测政策",
description: "NekoNest Cloud 前几个月免费公测,不绑定支付方式,也不创建报价或订单。",
};
export default async function PricingPage() {
const commercial = await getPublicCommercialSnapshot();
const betaActive = Boolean(commercial.beta);
const publicBetaOpen = betaActive && commercial.blockedP0 === 0;
const betaCopy = getPublicBetaPresentation(betaActive);
return (
<PublicShell>
<section className="subpage-hero pricing-hero">
<div className="subpage-hero-inner">
<span className="eyebrow">PUBLIC BETA / </span>
<h1></h1>
<p>
{publicBetaOpen
? "当前面向国内个人用户免费测试,不绑定支付方式、不生成报价或订单,也不会在公测结束时自动扣款。"
: betaActive
? "免费测试政策已经预设,但公开接入仍被冻结;当前仅向管理员明确邀请的闭测账户免费开放。"
: "当前免费公测政策已经结束,但收费功能仍未开放。后续方案确定前不会自动创建订单或扣款。"}
</p>
</div>
</section>
<section className="section pricing-catalog-section">
<div className="pricing-catalog">
<article className="pricing-card beta-card featured-card">
<div className="pricing-card-top">
<StatusPill tone={publicBetaOpen ? "good" : betaActive ? "warn" : "neutral"}>{publicBetaOpen ? betaCopy.cardStatus : betaActive ? "邀请闭测" : betaCopy.cardStatus}</StatusPill>
<span>{betaCopy.cardTitle}</span>
</div>
<h2>{betaCopy.priceValue}</h2>
<p>{betaCopy.cardDescription}</p>
<ul className="plain-list">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<Link className="button button-light" href="/dashboard">{betaCopy.cta}</Link>
</article>
<article className="pricing-card">
<div className="pricing-card-top">
<StatusPill tone="info"></StatusPill>
<span></span>
</div>
<h2></h2>
<p></p>
<ul className="plain-list">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<button className="button button-secondary" type="button" disabled></button>
</article>
</div>
</section>
<section className="section policy-section">
<div className="policy-grid">
<div>
<span className="eyebrow">BETA RULES</span>
<h2></h2>
</div>
<dl className="rules-list">
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd></dd></div>
<div><dt>退</dt><dd>退</dd></div>
</dl>
</div>
</section>
<section className="section comparison-section">
<div className="section-heading split-heading">
<div><span className="eyebrow">SELF-HOST OR CLOUD</span><h2></h2></div>
<p>Cloud </p>
</div>
<div className="comparison-table" role="table" aria-label="自托管与 Cloud 比较">
<div role="row" className="comparison-head"><span></span><strong></strong><strong>NekoNest Cloud</strong></div>
<div role="row"><span></span><b> / </b><b>{betaCopy.comparison}</b></div>
<div role="row"><span>VPSDNSTLS</span><b></b><b></b></div>
<div role="row"><span></span><b></b><b></b></div>
<div role="row"><span></span><b></b><b></b></div>
</div>
</section>
</PublicShell>
);
}
+148
View File
@@ -0,0 +1,148 @@
export type DataInventoryGroup = {
id: string;
title: string;
status: "active" | "security" | "operational" | "dormant";
summary: string;
examples: readonly string[];
purpose: string;
retention: string;
userControl: string;
tables: readonly string[];
};
export const DATA_INVENTORY: readonly DataInventoryGroup[] = [
{
id: "account",
title: "账户与登录身份",
status: "active",
summary: "识别登录用户,并把 Cloud 资源隔离到正确账户。",
examples: ["账户 ID", "登录提供方主体标识", "邮箱", "显示名称", "账户状态与时间"],
purpose: "登录、账户隔离、管理员核对和用户数据导出。",
retention: "最终保存期尚未确定;当前随账户保留,注销申请进入人工核对队列。",
userControl: "控制台可下载账户范围导出,并提交或撤回注销申请。",
tables: ["accounts"],
},
{
id: "host-security",
title: "主机、设备与配对安全",
status: "security",
summary: "把用户主机安全地认领到所属账户,并支持撤销和恢复。",
examples: ["主机名称与 OS", "daemon 版本", "公钥与身份指纹", "配对状态", "令牌摘要", "限速来源摘要"],
purpose: "配对认领、设备认证、槽位管理、重放拒绝、限速和安全恢复。",
retention: "明文配对码十分钟失效且不写入 D1;成功认领立即烧毁原摘要,其他过期摘要在维护时转为 tombstone。丢失响应恢复只保存由 daemon 一次性 retry key 加密的响应,十分钟后删除。来源限速窗口保留 24 小时,配对尝试保留 30 天。主机历史与凭据摘要的最终保存期尚未确定。",
userControl: "可取消未认领配对、撤销主机令牌;账户导出不包含令牌、配对码、摘要或内部密钥。",
tables: [
"hosts",
"pairing_requests",
"device_credentials",
"pairing_claim_rate_limits",
"pairing_claim_attempts",
"device_registration_replays",
],
},
{
id: "tenant-runtime",
title: "租户、区域与授权状态",
status: "operational",
summary: "记录租户所属主区域、唯一 Relay placement generation 与当前授权 revision。",
examples: ["租户 ID 与 slug", "home region", "placement generation", "迁移阶段与备份摘要", "authorization revision"],
purpose: "稳定端点路由、唯一写入节点 fencing、可回滚迁移、租户暂停和设备撤销传播。",
retention: "管理员只能针对已确认的注销申请启动永久逻辑删除;Relay 会先关闭 Engine,再删除实时 SQLite、附件和该租户全部备份,并回传摘要证据。云存储物理块擦除、法定保留和最终账户身份清除仍须按上线政策核定。",
userControl: "用户可先提交并在处理前撤回注销申请;删除启动后访问立即暂停且不可撤回。账户导出不包含内部节点地址或删除账本。",
tables: ["tenant_instances", "tenant_placements", "tenant_authorization_state", "relay_migrations", "relay_purge_jobs"],
},
{
id: "relay-infrastructure",
title: "Relay 区域、节点与内部身份",
status: "security",
summary: "维护共享 Relay 池的区域、节点、签名公钥和经 mTLS 绑定的节点凭据摘要。",
examples: ["区域代码", "节点状态", "证书指纹", "SPIFFE ID", "签名 kid 与公钥"],
purpose: "节点认证、授权快照签名、容量调度、故障隔离和密钥轮换。",
retention: "当前作为安全与运维状态保留;D1 不保存签名私钥或明文节点 bearer,轮换和退役记录的最终期限尚未确定。",
userControl: "属于内部基础设施元数据,不进入账户自助导出;用户数据导出不暴露节点地址、凭据摘要或密钥引用。",
tables: ["relay_regions", "relay_nodes", "relay_signing_keys", "relay_node_credentials"],
},
{
id: "phone-relay-access",
title: "手机 handoff 与 Relay 路由凭据",
status: "security",
summary: "把 Cloud 登录会话一次性交给目标 Relay,并验证后续手机路由和手机凭据属于同一租户。",
examples: ["handoff ticket 摘要", "预期 PWA origin", "route handle 摘要", "phone token 摘要", "手机 E2E 公钥"],
purpose: "单次 handoff、防重放、租户路由、可撤销手机身份和 sealed E2E 配对。",
retention: "D1 从不保存明文 ticket、route handle 或 phone token;已消费或过期 ticket 在 24 小时后由维护任务删除,route 与 phone principal 随撤销和租户删除策略处理。",
userControl: "Cloud 登录不自动授予任何设备访问;手机仍须逐设备配对,撤销 phone principal 会同时使对应 route handle 失效。",
tables: ["phone_handoff_tickets", "phone_route_handles", "relay_phone_principals"],
},
{
id: "beta-entitlement",
title: "免费公测资格",
status: "active",
summary: "决定当前账户可以接入多少台主机,不用于积分或余额。",
examples: ["公测开关", "容量上限", "闭测申请场景", "闭测邀请", "起止时间", "处理状态与说明"],
purpose: "公开测试容量控制、闭测申请审核、邀请签发和到期边界。",
retention: "申请、政策和权益记录当前作为状态及审计证据随账户保留;最终保存期尚未确定。",
userControl: "控制台可提交或撤回一条待处理申请,查看审核说明、当前资格、容量和下一次变化;不会据此自动创建订单或扣款。",
tables: ["beta_programs", "entitlement_grants", "beta_access_requests"],
},
{
id: "support-lifecycle",
title: "反馈与账户退出",
status: "active",
summary: "接收公测问题、管理员回复,以及记录可撤回的注销意愿。",
examples: ["反馈分类与正文", "管理员回复", "处理状态", "注销原因", "申请与撤回时间"],
purpose: "解决公测问题,并让账户退出流程可追踪而不是只隐藏界面。",
retention: "最终保存期和注销完成时限尚未确定;真实租户卷与备份擦除闭环完成前不会伪装成已删除。",
userControl: "反馈和注销记录进入账户导出;注销申请在处理前可以撤回。",
tables: ["beta_feedback", "account_deletion_requests"],
},
{
id: "operations",
title: "服务状态、审计与幂等记录",
status: "operational",
summary: "证明管理员做过什么、故障何时发生,并阻止重复写操作。",
examples: ["上线门禁", "故障公告", "动作与对象 ID", "变更前后摘要", "幂等请求摘要", "自动清理状态", "schema 版本"],
purpose: "服务公告、故障恢复、管理动作追溯、重复请求防护、到期数据最小化和数据库安全升级。",
retention: "幂等记录带技术到期时间;每日自动清理与管理员手工回退处理既有技术记录及已消费或过期 24 小时的 handoff ticket。自动任务只覆盖一条最近状态和累计计数,不保存逐次运行历史;审计及其他记录的最终保存期仍是公测门禁。",
userControl: "公开状态和门禁可直接查看;内部审计、请求摘要和迁移账本不进入自助账户导出。",
tables: [
"launch_gates",
"service_incidents",
"audit_events",
"idempotency_records",
"maintenance_jobs",
"cloud_schema_migrations",
],
},
{
id: "dormant-billing",
title: "休眠的收费表结构",
status: "dormant",
summary: "为未来可能的收费保留结构,但免费公测期间禁止创建业务记录。",
examples: ["价格版本", "订单", "付款尝试", "发票", "退款"],
purpose: "当前没有用户用途;相关 API 服务端拒绝写入,未来启用前必须重新决策和审查。",
retention: "免费公测期间不应产生这类记录;若未来启用,须先另行确定保存、退款和财税规则。",
userControl: "没有报价、支付或余额入口,也不会在公测结束时自动扣款。",
tables: ["price_versions", "orders", "payment_attempts", "invoices", "refunds"],
},
] as const;
export const INVENTORIED_TABLES = DATA_INVENTORY.flatMap((group) => group.tables);
export const CONTROL_PLANE_EXCLUSIONS = [
{
item: "项目文件和任意磁盘目录内容",
boundary: "不应上传到 Cloud 控制面。",
},
{
item: "coding-agent 原生会话库与 transcript",
boundary: "由本地主机的原生 store 管理,不进入控制平面 D1。",
},
{
item: "Agent CLI、模型账户和 API Key",
boundary: "只留在用户主机,Cloud 不需要这些凭据。",
},
{
item: "明文配对码、明文设备/手机/节点令牌和签名私钥",
boundary: "仅在必要的签发或请求中短暂处理,不持久化到 D1;D1 只保存摘要。",
},
] as const;
+92
View File
@@ -0,0 +1,92 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PublicShell, StatusPill } from "../components/Shells";
import { CONTROL_PLANE_EXCLUSIONS, DATA_INVENTORY } from "./data-inventory";
export const metadata: Metadata = {
title: "公测数据说明",
description: "NekoNest Cloud 免费公测当前保存的数据、用途、退出方式和尚未完成的保存与删除边界。",
};
const statusCopy = {
active: { label: "公测使用", tone: "good" },
security: { label: "安全必需", tone: "info" },
operational: { label: "运维必需", tone: "neutral" },
dormant: { label: "未启用", tone: "warn" },
} as const;
export default function PrivacyPage() {
return (
<PublicShell>
<section className="subpage-hero privacy-hero">
<div className="subpage-hero-inner">
<span className="eyebrow">BETA DATA MAP / </span>
<h1></h1>
<p></p>
</div>
</section>
<section className="section data-map-section">
<div className="section-heading split-heading">
<div><span className="eyebrow">CURRENT INVENTORY</span><h2></h2></div>
<p></p>
</div>
<div className="data-map-grid">
{DATA_INVENTORY.map((group) => {
const status = statusCopy[group.status];
return (
<article className={`data-map-card data-map-${group.status}`} key={group.id}>
<div className="data-map-card-heading">
<span>{group.id.toUpperCase()}</span>
<StatusPill tone={status.tone}>{status.label}</StatusPill>
</div>
<h3>{group.title}</h3>
<p>{group.summary}</p>
<ul className="data-example-list">
{group.examples.map((example) => <li key={example}>{example}</li>)}
</ul>
<dl className="data-map-details">
<div><dt></dt><dd>{group.purpose}</dd></div>
<div><dt></dt><dd>{group.retention}</dd></div>
<div><dt></dt><dd>{group.userControl}</dd></div>
</dl>
</article>
);
})}
</div>
</section>
<section className="section not-collected-section">
<div className="policy-grid">
<div><span className="eyebrow">CONTROL-PLANE BOUNDARY</span><h2></h2></div>
<div>
<ul className="not-collected-list">
{CONTROL_PLANE_EXCLUSIONS.map((entry) => <li key={entry.item}><strong>{entry.item}</strong><span>{entry.boundary}</span></li>)}
</ul>
<p className="privacy-boundary-note">sealed </p>
</div>
</div>
</section>
<section className="section privacy-actions-section">
<div className="section-heading split-heading">
<div><span className="eyebrow">YOUR CONTROLS</span><h2>使</h2></div>
<p> JSON</p>
</div>
<div className="privacy-action-grid">
<article><span>01</span><h3> Cloud </h3><p></p><Link href="/dashboard/security"> </Link></article>
<article><span>02</span><h3></h3><p></p><Link href="/dashboard/hosts"> </Link></article>
<article><span>03</span><h3></h3><p></p><Link href="/dashboard/security"> </Link></article>
</div>
</section>
<section className="privacy-open-items" aria-labelledby="privacy-open-heading">
<div>
<span className="eyebrow">STILL BLOCKING PUBLIC BETA</span>
<h2 id="privacy-open-heading"></h2>
</div>
<p></p>
</section>
</PublicShell>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { Metadata } from "next";
import { PublicShell, StatusPill } from "../components/Shells";
import { getPublicCommercialSnapshot } from "@/db/repository";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "免费公测上线门禁",
description: "免费公测开放前必须完成的安全、主机接入、主体路径、隐私与运维证据。",
};
const categoryName: Record<string, string> = {
security: "安全",
entitlement: "权益",
infrastructure: "基础设施",
billing: "账单与支付",
compliance: "经营与合规",
privacy: "隐私与数据",
operations: "运维",
product: "产品规则",
};
export default async function ReadinessPage() {
const commercial = await getPublicCommercialSnapshot();
return (
<PublicShell>
<section className="subpage-hero readiness-hero">
<div className="subpage-hero-inner">
<StatusPill tone={commercial.blockedP0 ? "danger" : "good"}>{commercial.blockedP0 ? "PUBLIC BETA BLOCKED" : "P0 GATES PASSED"}</StatusPill>
<h1>{commercial.blockedP0 ? "先把免费公测跑稳。" : "免费公测基础门禁已经通过。"}</h1>
<p>{commercial.blockedP0 ? `控制台可以演示数据和管理流程;真实主机接入、生产租户开通和强隐私承诺仍由 ${commercial.blockedP0} 项 P0 证据门禁阻止。` : "P0 证据已经齐全;接下来按邀请范围、支持能力和运营容量逐步开放。"}</p>
</div>
</section>
<section className="section gates-section">
<div className="gates-heading">
<div><span className="eyebrow">P0 BETA GATES</span><h2></h2></div>
<p></p>
</div>
<div className="gates-grid">
{commercial.gates.map((gate) => (
<article className="gate-card" key={gate.key}>
<div className="gate-card-top">
<span>{categoryName[gate.category] ?? gate.category}</span>
<StatusPill tone={gate.status === "passed" ? "good" : gate.status === "in_progress" ? "warn" : "danger"}>
{gate.status === "passed" ? "已通过" : gate.status === "in_progress" ? "进行中" : "阻止"}
</StatusPill>
</div>
<h3>{gate.title}</h3>
<dl>
<div><dt></dt><dd>{gate.owner || "待指定"}</dd></div>
<div><dt></dt><dd>{gate.evidence_url ? <a href={gate.evidence_url}></a> : "尚未提交"}</dd></div>
</dl>
</article>
))}
</div>
</section>
<section className="section legal-section">
<div className="section-heading split-heading">
<div><span className="eyebrow">DOMESTIC BETA BASICS</span><h2></h2></div>
<p>/</p>
</div>
<div className="legal-ledger">
<article><span>01</span><h3></h3><p></p></article>
<article><span>02</span><h3>退</h3><p>退/</p><a href="/privacy"> </a></article>
<article><span>03</span><h3></h3><p> daemon </p></article>
<article><span>04</span><h3></h3><p></p></article>
</div>
</section>
</PublicShell>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { RouteErrorState } from "../components/RouteStates";
export default function StatusError({ reset }: { reset: () => void }) {
return (
<RouteErrorState
area="服务状态"
title="状态页暂时不可用"
description="这次没有读取到可信的服务公告,因此不会显示推测的“服务正常”。"
reset={reset}
/>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { RouteLoadingState } from "../components/RouteStates";
export default function StatusLoading() {
return (
<RouteLoadingState
area="服务状态"
description="正在核对免费公测服务的最新公告和恢复记录。"
/>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { PublicShell, StatusPill, formatDate } from "../components/Shells";
import { getServiceStatusSnapshot } from "@/db/repository";
export const dynamic = "force-dynamic";
const statusCopy = {
operational: {
label: "服务正常",
tone: "good" as const,
summary: "目前没有正在处理的服务故障或维护公告。",
},
maintenance: {
label: "计划维护",
tone: "info" as const,
summary: "部分能力正在维护,请按公告中的建议操作。",
},
degraded: {
label: "服务降级",
tone: "warn" as const,
summary: "部分用户可能遇到延迟、重连或接入异常。",
},
outage: {
label: "服务中断",
tone: "danger" as const,
summary: "当前存在影响使用的服务中断,我们正在处理。",
},
};
export default async function ServiceStatusPage() {
const snapshot = await getServiceStatusSnapshot();
const copy = statusCopy[snapshot.status];
const resolved = snapshot.recentIncidents.filter(
(incident) => incident.status === "resolved",
);
return (
<PublicShell serviceStatus={snapshot}>
<div className="public-page status-page">
<header className={`status-hero status-hero-${snapshot.status}`}>
<StatusPill tone={copy.tone}>{copy.label}</StatusPill>
<h1>{copy.summary}</h1>
<p>
NekoNest Cloud 线
</p>
<small>{formatDate(snapshot.checkedAt, true)}</small>
</header>
<section className="status-section">
<div className="section-heading compact-heading">
<span className="eyebrow">ACTIVE / </span>
<h2></h2>
</div>
{snapshot.activeIncidents.length ? (
<div className="incident-list">
{snapshot.activeIncidents.map((incident) => (
<article className={`incident-card incident-${incident.severity}`} key={incident.id}>
<div className="incident-card-heading">
<StatusPill tone={statusCopy[incident.severity].tone}>
{statusCopy[incident.severity].label}
</StatusPill>
<span>{formatDate(incident.started_at, true)}</span>
</div>
<h3>{incident.title}</h3>
<p>{incident.message}</p>
</article>
))}
</div>
) : (
<div className="status-empty">
<span aria-hidden="true"></span>
<div><strong></strong><p></p></div>
</div>
)}
</section>
<section className="status-section status-history">
<div className="section-heading compact-heading">
<span className="eyebrow">HISTORY / </span>
<h2></h2>
</div>
{resolved.length ? (
<div className="incident-history-list">
{resolved.map((incident) => (
<article key={incident.id}>
<div><strong>{incident.title}</strong><span>{formatDate(incident.resolved_at, true)}</span></div>
<p>{incident.resolution}</p>
</article>
))}
</div>
) : (
<p className="status-history-empty"></p>
)}
</section>
</div>
</PublicShell>
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PublicShell, StatusPill } from "../components/Shells";
export const metadata: Metadata = {
title: "信任与隐私边界",
description: "NekoNest Cloud 能看到什么、不能承诺什么,以及 sealed 上线前需要哪些证据。",
};
export default function TrustPage() {
return (
<PublicShell>
<section className="subpage-hero trust-hero">
<div className="subpage-hero-inner">
<span className="eyebrow">TRUST / </span>
<h1></h1>
<p> PWA </p>
</div>
</section>
<section className="section boundary-section">
<div className="boundary-grid">
<article className="boundary-card local-card">
<span className="boundary-icon">HOST</span>
<h2></h2>
<ul className="plain-list">
<li></li>
<li>Agent CLI API Key</li>
<li> Agent </li>
<li> ownership</li>
</ul>
</article>
<article className="boundary-card cloud-boundary-card">
<span className="boundary-icon">CLOUD</span>
<h2></h2>
<ul className="plain-list">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</article>
<article className="boundary-card evidence-card">
<span className="boundary-icon">PROVE</span>
<h2></h2>
<p>sealed </p>
<StatusPill tone="danger"></StatusPill>
</article>
</div>
</section>
<section className="section mutable-pwa-section">
<div className="mutable-pwa-grid">
<div>
<span className="eyebrow">THE MUTABLE PWA PROBLEM</span>
<h2> Cloud</h2>
</div>
<div>
<p>使 PWA线 CSP</p>
<div className="evidence-checks">
<span> </span>
<span> nonce/hash CSP线</span>
<span> / provenance</span>
<span> </span>
<span> sealed </span>
</div>
</div>
</div>
</section>
<section className="section claim-section">
<div className="claim-grid">
<div className="claim-do">
<span></span>
<h3>Cloud store </h3>
</div>
<div className="claim-dont">
<span></span>
<h3> E2E</h3>
</div>
</div>
</section>
<section className="trust-cta">
<div><h2></h2><p></p></div>
<div className="trust-cta-actions"><Link className="button button-primary button-large" href="/privacy"> </Link><Link className="button button-secondary button-large" href="/readiness">线</Link></div>
</section>
</PublicShell>
);
}