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
+114
View File
@@ -0,0 +1,114 @@
name: Build pinned Cloud PWA
on:
workflow_dispatch:
inputs:
nekonest_commit:
description: Immutable 40-character NekoNest commit SHA
required: true
type: string
connect_origin:
description: Stable HTTPS Connect origin
required: true
type: string
permissions:
contents: read
concurrency:
group: cloud-pwa-${{ inputs.nekonest_commit }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Validate immutable inputs
shell: pwsh
env:
NEKONEST_COMMIT: ${{ inputs.nekonest_commit }}
CONNECT_ORIGIN: ${{ inputs.connect_origin }}
run: |
if ($env:NEKONEST_COMMIT -notmatch '^[0-9a-f]{40}$') {
throw 'nekonest_commit must be a full immutable SHA'
}
$origin = [Uri]$env:CONNECT_ORIGIN
if ($origin.Scheme -ne 'https' -or $origin.AbsoluteUri.TrimEnd('/') -ne $env:CONNECT_ORIGIN.TrimEnd('/')) {
throw 'connect_origin must be an exact HTTPS origin'
}
if ($origin.UserInfo -or $origin.PathAndQuery -ne '/' -or $origin.Fragment) {
throw 'connect_origin must not contain credentials, path, query, or fragment'
}
- name: Check out exact NekoNest source
uses: actions/checkout@v7
with:
repository: klarkxy/nekonest
ref: ${{ inputs.nekonest_commit }}
fetch-depth: 0
path: nekonest
- name: Verify checked-out source did not move
shell: pwsh
working-directory: nekonest
env:
EXPECTED_SHA: ${{ inputs.nekonest_commit }}
run: |
$actual = (git rev-parse HEAD).Trim()
if ($actual -ne $env:EXPECTED_SHA) { throw "expected $env:EXPECTED_SHA, got $actual" }
- name: Set up pnpm
uses: pnpm/action-setup@v6
with:
version: 10.29.2
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: '24'
cache: pnpm
cache-dependency-path: nekonest/pwa/pnpm-lock.yaml
- name: Install, test, and build managed PWA
working-directory: nekonest/pwa
env:
VITE_NEKONEST_MANAGED: 'true'
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm type-check
pnpm build
- name: Write deploy-time stable endpoint config
shell: pwsh
env:
CONNECT_ORIGIN: ${{ inputs.connect_origin }}
run: |
$config = [ordered]@{
api_base = $env:CONNECT_ORIGIN.TrimEnd('/')
ws_base = $env:CONNECT_ORIGIN.TrimEnd('/').Replace('https://', 'wss://')
attachment_base = $env:CONNECT_ORIGIN.TrimEnd('/')
push_base = $env:CONNECT_ORIGIN.TrimEnd('/')
managed = $true
handoff_exchange_path = '/api/pwa/handoff/exchange'
}
$config | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM nekonest/pwa/dist/runtime-config.json
- name: Record source provenance
shell: pwsh
env:
SOURCE_SHA: ${{ inputs.nekonest_commit }}
run: |
[ordered]@{
repository = 'https://github.com/klarkxy/nekonest'
commit = $env:SOURCE_SHA
built_at = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM nekonest/pwa/dist/source-provenance.json
- name: Upload exact-build artifact
uses: actions/upload-artifact@v7
with:
name: nekonest-cloud-pwa-${{ inputs.nekonest_commit }}
path: nekonest/pwa/dist
if-no-files-found: error
retention-days: 7
+81
View File
@@ -0,0 +1,81 @@
name: Verify pinned Cloud Relay
on:
workflow_dispatch:
inputs:
relay_core_tag:
description: Exact published Relay Core tag, for example relaycore/v0.1.0
required: true
type: string
permissions:
contents: read
concurrency:
group: cloud-relay-${{ inputs.relay_core_tag }}
cancel-in-progress: false
jobs:
verify:
runs-on: ubuntu-latest
defaults:
run:
working-directory: relay
env:
GOWORK: 'off'
CGO_ENABLED: '1'
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: relay/go.mod
cache-dependency-path: relay/go.sum
- name: Require an immutable released Relay Core dependency
shell: pwsh
env:
RELAY_CORE_TAG: ${{ inputs.relay_core_tag }}
run: |
if ($env:RELAY_CORE_TAG -notmatch '^relaycore/v[0-9]+\.[0-9]+\.[0-9]+$') {
throw 'relay_core_tag must be an exact relaycore/vX.Y.Z tag'
}
if (Select-String -Path go.mod -Pattern '^replace\s' -Quiet) {
throw 'Cloud Relay go.mod must not contain replace directives'
}
$expected = $env:RELAY_CORE_TAG.Substring('relaycore/'.Length)
$actual = (go list -m -f '{{.Version}}' github.com/klarkxy/nekonest/relaycore).Trim()
if ($actual -ne $expected) {
throw "go.mod requires $actual but workflow requested $expected"
}
go mod download
- name: Test, vet, race, and build
run: |
go test -count=1 ./...
go vet ./...
go test -race -count=1 ./...
go build -trimpath -o ../release/nekonest-cloud-relay ./cmd/relay
- name: Record source provenance
shell: pwsh
env:
RELAY_CORE_TAG: ${{ inputs.relay_core_tag }}
run: |
$cloudCommit = (git -C .. rev-parse HEAD).Trim()
$binaryHash = (Get-FileHash ../release/nekonest-cloud-relay -Algorithm SHA256).Hash.ToLowerInvariant()
[ordered]@{
cloud_commit = $cloudCommit
relay_core_tag = $env:RELAY_CORE_TAG
binary_sha256 = $binaryHash
built_at = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM ../release/cloud-relay-provenance.json
- uses: actions/upload-artifact@v7
with:
name: nekonest-cloud-relay-${{ github.run_id }}
path: |
release/nekonest-cloud-relay
release/cloud-relay-provenance.json
if-no-files-found: error
retention-days: 7
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.npm-cache
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
next-env.d.ts
*.tsbuildinfo
/dist/
/.wrangler/
/outputs/
/work/
/.codegraph/
+4
View File
@@ -0,0 +1,4 @@
{
"d1": "DB",
"r2": null
}
+171
View File
@@ -0,0 +1,171 @@
# NekoNest Cloud
NekoNest Cloud is the private commercial control plane and managed Relay for
the open-source NekoNest data plane. Self-hosted NekoNest remains independent:
it does not contact this repository, read subscriptions, or display Cloud
concepts.
The managed architecture is deliberately not “one Docker Server per customer”:
```text
Daemon / Cloud PWA
-> stable Connect origin
-> regional Cloud Relay
-> TenantEngineRegistry
-> one isolated relaycore.Engine per tenant
-> per-tenant SQLite and attachment root
Cloud control plane
-> account, entitlement, host slots, placement, authorization revisions
-> short-lived signed authorization snapshots for Relay nodes
```
One account maps to one tenant/Nest. A host slot is consumed by one non-revoked
host identity, whether it is online or not. A copied device credential remains
the same identity and the managed Relay rejects a second simultaneous daemon;
software cannot prove that a fully copied private key resides on a different
physical machine.
## Current commercial policy
- Open-source self-hosting remains free.
- Cloud is a closed, free test while operational and legal gates remain open.
- No quote, order, payment, balance, automatic charge, or token resale path is
enabled.
- The existing entitlement/capacity source is authoritative for host slots.
Registering the N+1 non-revoked identity returns
`device_capacity_exceeded`; revoking a host releases its slot.
- Lowering capacity below the active host count is rejected until the user
explicitly revokes hosts. Cloud never chooses a device to evict.
- Price, billing period, refund, subscription-expiry, and payment-provider
policy remain outside this implementation.
See [`docs/commercial-contract.md`](./docs/commercial-contract.md) and
[`docs/launch-gates.md`](./docs/launch-gates.md).
## Implemented control-plane contracts
- Account, beta entitlement, host-slot, pairing, claim, device credential,
revocation, audit, feedback, retention, and account-exit domain state in D1.
- Atomic host claiming: an identity retry is idempotent and does not consume a
second slot; a different N+1 identity is refused.
- Stable daemon registration. `POST /api/devices/register` returns the existing
device fields plus `connection_state: ready | provisioning` and optional
`retry_after_seconds`; it never returns a tenant Relay URL.
- Regions, Relay nodes, tenant placement, placement generation,
authorization revision, opaque route handles, and single-use handoff tickets.
- Ed25519-signed canonical authorization snapshots with `kid`, issue/expiry,
tenant state, home region, target node, generation, revision, and active
credential digests.
- Relay-node-only internal APIs for live authorization, revision/delta polling,
route resolution, and heartbeat. Node calls use scoped identity; clients
cannot submit a raw tenant ID.
- A 60-second, origin-bound, single-use Dashboard-to-PWA handoff. D1 stores only
the ticket digest. Exchange creates an independent revocable `phone_id`,
`phone_token`, and non-authorizing opaque route handle. Completion first
records a non-authorizing pending principal; only the first request proving
possession of the exact token and route handle activates it and advances the
tenant authorization revision. A lost exchange response therefore cannot
leave an unused active phone identity.
- Login or handoff never grants a phone access to a host. The phone must still
complete NekoNests per-device E2E pairing and receive that devices key
package.
- Stable ingress resolves the current placement for every new connection and
uses authenticated internal HTTPS/WSS forwarding when the home node is
elsewhere; clients never receive a node URL or redirect.
- Fenced backup/restore and migration primitives keep one writable generation.
Application-layer tenant purge closes the Engine, deletes live data,
attachments and all tenant backups, then clears active credentials only
after node acknowledgement. Its completion audit is written only after all
D1 postconditions hold, and the purge job's `completed` marker is the final
fenced mutation.
The Cloud PWA is built from an exact NekoNest revision. It points at the stable
Connect origin through NekoNest runtime endpoint configuration and refuses any
managed endpoint that is not `sealed`.
The manual [`cloud-pwa.yml`](./.github/workflows/cloud-pwa.yml) workflow accepts
only a full 40-character NekoNest commit SHA, verifies the checkout, runs the
PWA tests/type-check/build, writes the deploy-time Connect config, records the
source SHA, and emits an immutable build artifact. It does not deploy by itself.
## Removed legacy design
The unreleased activation poller, `/api/devices/bootstrap`, tenant
`relay_url` handoff, managed-device manifest, per-tenant Server container, and
single-VPS Node provisioner are intentionally removed. They are not a fallback
or a compatibility mode. A daemon with that old managed configuration must
re-register.
## Local control-plane development
Requires Node.js 22.13 or newer:
```powershell
npm ci
npm run dev
```
Production control-plane configuration includes:
- D1 binding `DB` from [`.openai/hosting.json`](./.openai/hosting.json);
- `NEKONEST_CLOUD_ADMIN_EMAILS` for the temporary closed-test admin boundary;
- `NEKONEST_CLOUD_CREDENTIAL_SECRET` with at least 32 random characters for
purpose-separated HMAC digests;
- Relay snapshot signing keys and active `kid`;
- independent Relay-node mTLS identities and an exact origin allowlist for the
Cloud PWA;
- the stable Connect and PWA origins used by registration and handoff.
Sites/ChatGPT identity and the development demo viewer are closed-test
scaffolding, not the final public identity or account-recovery decision.
## Daemon distribution
The download catalog is fail closed. It accepts only an exact stable NekoNest
tag, exact platform assets, `checksums.txt`, and configured SHA-256 digests; it
never follows `latest`. The compatible daemon must implement protocol 1.3,
stable `server_url`, `ready | provisioning`, and structured service errors.
Artifact hashes do not replace publisher/code signing.
See [`docs/daemon-distribution.md`](./docs/daemon-distribution.md).
Relay deployment, internal forwarding, migration and purge configuration are
documented in [`docs/relay-operations.md`](./docs/relay-operations.md).
The manual [`cloud-relay.yml`](./.github/workflows/cloud-relay.yml) workflow
rejects local `replace` directives, verifies the requested `relaycore/vX.Y.Z`
tag against `go.mod`, and runs tests, vet, race checks, and a provenance build
with `GOWORK=off`.
## Database
Drizzle schema is in [`db/schema.ts`](./db/schema.ts), with append-only
migrations under [`drizzle/`](./drizzle/). Runtime migration tracking uses
`cloud_schema_migrations`.
```powershell
npm run db:generate
```
Never rewrite a migration already applied to a D1 environment. Because this
repository has not yet served production users, a disposable environment may
be recreated; any non-disposable D1 database must be exported before migration.
## Verification
```powershell
npm run type-check
npm run lint
npm test
cd relay
go test -count=1 ./...
go vet ./...
```
The Go Relay has its own tests, race checks, and resource-leak checks. A local
green build is not public-service acceptance. Paid/public launch stays blocked
until exact-build deployment proves stable ingress, cross-tenant isolation,
sealed message and attachment paths, 15-second revocation, five-minute snapshot
expiry, backup/restore, deletion/purge, regional migration and rollback,
monitoring/on-call, retention, identity, domain/filing, and payment policy.
+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>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}
+48
View File
@@ -0,0 +1,48 @@
export type FreeBetaBoundaryOperation =
| "create_pairing"
| "claim_pairing"
| "cancel_pairing"
| "connect_device"
| "revoke_host";
export const FREE_BETA_ACCESS_BOUNDARY: ReadonlyArray<{
operation: FreeBetaBoundaryOperation;
requiresCurrentEntitlement: boolean;
preservesExistingHost: boolean;
}> = [
{ operation: "create_pairing", requiresCurrentEntitlement: true, preservesExistingHost: true },
{ operation: "claim_pairing", requiresCurrentEntitlement: true, preservesExistingHost: true },
{ operation: "cancel_pairing", requiresCurrentEntitlement: false, preservesExistingHost: true },
{ operation: "connect_device", requiresCurrentEntitlement: false, preservesExistingHost: true },
{ operation: "revoke_host", requiresCurrentEntitlement: false, preservesExistingHost: false },
] as const;
/**
* Existing claimed devices remain authenticated after a free policy or invite
* ends. Entitlement gates new pairing and claim operations instead of silently
* revoking an already issued device credential.
*/
export const AUTHENTICATE_ACTIVE_DEVICE_SQL = `
UPDATE device_credentials
SET last_used_at = ?1
WHERE host_id = ?2 AND token_hash = ?3 AND status = 'active'
AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?1)
AND EXISTS (
SELECT 1 FROM hosts
WHERE id = ?2 AND lifecycle = 'active' AND slot_state = 'active'
)
`;
/** Security exits intentionally have no entitlement predicate. */
export const REVOKE_ACTIVE_DEVICE_CREDENTIALS_SQL = `
UPDATE device_credentials
SET status = 'revoked', revoked_at = ?1
WHERE host_id = ?2 AND status = 'active'
`;
export const DEACTIVATE_OWNED_HOST_SQL = `
UPDATE hosts
SET lifecycle = 'deactivated', slot_state = 'released',
connection_state = 'offline', deactivated_at = ?1
WHERE id = ?2 AND account_id = ?3 AND lifecycle = 'active'
`;
+190
View File
@@ -0,0 +1,190 @@
import { PUBLIC_BETA_GATE_READY_SQL } from "./launch-gates.ts";
export type BetaAccessRequestStatus =
| "requested"
| "approved"
| "declined"
| "cancelled";
/**
* Reserve one account-level pending request by first reserving its replay key.
* Parameters: scope, key, hash, response JSON, expiry, now, account id.
*/
export const CREATE_ACCESS_REQUEST_IDEMPOTENCY_SQL = `
INSERT INTO idempotency_records
(scope, key, request_hash, response_json, status_code, expires_at, created_at)
SELECT ?1, ?2, ?3, ?4, 201, ?5, ?6
WHERE NOT EXISTS (
SELECT 1 FROM beta_access_requests
WHERE account_id = ?7 AND status = 'requested'
)
AND NOT EXISTS (
SELECT 1 FROM entitlement_grants
WHERE account_id = ?7 AND state = 'active' AND starts_at <= ?6
AND (ends_at IS NULL OR ends_at > ?6) AND revoked_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM beta_programs
WHERE state = 'active' AND starts_at <= ?6
AND (ends_at IS NULL OR ends_at > ?6)
AND ${PUBLIC_BETA_GATE_READY_SQL}
)
`;
/** Parameters: id, account, OS, slots, use case, now, scope, key, hash. */
export const CREATE_ACCESS_REQUEST_SQL = `
INSERT INTO beta_access_requests
(id, account_id, status, preferred_os, requested_slots, use_case,
requested_at, created_at, updated_at)
SELECT ?1, ?2, 'requested', ?3, ?4, ?5, ?6, ?6, ?6
WHERE EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?7 AND key = ?8 AND request_hash = ?9
)
`;
/**
* Reserve cancellation only while the same account still owns a pending row.
* Parameters: scope, key, hash, response JSON, expiry, now, request id, account.
*/
export const CREATE_ACCESS_CANCELLATION_IDEMPOTENCY_SQL = `
INSERT INTO idempotency_records
(scope, key, request_hash, response_json, status_code, expires_at, created_at)
SELECT ?1, ?2, ?3, ?4, 200, ?5, ?6
FROM beta_access_requests
WHERE id = ?7 AND account_id = ?8 AND status = 'requested'
`;
/** Parameters: now, request id, account, scope, key, hash. */
export const CANCEL_ACCESS_REQUEST_SQL = `
UPDATE beta_access_requests
SET status = 'cancelled', cancelled_at = ?1, updated_at = ?1
WHERE id = ?2 AND account_id = ?3 AND status = 'requested'
AND EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?4 AND key = ?5 AND request_hash = ?6
)
`;
/**
* Reserve an administrator decision only while the request is pending.
* Parameters: scope, key, hash, response JSON, status code, expiry, now, request.
*/
export const CREATE_ACCESS_RESOLUTION_IDEMPOTENCY_SQL = `
INSERT INTO idempotency_records
(scope, key, request_hash, response_json, status_code, expires_at, created_at)
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7
FROM beta_access_requests
WHERE id = ?8 AND status = 'requested'
`;
/**
* Create the non-monetary invitation for an approved request.
* Parameters: grant id, request id/source ref, capacity, now, end, reason,
* actor, request id, scope, key, hash.
*/
export const CREATE_APPROVED_INVITATION_SQL = `
INSERT INTO entitlement_grants
(id, account_id, host_id, source, source_ref, capacity_slots,
starts_at, ends_at, state, reason, created_by, created_at)
SELECT ?1, account_id, NULL, 'admin_exemption', ?2, ?3,
?4, ?5, 'active', ?6, ?7, ?4
FROM beta_access_requests
WHERE id = ?8 AND status = 'requested'
AND EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?9 AND key = ?10 AND request_hash = ?11
)
`;
/** Parameters: response, actor, grant id, now, request id, scope, key, hash. */
export const APPROVE_ACCESS_REQUEST_SQL = `
UPDATE beta_access_requests
SET status = 'approved', admin_response = ?1, resolved_by = ?2,
invitation_grant_id = ?3, resolved_at = ?4, updated_at = ?4
WHERE id = ?5 AND status = 'requested'
AND EXISTS (SELECT 1 FROM entitlement_grants WHERE id = ?3)
AND EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?6 AND key = ?7 AND request_hash = ?8
)
`;
/** Parameters: response, actor, now, request id, scope, key, hash. */
export const DECLINE_ACCESS_REQUEST_SQL = `
UPDATE beta_access_requests
SET status = 'declined', admin_response = ?1, resolved_by = ?2,
resolved_at = ?3, updated_at = ?3
WHERE id = ?4 AND status = 'requested'
AND EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?5 AND key = ?6 AND request_hash = ?7
)
`;
/**
* Manual invitations are only for proactive invitations. When an account has
* a pending request, administrators must resolve that request so user-visible
* state and the grant cannot diverge.
* Parameters: scope, key, hash, response JSON, expiry, now, account id.
*/
export const CREATE_MANUAL_INVITATION_IDEMPOTENCY_SQL = `
INSERT INTO idempotency_records
(scope, key, request_hash, response_json, status_code, expires_at, created_at)
SELECT ?1, ?2, ?3, ?4, 201, ?5, ?6
WHERE NOT EXISTS (
SELECT 1 FROM beta_access_requests
WHERE account_id = ?7 AND status = 'requested'
)
`;
/** Parameters: grant fields followed by scope, key and hash. */
export const CREATE_MANUAL_INVITATION_SQL = `
INSERT INTO entitlement_grants
(id, account_id, host_id, source, source_ref, capacity_slots,
starts_at, ends_at, state, reason, created_by, created_at)
SELECT ?1, ?2, NULL, 'admin_exemption', ?3, ?4,
?5, ?6, 'active', ?7, ?8, ?9
WHERE EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?10 AND key = ?11 AND request_hash = ?12
)
`;
export const PUBLIC_BETA_ACCESS_RESPONSE =
"公开免费测试已经开放,当前不再需要单独闭测邀请。";
/**
* Close stale pending requests only when an active public beta and every P0
* evidence gate are simultaneously true inside the same D1 batch.
* Parameters: user-facing response, actor, now.
*/
export const FULFILL_ACCESS_REQUESTS_BY_PUBLIC_BETA_SQL = `
UPDATE beta_access_requests
SET status = 'approved', admin_response = ?1, resolved_by = ?2,
invitation_grant_id = NULL, resolved_at = ?3, updated_at = ?3
WHERE status = 'requested'
AND EXISTS (
SELECT 1 FROM beta_programs
WHERE state = 'active' AND starts_at <= ?3
AND (ends_at IS NULL OR ends_at > ?3)
AND ${PUBLIC_BETA_GATE_READY_SQL}
)
`;
/** Parameters: actor, response, correlation id, now. */
export const AUDIT_PUBLIC_BETA_ACCESS_FULFILLMENT_SQL = `
INSERT OR IGNORE INTO audit_events
(id, actor_id, action, target_type, target_id, reason,
before_json, after_json, correlation_id, created_at)
SELECT 'audit_public_beta_' || substr(id, 8), ?1,
'beta_access.fulfilled_by_public_beta', 'beta_access_request', id,
'公开免费测试开放,待审申请无需单独邀请',
'{"status":"requested"}',
json_object('status', 'approved', 'adminResponse', ?2,
'invitationGrantId', NULL),
?3, ?4
FROM beta_access_requests
WHERE status = 'approved' AND resolved_by = ?1 AND resolved_at = ?4
AND invitation_grant_id IS NULL AND admin_response = ?2
`;
+268
View File
@@ -0,0 +1,268 @@
export const BETA_OPERATIONS_WINDOW_DAYS = 30;
export const BETA_ACCOUNTS_SQL = `
SELECT
COUNT(*) AS total,
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS new_accounts,
SUM(CASE WHEN EXISTS (
SELECT 1 FROM hosts
WHERE hosts.account_id = accounts.id AND hosts.lifecycle = 'active'
) THEN 1 ELSE 0 END) AS with_active_host
FROM accounts`;
export const BETA_PAIRINGS_SQL = `
SELECT
COUNT(*) AS created,
SUM(CASE WHEN status = 'claimed' THEN 1 ELSE 0 END) AS claimed,
SUM(CASE WHEN status = 'waiting' AND expires_at > ? THEN 1 ELSE 0 END) AS waiting,
SUM(CASE WHEN status = 'waiting' AND expires_at <= ? THEN 1 ELSE 0 END) AS expired,
SUM(CASE
WHEN status = 'waiting' AND (expires_at <= ? OR locked_at IS NOT NULL)
THEN 1 ELSE 0
END) AS attention_required,
SUM(CASE WHEN locked_at IS NOT NULL THEN 1 ELSE 0 END) AS locked,
AVG(CASE
WHEN status = 'claimed' AND claimed_at IS NOT NULL
THEN (julianday(claimed_at) - julianday(created_at)) * 86400
END) AS average_claim_seconds
FROM pairing_requests
WHERE created_at >= ?`;
export const BETA_CLAIM_ATTEMPTS_SQL = `
SELECT
SUM(CASE WHEN outcome = 'rejected' THEN 1 ELSE 0 END) AS rejected,
SUM(CASE WHEN outcome = 'rate_limited' THEN 1 ELSE 0 END) AS rate_limited
FROM pairing_claim_attempts
WHERE created_at >= ?`;
export const BETA_PROVISIONING_SQL = `
SELECT
COUNT(*) AS created,
SUM(CASE WHEN state = 'active' THEN 1 ELSE 0 END) AS succeeded,
SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN state IN ('provisioning', 'quiescing', 'copying', 'switching', 'draining') THEN 1 ELSE 0 END) AS in_progress,
AVG(CASE
WHEN state = 'active'
THEN (julianday(updated_at) - julianday(created_at)) * 86400
END) AS average_completion_seconds
FROM tenant_placements
WHERE created_at >= ?`;
export const BETA_SUPPORT_SQL = `
SELECT
COUNT(*) AS created,
SUM(CASE WHEN category = 'connection_issue' THEN 1 ELSE 0 END) AS connection_issues,
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) AS open,
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END) AS resolved,
AVG(CASE
WHEN status = 'resolved' AND resolved_at IS NOT NULL
THEN (julianday(resolved_at) - julianday(created_at)) * 86400
END) AS average_resolution_seconds
FROM beta_feedback
WHERE created_at >= ?`;
export const BETA_ACCESS_REQUESTS_SQL = `
SELECT
COUNT(*) AS submitted,
SUM(requested_slots) AS requested_slot_demand,
AVG(requested_slots) AS average_requested_slots,
SUM(CASE WHEN preferred_os = 'windows' THEN 1 ELSE 0 END) AS windows,
SUM(CASE WHEN preferred_os = 'linux' THEN 1 ELSE 0 END) AS linux,
SUM(CASE WHEN preferred_os = 'both' THEN 1 ELSE 0 END) AS both,
SUM(CASE WHEN status = 'requested' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) AS approved,
SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END) AS declined,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
SUM(CASE
WHEN status = 'approved' AND resolved_at IS NOT NULL AND EXISTS (
SELECT 1 FROM hosts
WHERE hosts.account_id = beta_access_requests.account_id
AND hosts.claimed_at >= beta_access_requests.resolved_at
) THEN 1 ELSE 0
END) AS approved_with_post_approval_claim,
AVG(CASE
WHEN status IN ('approved', 'declined') AND resolved_at IS NOT NULL
THEN (julianday(resolved_at) - julianday(requested_at)) * 86400
END) AS average_review_seconds
FROM beta_access_requests
WHERE requested_at >= ?`;
type AggregateRow = Record<string, number | null>;
export type BetaOperationsSnapshot = {
generatedAt: string;
windowDays: number;
accounts: {
total: number;
newAccounts: number;
withActiveHost: number;
};
pairings: {
created: number;
claimed: number;
waiting: number;
expired: number;
attentionRequired: number;
locked: number;
rejectedAttempts: number;
rateLimitedAttempts: number;
claimRatePercent: number | null;
averageClaimSeconds: number | null;
};
provisioning: {
created: number;
succeeded: number;
failed: number;
inProgress: number;
successRatePercent: number | null;
averageCompletionSeconds: number | null;
};
support: {
created: number;
connectionIssues: number;
open: number;
resolved: number;
resolutionRatePercent: number | null;
averageResolutionSeconds: number | null;
};
accessRequests: {
submitted: number;
requestedSlotDemand: number;
averageRequestedSlots: number | null;
windows: number;
linux: number;
both: number;
pending: number;
approved: number;
declined: number;
cancelled: number;
decided: number;
approvalRatePercent: number | null;
averageReviewSeconds: number | null;
approvedWithPostApprovalClaim: number;
postApprovalClaimRatePercent: number | null;
};
unavailable: readonly [
"relay_reconnect_rate",
"relay_latency",
"runtime_resource_cost",
"support_effort",
];
};
function count(row: AggregateRow, key: string): number {
const value = Number(row[key] ?? 0);
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function duration(row: AggregateRow, key: string): number | null {
const value = Number(row[key]);
return Number.isFinite(value) && value >= 0 ? Math.round(value) : null;
}
function average(row: AggregateRow, key: string): number | null {
const value = Number(row[key]);
return Number.isFinite(value) && value >= 0
? Math.round(value * 10) / 10
: null;
}
function percent(numerator: number, denominator: number): number | null {
if (denominator <= 0) return null;
return Math.round((numerator / denominator) * 1000) / 10;
}
export function deriveBetaOperationsSnapshot(input: {
generatedAt: string;
accounts: AggregateRow;
pairings: AggregateRow;
claimAttempts: AggregateRow;
provisioning: AggregateRow;
support: AggregateRow;
accessRequests: AggregateRow;
}): BetaOperationsSnapshot {
const pairingCreated = count(input.pairings, "created");
const pairingClaimed = count(input.pairings, "claimed");
const provisioningCreated = count(input.provisioning, "created");
const provisioningSucceeded = count(input.provisioning, "succeeded");
const supportCreated = count(input.support, "created");
const supportResolved = count(input.support, "resolved");
const accessSubmitted = count(input.accessRequests, "submitted");
const accessApproved = count(input.accessRequests, "approved");
const accessDeclined = count(input.accessRequests, "declined");
const accessDecided = accessApproved + accessDeclined;
const approvedWithPostApprovalClaim = count(
input.accessRequests,
"approved_with_post_approval_claim",
);
return {
generatedAt: input.generatedAt,
windowDays: BETA_OPERATIONS_WINDOW_DAYS,
accounts: {
total: count(input.accounts, "total"),
newAccounts: count(input.accounts, "new_accounts"),
withActiveHost: count(input.accounts, "with_active_host"),
},
pairings: {
created: pairingCreated,
claimed: pairingClaimed,
waiting: count(input.pairings, "waiting"),
expired: count(input.pairings, "expired"),
attentionRequired: count(input.pairings, "attention_required"),
locked: count(input.pairings, "locked"),
rejectedAttempts: count(input.claimAttempts, "rejected"),
rateLimitedAttempts: count(input.claimAttempts, "rate_limited"),
claimRatePercent: percent(pairingClaimed, pairingCreated),
averageClaimSeconds: duration(input.pairings, "average_claim_seconds"),
},
provisioning: {
created: provisioningCreated,
succeeded: provisioningSucceeded,
failed: count(input.provisioning, "failed"),
inProgress: count(input.provisioning, "in_progress"),
successRatePercent: percent(provisioningSucceeded, provisioningCreated),
averageCompletionSeconds: duration(
input.provisioning,
"average_completion_seconds",
),
},
support: {
created: supportCreated,
connectionIssues: count(input.support, "connection_issues"),
open: count(input.support, "open"),
resolved: supportResolved,
resolutionRatePercent: percent(supportResolved, supportCreated),
averageResolutionSeconds: duration(
input.support,
"average_resolution_seconds",
),
},
accessRequests: {
submitted: accessSubmitted,
requestedSlotDemand: count(input.accessRequests, "requested_slot_demand"),
averageRequestedSlots: average(input.accessRequests, "average_requested_slots"),
windows: count(input.accessRequests, "windows"),
linux: count(input.accessRequests, "linux"),
both: count(input.accessRequests, "both"),
pending: count(input.accessRequests, "pending"),
approved: accessApproved,
declined: accessDeclined,
cancelled: count(input.accessRequests, "cancelled"),
decided: accessDecided,
approvalRatePercent: percent(accessApproved, accessDecided),
averageReviewSeconds: duration(input.accessRequests, "average_review_seconds"),
approvedWithPostApprovalClaim,
postApprovalClaimRatePercent: percent(
approvedWithPostApprovalClaim,
accessApproved,
),
},
unavailable: [
"relay_reconnect_rate",
"relay_latency",
"runtime_resource_cost",
"support_effort",
],
};
}
+367
View File
@@ -0,0 +1,367 @@
import { env } from "cloudflare:workers";
import migrationSql from "../drizzle/0000_condemned_legion.sql?raw";
import claimMigrationSql from "../drizzle/0001_mushy_vance_astro.sql?raw";
import claimOwnershipMigrationSql from "../drizzle/0002_wild_ravenous.sql?raw";
import provisioningMigrationSql from "../drizzle/0003_medical_rocket_racer.sql?raw";
import provisioningFenceMigrationSql from "../drizzle/0004_loud_prodigy.sql?raw";
import feedbackMigrationSql from "../drizzle/0005_pale_corsair.sql?raw";
import serviceIncidentMigrationSql from "../drizzle/0006_clever_shocker.sql?raw";
import accountLifecycleMigrationSql from "../drizzle/0007_zippy_nomad.sql?raw";
import provisionerLivenessMigrationSql from "../drizzle/0008_far_justice.sql?raw";
import maintenanceJobMigrationSql from "../drizzle/0009_flat_robbie_robertson.sql?raw";
import betaAccessRequestMigrationSql from "../drizzle/0010_windy_toxin.sql?raw";
import betaAccessRequestTimeIndexMigrationSql from "../drizzle/0011_next_thunderball.sql?raw";
import sharedRelayControlPlaneMigrationSql from "../drizzle/0012_shared_relay_control_plane.sql?raw";
import relayMigrationFencingMigrationSql from "../drizzle/0013_relay_migration_fencing.sql?raw";
import relayTenantPurgeMigrationSql from "../drizzle/0014_relay_tenant_purge.sql?raw";
import phoneHandoffIdempotencyMigrationSql from "../drizzle/0015_phone_handoff_idempotency.sql?raw";
import phoneHandoffActivationMigrationSql from "../drizzle/0016_phone_handoff_activation.sql?raw";
import provisioningInvariantMigrationSql from "../drizzle/9000_provisioning_invariants.sql?raw";
import provisioningSlugMigrationSql from "../drizzle/9001_provisioning_slug_backfill.sql?raw";
import readyCredentialReconciliationMigrationSql from "../drizzle/9002_ready_credential_reconciliation.sql?raw";
import { LAUNCH_GATE_SEEDS } from "./launch-gates.ts";
let schemaPromise: Promise<void> | null = null;
function getDatabase(): D1Database {
if (!env.DB) {
throw new Error("Cloudflare D1 binding `DB` is unavailable.");
}
return env.DB;
}
function migrationStatements(sql: string): string[] {
return sql
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter(Boolean);
}
async function applyTrackedMigration(
db: D1Database,
id: string,
sql: string,
): Promise<void> {
const applied = await db
.prepare("SELECT id FROM cloud_schema_migrations WHERE id = ?")
.bind(id)
.first<{ id: string }>();
if (applied) return;
const now = new Date().toISOString();
try {
await db.batch([
...migrationStatements(sql).map((statement) => db.prepare(statement)),
db
.prepare(
`INSERT INTO cloud_schema_migrations (id, applied_at)
VALUES (?, ?)`,
)
.bind(id, now),
]);
} catch (error) {
const racedMigration = await db
.prepare("SELECT id FROM cloud_schema_migrations WHERE id = ?")
.bind(id)
.first<{ id: string }>();
if (!racedMigration) throw error;
}
}
async function migrateSchema(db: D1Database): Promise<void> {
let accountsTable = await db
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'accounts'",
)
.first<{ name: string }>();
if (!accountsTable) {
try {
await db.batch(
migrationStatements(migrationSql).map((statement) => db.prepare(statement)),
);
} catch (error) {
accountsTable = await db
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'accounts'",
)
.first<{ name: string }>();
if (!accountsTable) throw error;
}
}
const ledgerTable = await db
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'cloud_schema_migrations'",
)
.first<{ name: string }>();
const claimMigration = ledgerTable
? await db
.prepare("SELECT id FROM cloud_schema_migrations WHERE id = ?")
.bind("0001_mushy_vance_astro")
.first<{ id: string }>()
: null;
if (!claimMigration) {
const statements = migrationStatements(claimMigrationSql);
const applicableStatements = ledgerTable ? statements.slice(1) : statements;
const now = new Date().toISOString();
try {
await db.batch([
...applicableStatements.map((statement) => db.prepare(statement)),
db
.prepare(
`INSERT OR IGNORE INTO cloud_schema_migrations (id, applied_at)
VALUES ('0000_condemned_legion', ?), ('0001_mushy_vance_astro', ?)`,
)
.bind(now, now),
]);
} catch (error) {
const racedMigration = await db
.prepare("SELECT id FROM cloud_schema_migrations WHERE id = ?")
.bind("0001_mushy_vance_astro")
.first<{ id: string }>();
if (!racedMigration) throw error;
}
}
await applyTrackedMigration(
db,
"0002_wild_ravenous",
claimOwnershipMigrationSql,
);
await applyTrackedMigration(
db,
"0003_medical_rocket_racer",
provisioningMigrationSql,
);
await applyTrackedMigration(
db,
"0004_loud_prodigy",
provisioningFenceMigrationSql,
);
await applyTrackedMigration(db, "0005_pale_corsair", feedbackMigrationSql);
await applyTrackedMigration(
db,
"0006_clever_shocker",
serviceIncidentMigrationSql,
);
await applyTrackedMigration(
db,
"0007_zippy_nomad",
accountLifecycleMigrationSql,
);
await applyTrackedMigration(
db,
"0008_far_justice",
provisionerLivenessMigrationSql,
);
await applyTrackedMigration(
db,
"0009_flat_robbie_robertson",
maintenanceJobMigrationSql,
);
await applyTrackedMigration(
db,
"0010_windy_toxin",
betaAccessRequestMigrationSql,
);
await applyTrackedMigration(
db,
"0011_next_thunderball",
betaAccessRequestTimeIndexMigrationSql,
);
await applyTrackedMigration(
db,
"9000_provisioning_invariants",
provisioningInvariantMigrationSql,
);
await applyTrackedMigration(
db,
"9001_provisioning_slug_backfill",
provisioningSlugMigrationSql,
);
await applyTrackedMigration(
db,
"9002_ready_credential_reconciliation",
readyCredentialReconciliationMigrationSql,
);
await applyTrackedMigration(
db,
"0012_shared_relay_control_plane",
sharedRelayControlPlaneMigrationSql,
);
await applyTrackedMigration(
db,
"0013_relay_migration_fencing",
relayMigrationFencingMigrationSql,
);
await applyTrackedMigration(
db,
"0014_relay_tenant_purge",
relayTenantPurgeMigrationSql,
);
await applyTrackedMigration(
db,
"0015_phone_handoff_idempotency",
phoneHandoffIdempotencyMigrationSql,
);
await applyTrackedMigration(
db,
"0016_phone_handoff_activation",
phoneHandoffActivationMigrationSql,
);
await db.prepare("PRAGMA optimize").run();
}
async function seedCatalogAndGates(db: D1Database): Promise<void> {
const now = new Date().toISOString();
const gateStatements = LAUNCH_GATE_SEEDS.map(([key, priority, category, title]) =>
db
.prepare(
`INSERT OR IGNORE INTO launch_gates
(key, priority, category, title, status, notes, updated_at)
VALUES (?, ?, ?, ?, 'blocked', '', ?)`,
)
.bind(key, priority, category, title, now),
);
const gateReconciliationStatements = LAUNCH_GATE_SEEDS.map(
([key, priority, category, title]) =>
db
.prepare(
`UPDATE launch_gates
SET priority = ?, category = ?, title = ?, updated_at = ?
WHERE key = ?
AND (priority <> ? OR category <> ? OR title <> ?)`,
)
.bind(priority, category, title, now, key, priority, category, title),
);
const gateReconciliationAuditStatements = LAUNCH_GATE_SEEDS.map(
([key, priority, category, title]) =>
db
.prepare(
`INSERT OR IGNORE INTO audit_events
(id, actor_id, action, target_type, target_id, reason,
before_json, after_json, correlation_id, created_at)
SELECT 'audit_reclassify_' || key || '_free_beta_v1',
'system:bootstrap', 'launch_gate.reclassified',
'launch_gate', key,
'免费公测门禁与未来收费门禁分离',
json_object('priority', priority, 'category', category, 'title', title),
json_object('priority', ?, 'category', ?, 'title', ?),
'corr_reclassify_' || key || '_free_beta_v1', ?
FROM launch_gates
WHERE key = ?
AND (priority <> ? OR category <> ? OR title <> ?)`,
)
.bind(priority, category, title, now, key, priority, category, title),
);
await db.batch([
db
.prepare(
`INSERT OR IGNORE INTO price_versions
(id, product_code, billing_period, unit_slots, amount_minor, currency,
tax_mode, quote_ttl_seconds, status, effective_from, created_by)
VALUES (?, 'host_slot', 'month', 1, 1000, 'CNY', 'undecided', 900,
'retired', ?, 'system:seed')`,
)
.bind("price_host_month_v1", now),
db
.prepare(
`INSERT OR IGNORE INTO price_versions
(id, product_code, billing_period, unit_slots, amount_minor, currency,
tax_mode, quote_ttl_seconds, status, effective_from, created_by)
VALUES (?, 'host_slot', 'year', 1, 10000, 'CNY', 'undecided', 900,
'retired', ?, 'system:seed')`,
)
.bind("price_host_year_v1", now),
db
.prepare(
`INSERT OR IGNORE INTO beta_programs
(id, state, capacity_slots, starts_at, ends_at, grace_days, created_by)
VALUES (?, 'active', NULL, ?, NULL, 0, 'system:seed')`,
)
.bind("beta_public_v1", now),
...gateStatements,
...gateReconciliationAuditStatements,
...gateReconciliationStatements,
db
.prepare(
`INSERT OR IGNORE INTO audit_events
(id, actor_id, action, target_type, target_id, reason,
before_json, after_json, correlation_id, created_at)
SELECT 'audit_defer_paid_catalog_v1', 'system:bootstrap',
'price_catalog.retired', 'price_catalog', 'host_slot',
'免费公测阶段暂缓收费决策,保留历史价格但撤销发布状态',
'{"status":"published"}', '{"status":"retired"}',
'corr_defer_paid_catalog_v1', ?
WHERE EXISTS (
SELECT 1 FROM price_versions
WHERE product_code = 'host_slot' AND status = 'published'
)`,
)
.bind(now),
db.prepare(
`UPDATE price_versions
SET status = 'retired'
WHERE product_code = 'host_slot' AND status = 'published'`,
),
db
.prepare(
`INSERT OR IGNORE INTO audit_events
(id, actor_id, action, target_type, target_id, reason,
before_json, after_json, correlation_id, created_at)
SELECT 'audit_repair_p0_' || key, 'system:bootstrap',
'launch_gate.repaired', 'launch_gate', key,
'旧版 P0 状态缺少有效证据或使用了不适用,启动时恢复为阻止',
'{"status":"legacy_invalid"}', '{"status":"blocked"}',
'corr_repair_p0_' || key, ?
FROM launch_gates
WHERE priority = 'P0' AND (
status = 'not_applicable'
OR (status = 'passed' AND (
trim(COALESCE(owner, '')) = ''
OR trim(COALESCE(notes, '')) = ''
OR evidence_url IS NULL
OR evidence_url NOT LIKE 'https://%'
))
)`,
)
.bind(now),
db
.prepare(
`UPDATE launch_gates
SET status = 'blocked', reviewed_at = ?, updated_at = ?
WHERE priority = 'P0' AND (
status = 'not_applicable'
OR (status = 'passed' AND (
trim(COALESCE(owner, '')) = ''
OR trim(COALESCE(notes, '')) = ''
OR evidence_url IS NULL
OR evidence_url NOT LIKE 'https://%'
))
)`,
)
.bind(now, now),
]);
}
export async function ensureDatabase(): Promise<void> {
if (!schemaPromise) {
schemaPromise = (async () => {
const db = getDatabase();
await migrateSchema(db);
await seedCatalogAndGates(db);
})().catch((error) => {
schemaPromise = null;
throw error;
});
}
await schemaPromise;
}
export function getD1(): D1Database {
return getDatabase();
}
+218
View File
@@ -0,0 +1,218 @@
export type DeviceIdentity = {
ed25519Public: string;
x25519Public: string;
fingerprint: string;
};
const PAIRING_ID = /^pair_[0-9a-f]{32}$/;
const PAIRING_CODE = /^[0-9A-F]{20}$/;
const PUBLIC_KEY = /^[A-Za-z0-9_-]{43}$/;
const ED25519_SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
const HEX_64 = /^[0-9a-f]{64}$/;
const REGISTRATION_PROOF_DOMAIN = new TextEncoder().encode(
"nekonest-cloud/device-registration-proof/v1",
);
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array {
const result = new Uint8Array(left.length + right.length);
result.set(left);
result.set(right, left.length);
return result;
}
function decodeBase64Url(value: string): Uint8Array {
if (!PUBLIC_KEY.test(value)) throw new Error("invalid_public_key");
const base64 = `${value.replaceAll("-", "+").replaceAll("_", "/")}=`;
let decoded: string;
try {
decoded = atob(base64);
} catch {
throw new Error("invalid_public_key");
}
const bytes = Uint8Array.from(decoded, (character) => character.charCodeAt(0));
if (bytes.length !== 32) throw new Error("invalid_public_key");
return bytes;
}
function decodeEd25519Signature(value: string): Uint8Array {
if (!ED25519_SIGNATURE.test(value)) throw new Error("invalid_registration_proof");
const base64 = `${value.replaceAll("-", "+").replaceAll("_", "/")}==`;
let decoded: string;
try {
decoded = atob(base64);
} catch {
throw new Error("invalid_registration_proof");
}
const bytes = Uint8Array.from(decoded, (character) => character.charCodeAt(0));
if (bytes.length !== 64) throw new Error("invalid_registration_proof");
return bytes;
}
function lengthPrefixed(value: string): Uint8Array {
const encoded = new TextEncoder().encode(value);
const result = new Uint8Array(4 + encoded.length);
new DataView(result.buffer).setUint32(0, encoded.length, false);
result.set(encoded, 4);
return result;
}
export function deviceRegistrationProofTranscript(input: {
bootstrapToken: string;
os: string;
ed25519Public: string;
x25519Public: string;
identityFingerprint: string;
transportMode: string;
}): Uint8Array {
const fields = [
input.bootstrapToken.trim(),
input.os.trim().toLowerCase(),
input.ed25519Public.trim(),
input.x25519Public.trim(),
input.identityFingerprint.trim().toLowerCase(),
input.transportMode.trim(),
].map(lengthPrefixed);
const totalLength = fields.reduce(
(total, field) => total + field.length,
REGISTRATION_PROOF_DOMAIN.length,
);
const transcript = new Uint8Array(totalLength);
transcript.set(REGISTRATION_PROOF_DOMAIN);
let offset = REGISTRATION_PROOF_DOMAIN.length;
for (const field of fields) {
transcript.set(field, offset);
offset += field.length;
}
return transcript;
}
export async function verifyDeviceRegistrationProof(input: {
bootstrapToken: string;
os: string;
ed25519Public: string;
x25519Public: string;
identityFingerprint: string;
transportMode: string;
registrationProof: string;
}): Promise<boolean> {
try {
const publicKey = decodeBase64Url(input.ed25519Public.trim());
const signature = decodeEd25519Signature(input.registrationProof.trim());
const key = await crypto.subtle.importKey(
"raw",
Uint8Array.from(publicKey).buffer,
{ name: "Ed25519" },
false,
["verify"],
);
return crypto.subtle.verify(
{ name: "Ed25519" },
key,
Uint8Array.from(signature).buffer,
Uint8Array.from(deviceRegistrationProofTranscript(input)).buffer,
);
} catch {
return false;
}
}
export function normalizePairingCode(value: string): string {
const normalized = value.trim().toUpperCase();
if (!PAIRING_CODE.test(normalized)) throw new Error("invalid_pairing_code");
return normalized;
}
export function parseBootstrapToken(value: string): {
pairingId: string;
code: string;
} {
const trimmed = value.trim();
const separator = trimmed.indexOf(".");
if (separator < 0 || separator !== trimmed.lastIndexOf(".")) {
throw new Error("invalid_bootstrap_token");
}
const pairingId = trimmed.slice(0, separator);
if (!PAIRING_ID.test(pairingId)) throw new Error("invalid_bootstrap_token");
return {
pairingId,
code: normalizePairingCode(trimmed.slice(separator + 1)),
};
}
export async function sha256Hex(value: string | Uint8Array): Promise<string> {
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
const input = Uint8Array.from(bytes).buffer;
return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", input)));
}
export function constantTimeEqualHex(left: string, right: string): boolean {
const leftNormalized = left.toLowerCase();
const rightNormalized = right.toLowerCase();
let difference = leftNormalized.length ^ rightNormalized.length;
const length = Math.max(leftNormalized.length, rightNormalized.length);
for (let index = 0; index < length; index += 1) {
difference |=
(leftNormalized.charCodeAt(index) || 0) ^
(rightNormalized.charCodeAt(index) || 0);
}
return difference === 0;
}
export async function validateDeviceIdentity(input: {
ed25519Public: string;
x25519Public: string;
identityFingerprint: string;
}): Promise<DeviceIdentity> {
const ed25519Public = input.ed25519Public.trim();
const x25519Public = input.x25519Public.trim();
const fingerprint = input.identityFingerprint.trim().toLowerCase();
const ed25519Bytes = decodeBase64Url(ed25519Public);
const x25519Bytes = decodeBase64Url(x25519Public);
if (!HEX_64.test(fingerprint)) throw new Error("invalid_identity_fingerprint");
const expected = await sha256Hex(concatBytes(ed25519Bytes, x25519Bytes));
if (!constantTimeEqualHex(expected, fingerprint)) {
throw new Error("invalid_identity_fingerprint");
}
return { ed25519Public, x25519Public, fingerprint };
}
export function randomDeviceToken(): string {
return bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
}
export function rateWindowStart(now: Date): string {
const windowMilliseconds = 10 * 60_000;
return new Date(
Math.floor(now.getTime() / windowMilliseconds) * windowMilliseconds,
).toISOString();
}
export async function sourceFingerprint(
rootSecret: string,
source: string,
): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(rootSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`pairing-source-v1\0${source}`),
);
return bytesToHex(new Uint8Array(signature));
}
export function requestSource(request: Request, production: boolean): string {
const edgeSource = request.headers.get("cf-connecting-ip")?.trim() ?? "";
if (/^[0-9A-Fa-f:.]{3,64}$/.test(edgeSource)) return edgeSource.toLowerCase();
if (production) throw new Error("trusted_source_unavailable");
return "local-development";
}
+191
View File
@@ -0,0 +1,191 @@
export const CONTROL_PLANE_CONTACT_FRESH_MS = 10 * 60 * 1_000;
export const CONTROL_PLANE_CONTACT_DELAYED_MS = 30 * 60 * 1_000;
const CONTROL_PLANE_CONTACT_FUTURE_SKEW_MS = 5 * 60 * 1_000;
const ADMIN_HOST_CONTACT_CTE = `
WITH latest_contact AS (
SELECT hosts.id, hosts.account_id, hosts.name, hosts.os,
hosts.daemon_version, accounts.email,
MAX(credentials.last_used_at) AS control_plane_last_seen_at
FROM hosts
INNER JOIN accounts ON accounts.id = hosts.account_id
LEFT JOIN device_credentials AS credentials ON credentials.host_id = hosts.id
WHERE hosts.lifecycle = 'active' AND hosts.slot_state = 'active'
GROUP BY hosts.id, hosts.account_id, hosts.name, hosts.os,
hosts.daemon_version, accounts.email
), categorized AS (
SELECT *, CASE
WHEN control_plane_last_seen_at IS NULL THEN 'never'
WHEN julianday(control_plane_last_seen_at) IS NULL
OR julianday(control_plane_last_seen_at) > julianday(?1) THEN 'invalid'
WHEN julianday(control_plane_last_seen_at) >= julianday(?2) THEN 'fresh'
WHEN julianday(control_plane_last_seen_at) >= julianday(?3) THEN 'delayed'
ELSE 'stale'
END AS contact_state
FROM latest_contact
)
`;
export const ADMIN_HOST_CONTROL_PLANE_SUMMARY_SQL = `${ADMIN_HOST_CONTACT_CTE}
SELECT COUNT(*) AS total_active,
SUM(CASE WHEN contact_state = 'fresh' THEN 1 ELSE 0 END) AS fresh,
SUM(CASE WHEN contact_state = 'delayed' THEN 1 ELSE 0 END) AS delayed,
SUM(CASE WHEN contact_state = 'stale' THEN 1 ELSE 0 END) AS stale,
SUM(CASE WHEN contact_state = 'never' THEN 1 ELSE 0 END) AS never,
SUM(CASE WHEN contact_state = 'invalid' THEN 1 ELSE 0 END) AS invalid,
SUM(CASE WHEN daemon_version IS NULL THEN 1 ELSE 0 END) AS version_unknown
FROM categorized
`;
export const ADMIN_HOST_CONTROL_PLANE_ATTENTION_SQL = `${ADMIN_HOST_CONTACT_CTE}
SELECT id, account_id, name, os, daemon_version, email,
control_plane_last_seen_at, contact_state
FROM categorized
WHERE contact_state <> 'fresh'
ORDER BY CASE contact_state
WHEN 'invalid' THEN 0
WHEN 'never' THEN 1
WHEN 'stale' THEN 2
WHEN 'delayed' THEN 3
ELSE 4
END,
control_plane_last_seen_at ASC, id ASC
LIMIT 25
`;
export const OWNED_HOSTS_WITH_CONTROL_PLANE_CONTACT_SQL = `
SELECT hosts.*,
(SELECT MAX(credentials.last_used_at)
FROM device_credentials AS credentials
WHERE credentials.host_id = hosts.id)
AS control_plane_last_seen_at
FROM hosts
WHERE hosts.account_id = ?
ORDER BY hosts.lifecycle = 'active' DESC, hosts.created_at DESC
`;
export type ControlPlaneContactState =
| "never"
| "fresh"
| "delayed"
| "stale"
| "invalid";
export type ControlPlaneContact = {
state: ControlPlaneContactState;
label: string;
detail: string;
tone: "good" | "warn" | "danger" | "neutral";
};
type AdminHostControlPlaneAggregateRow = Record<string, number | null>;
export type AdminHostControlPlaneAttentionRow = {
id: string;
account_id: string;
name: string;
os: string;
daemon_version: string | null;
email: string;
control_plane_last_seen_at: string | null;
contact_state: Exclude<ControlPlaneContactState, "fresh">;
};
export type AdminHostControlPlaneSnapshot = {
generatedAt: string;
totalActive: number;
fresh: number;
delayed: number;
stale: number;
never: number;
invalid: number;
versionUnknown: number;
attentionHosts: AdminHostControlPlaneAttentionRow[];
};
function aggregateCount(
row: AdminHostControlPlaneAggregateRow,
key: string,
): number {
const value = Number(row[key] ?? 0);
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
export function controlPlaneContactCutoffs(generatedAt: string) {
const nowMs = Date.parse(generatedAt);
if (!Number.isFinite(nowMs)) throw new Error("invalid_control_plane_contact_time");
return {
futureLimitAt: new Date(nowMs + CONTROL_PLANE_CONTACT_FUTURE_SKEW_MS).toISOString(),
freshCutoff: new Date(nowMs - CONTROL_PLANE_CONTACT_FRESH_MS).toISOString(),
delayedCutoff: new Date(nowMs - CONTROL_PLANE_CONTACT_DELAYED_MS).toISOString(),
};
}
export function deriveAdminHostControlPlaneSnapshot(input: {
generatedAt: string;
summary: AdminHostControlPlaneAggregateRow;
attentionHosts: AdminHostControlPlaneAttentionRow[];
}): AdminHostControlPlaneSnapshot {
return {
generatedAt: input.generatedAt,
totalActive: aggregateCount(input.summary, "total_active"),
fresh: aggregateCount(input.summary, "fresh"),
delayed: aggregateCount(input.summary, "delayed"),
stale: aggregateCount(input.summary, "stale"),
never: aggregateCount(input.summary, "never"),
invalid: aggregateCount(input.summary, "invalid"),
versionUnknown: aggregateCount(input.summary, "version_unknown"),
attentionHosts: input.attentionHosts,
};
}
export function deriveControlPlaneContact(
lastSeenAt: string | null,
nowMs = Date.now(),
): ControlPlaneContact {
if (!lastSeenAt) {
return {
state: "never",
label: "尚未联系",
detail: "daemon 还没有成功查询 Cloud 开通状态。",
tone: "neutral",
};
}
const seenAtMs = Date.parse(lastSeenAt);
if (
!Number.isFinite(seenAtMs) ||
seenAtMs > nowMs + CONTROL_PLANE_CONTACT_FUTURE_SKEW_MS
) {
return {
state: "invalid",
label: "时间待核实",
detail: "最近一次控制面签到时间无效或明显超前。",
tone: "warn",
};
}
const ageMs = Math.max(0, nowMs - seenAtMs);
if (ageMs <= CONTROL_PLANE_CONTACT_FRESH_MS) {
return {
state: "fresh",
label: "控制面正常",
detail: "daemon 最近成功查询了 Cloud 开通状态。",
tone: "good",
};
}
if (ageMs <= CONTROL_PLANE_CONTACT_DELAYED_MS) {
return {
state: "delayed",
label: "联系延迟",
detail: "daemon 一段时间没有再次查询 Cloud 开通状态。",
tone: "warn",
};
}
return {
state: "stale",
label: "长时间未联系",
detail: "daemon 已超过半小时没有查询 Cloud 开通状态。",
tone: "danger",
};
}
+23
View File
@@ -0,0 +1,23 @@
export class DomainError extends Error {
readonly code: string;
readonly status: number;
readonly retryable: boolean;
readonly retryAfterSeconds?: number;
readonly actionUrl?: string;
constructor(
code: string,
message: string,
status = 400,
retryable = status >= 500 || status === 429,
retryAfterSeconds?: number,
actionUrl?: string,
) {
super(message);
this.code = code;
this.status = status;
this.retryable = retryable;
this.retryAfterSeconds = retryAfterSeconds;
this.actionUrl = actionUrl;
}
}
+149
View File
@@ -0,0 +1,149 @@
export type BillingPeriod = "month" | "year";
export function getPublicBetaPresentation(active: boolean) {
return active
? {
status: "公测期间服务费全免",
subline: "不绑支付方式 · 不会自动扣款",
factValue: "¥0",
factLabel: "公测服务费",
priceHeading: "当前公测",
priceValue: "¥0",
priceDetail: "全部已允许主机",
cardStatus: "当前有效",
cardTitle: "公开公测",
cardDescription: "公测期间,所有已经允许接入的主机槽位不收服务费。",
cta: "进入公测控制台",
comparison: "当前公测免费;未来方案未定",
}
: {
status: "公测免费政策已结束",
subline: "不会自动扣款 · 付费入口仍关闭",
factValue: "关闭",
factLabel: "公测免费",
priceHeading: "公测政策",
priceValue: "已结束",
priceDetail: "不会转为自动扣款",
cardStatus: "已经结束",
cardTitle: "公开公测",
cardDescription: "免费政策已结束;付费入口仍由上线门禁关闭,不会自动扣款。",
cta: "进入控制台",
comparison: "公测已结束;收费入口未开放",
};
}
export function getBillingEntitlementPresentation(
mode: "public_beta" | "grant" | "none",
publicBetaState: "open" | "gated" | "inactive" = "inactive",
) {
if (mode === "public_beta") {
return {
tone: "good" as const,
status: "公测免费",
title: "当前应付 ¥0",
description: "不需要支付方式;公测结束不会自动生成付款或扣款。",
emptyOrders: "免费公测不提供报价,也不会生成订单或付款单。",
};
}
if (mode === "grant") {
return {
tone: "info" as const,
status: "闭测邀请",
title: "当前权益有效",
description: "当前由有期限的非货币权益覆盖;不会生成付款或自动扣款。",
emptyOrders: "闭测邀请不会生成报价、订单或付款单。",
};
}
if (publicBetaState === "gated") {
return {
tone: "warn" as const,
status: "公开接入冻结",
title: "当前没有有效闭测邀请",
description: "免费政策已经预设,但安全门禁尚未齐全;不会要求付款或自动创建订单。",
emptyOrders: "公开接入冻结期间不会生成报价、订单或付款单。",
};
}
return {
tone: "neutral" as const,
status: "无有效免费资格",
title: "当前无免费权益",
description: "公开公测当前未开放,该账户也没有有效闭测邀请;不会自动生成付款或扣款。",
emptyOrders: "收费功能仍未开放,不会自动生成报价、订单或付款单。",
};
}
/**
* Advance a fixed-term order by one calendar billing period while clamping
* month-end dates. This prevents January 31 from rolling into March and keeps
* leap-day yearly orders on the final valid day of February.
*/
export function addBillingPeriod(
startInput: Date | string,
period: BillingPeriod,
): string {
const start = new Date(startInput);
if (Number.isNaN(start.valueOf())) {
throw new RangeError("Invalid billing term start");
}
const requestedDay = start.getUTCDate();
const result = new Date(start);
result.setUTCDate(1);
if (period === "month") {
result.setUTCMonth(result.getUTCMonth() + 1);
} else {
result.setUTCFullYear(result.getUTCFullYear() + 1);
}
const lastDay = new Date(
Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0),
).getUTCDate();
result.setUTCDate(Math.min(requestedDay, lastDay));
return result.toISOString();
}
/** Return the next time the currently summarized entitlement can change. */
export function nextEntitlementExpiry(
values: Array<string | null>,
): string | null {
return values
.filter((value): value is string => Boolean(value))
.sort()
.at(0) ?? null;
}
export type EntitlementComponent = {
source: string;
capacity: number | null;
endsAt: string | null;
};
export function summarizeEntitlementComponents(
components: EntitlementComponent[],
activeSlots: number,
reservedSlots: number,
) {
const unlimitedComponents = components.filter(
(component) => component.capacity === null,
);
const unlimited = unlimitedComponents.length > 0;
const capacitySlots = unlimited
? null
: components.reduce((sum, component) => sum + (component.capacity ?? 0), 0);
const effectiveUntil = unlimited
? unlimitedComponents.some((component) => component.endsAt === null)
? null
: nextEntitlementExpiry(unlimitedComponents.map((component) => component.endsAt))
: nextEntitlementExpiry(components.map((component) => component.endsAt));
return {
unlimited,
capacitySlots,
availableSlots: unlimited
? null
: Math.max(0, (capacitySlots ?? 0) - activeSlots - reservedSlots),
effectiveUntil,
sources: [...new Set(components.map((component) => component.source))],
};
}
+13
View File
@@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}
+58
View File
@@ -0,0 +1,58 @@
export type InvitationDisplayState = "active" | "expired" | "revoked";
export function deriveInvitationDisplayState(
invitation: { state: string; ends_at: string | null; revoked_at: string | null },
now = new Date().toISOString(),
): InvitationDisplayState {
if (invitation.state === "revoked" || invitation.revoked_at) return "revoked";
if (invitation.ends_at && invitation.ends_at <= now) return "expired";
return "active";
}
/**
* Create the replay record only while the requested administrator invitation
* is still revocable. D1 batch serialization makes this predicate the fence
* for concurrent revocations with different idempotency keys.
*
* Parameters: scope, key, request hash, response JSON, expiry, now, grant id.
*/
export const CREATE_INVITATION_REVOCATION_IDEMPOTENCY_SQL = `
INSERT INTO idempotency_records
(scope, key, request_hash, response_json, status_code, expires_at, created_at)
SELECT ?1, ?2, ?3, ?4, 200, ?5, ?6
FROM entitlement_grants
WHERE id = ?7 AND source = 'admin_exemption'
AND state = 'active' AND revoked_at IS NULL
`;
/** Parameters: revoked time, grant id, scope, key, request hash. */
export const REVOKE_INVITATION_SQL = `
UPDATE entitlement_grants
SET state = 'revoked', revoked_at = ?1
WHERE id = ?2 AND source = 'admin_exemption'
AND state = 'active' AND revoked_at IS NULL
AND EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?3 AND key = ?4 AND request_hash = ?5
)
`;
/**
* Append exactly one audit row for a successful revocation. The audit id is
* deterministic per idempotency key, so a replay cannot duplicate history.
*/
export const AUDIT_INVITATION_REVOCATION_SQL = `
INSERT OR IGNORE INTO audit_events
(id, actor_id, action, target_type, target_id, reason,
before_json, after_json, correlation_id, created_at)
SELECT ?1, ?2, 'entitlement.invitation_revoked', 'entitlement_grant', ?3,
?4, ?5, ?6, ?7, ?8
WHERE EXISTS (
SELECT 1 FROM idempotency_records
WHERE scope = ?9 AND key = ?10 AND request_hash = ?11
)
AND EXISTS (
SELECT 1 FROM entitlement_grants
WHERE id = ?3 AND state = 'revoked' AND revoked_at = ?8
)
`;
+87
View File
@@ -0,0 +1,87 @@
export const LAUNCH_GATE_SEEDS = [
["sealed-e2e", "P0", "security", "sealed 命令与附件端到端实证"],
["expiry-boundary", "P0", "entitlement", "到期后的操作边界与重放测试"],
["tenant-isolation", "P0", "infrastructure", "租户隔离、备份与删除验证"],
["billing-idempotency", "PAID", "billing", "订单到权益的幂等与对账"],
["pairing-claim-security", "P0", "security", "主机配对认领、限速与尝试预算"],
["public-auth", "P0", "identity", "国内个人用户登录、恢复与管理员身份"],
["legal-entity", "P0", "compliance", "免费公测主体、域名与备案路径"],
["payment-provider", "PAID", "billing", "支付商户准入、验签、退款与对账"],
["tax-invoice", "PAID", "compliance", "税务、含税口径与数电发票"],
["privacy-retention", "P0", "privacy", "数据清单、保存、删除与跨境路径"],
["terms-consumer", "PAID", "compliance", "付费服务条款、取消、退款与消费者规则"],
["backup-restore", "P1", "operations", "备份与恢复演练"],
["capacity-economics", "P1", "operations", "容量、成本与支持工时实测"],
["beta-policy", "P1", "product", "公测结束、通知、宽限与反滥用限制"],
["build-toolchain-audit", "P1", "security", "构建工具链残余公告与上游替换"],
["account-lifecycle", "P1", "privacy", "导出、注销、保留例外与备份擦除"],
["host-lifecycle-recovery", "P1", "operations", "主机恢复、换绑、停用、重装与凭据轮换"],
["incident-response", "P1", "security", "安全事件分级、值守、通知与服务流程"],
["release-provenance", "P1", "security", "PWA 构建来源、CSP、依赖与回滚 provenance"],
] as const;
export const REQUIRED_PUBLIC_BETA_P0_KEYS = LAUNCH_GATE_SEEDS
.filter(([, priority]) => priority === "P0")
.map(([key]) => key);
export type LaunchGateEvidence = {
key: string;
priority: string;
status: string;
owner: string | null;
notes: string;
evidence_url: string | null;
};
export function hasPassingGateEvidence(gate: LaunchGateEvidence): boolean {
if (
gate.status !== "passed"
|| !gate.owner?.trim()
|| !gate.notes.trim()
|| !gate.evidence_url
) {
return false;
}
try {
return new URL(gate.evidence_url).protocol === "https:";
} catch {
return false;
}
}
export function countBlockedPublicBetaP0(gates: readonly LaunchGateEvidence[]): number {
const required = new Set<string>(REQUIRED_PUBLIC_BETA_P0_KEYS);
const presentRequired = new Set(
gates
.filter((gate) => gate.priority === "P0" && required.has(gate.key))
.map((gate) => gate.key),
);
const missingRequired = REQUIRED_PUBLIC_BETA_P0_KEYS.length - presentRequired.size;
const invalidP0 = gates.filter(
(gate) => gate.priority === "P0" && !hasPassingGateEvidence(gate),
).length;
return missingRequired + invalidP0;
}
const requiredP0SqlList = REQUIRED_PUBLIC_BETA_P0_KEYS
.map((key) => `'${key.replaceAll("'", "''")}'`)
.join(", ");
/**
* Fail closed when a required P0 row is missing or any P0 row lacks complete
* passing evidence. This fragment is embedded only in repository-owned SQL.
*/
export const PUBLIC_BETA_GATE_READY_SQL = `
(SELECT COUNT(*) FROM launch_gates
WHERE priority = 'P0' AND key IN (${requiredP0SqlList})) = ${REQUIRED_PUBLIC_BETA_P0_KEYS.length}
AND NOT EXISTS (
SELECT 1 FROM launch_gates
WHERE priority = 'P0'
AND (
status != 'passed'
OR trim(COALESCE(owner, '')) = ''
OR trim(COALESCE(notes, '')) = ''
OR evidence_url IS NULL
OR lower(evidence_url) NOT LIKE 'https://%'
)
)`;
+351
View File
@@ -0,0 +1,351 @@
import { PUBLIC_BETA_GATE_READY_SQL } from "./launch-gates.ts";
/**
* Reserve a pairing slot in one SQLite statement.
*
* Parameters:
* ?1 request id, ?2 account id, ?3 requested name, ?4 OS,
* ?5 code hash, ?6 expiry, ?7 current UTC timestamp.
*
* Keeping the capacity and pending-limit predicates inside the INSERT makes
* concurrent D1 writes serialize around the actual reservation instead of
* trusting an earlier read that may already be stale.
*/
export const RESERVE_PAIRING_SQL = `
WITH active_beta AS (
SELECT capacity_slots
FROM beta_programs
WHERE state = 'active' AND starts_at <= ?7
AND (ends_at IS NULL OR ends_at > ?7)
AND ${PUBLIC_BETA_GATE_READY_SQL}
ORDER BY created_at DESC, id DESC
LIMIT 1
)
INSERT INTO pairing_requests
(id, account_id, requested_name, os, code_hash, status, expires_at, created_at)
SELECT ?1, ?2, ?3, ?4, ?5, 'waiting', ?6, ?7
WHERE
(
SELECT COUNT(*) FROM pairing_requests
WHERE account_id = ?2 AND status = 'waiting' AND expires_at > ?7
) < 5
AND (
EXISTS (
SELECT 1 FROM active_beta WHERE capacity_slots IS NULL
)
OR EXISTS (
SELECT 1 FROM entitlement_grants
WHERE account_id = ?2 AND state = 'active' AND starts_at <= ?7
AND (ends_at IS NULL OR ends_at > ?7)
AND revoked_at IS NULL AND capacity_slots IS NULL
)
OR (
COALESCE((
SELECT SUM(capacity_slots) FROM active_beta
WHERE capacity_slots IS NOT NULL
), 0)
+ COALESCE((
SELECT SUM(capacity_slots) FROM entitlement_grants
WHERE account_id = ?2 AND state = 'active' AND starts_at <= ?7
AND (ends_at IS NULL OR ends_at > ?7)
AND revoked_at IS NULL AND capacity_slots IS NOT NULL
), 0)
>
(
SELECT COUNT(*) FROM hosts
WHERE account_id = ?2 AND lifecycle = 'active' AND slot_state = 'active'
)
+ (
SELECT COUNT(*) FROM pairing_requests
WHERE account_id = ?2 AND status = 'waiting' AND expires_at > ?7
)
)
)
`;
export const CONSUME_CLAIM_RATE_SQL = `
INSERT INTO pairing_claim_rate_limits
(source_hash, window_start, attempts, updated_at)
VALUES (?1, ?2, 1, ?3)
ON CONFLICT(source_hash, window_start) DO UPDATE SET
attempts = pairing_claim_rate_limits.attempts + 1,
updated_at = excluded.updated_at
WHERE pairing_claim_rate_limits.attempts < 20
`;
export const RECORD_FAILED_CODE_SQL = `
UPDATE pairing_requests
SET failed_attempts = failed_attempts + 1,
last_attempt_at = ?1,
locked_at = CASE WHEN failed_attempts + 1 >= 5 THEN ?1 ELSE locked_at END,
status = CASE WHEN failed_attempts + 1 >= 5 THEN 'locked' ELSE status END
WHERE id = ?2 AND status = 'waiting' AND locked_at IS NULL
AND expires_at > ?1 AND failed_attempts < 5
`;
/**
* Invalidate an unclaimed pairing request owned by one account.
*
* Parameters:
* ?1 request id, ?2 account id, ?3 replacement code hash.
*
* Replacing the hash makes an accidentally reverted status insufficient to
* revive the original bearer code.
*/
export const CANCEL_PAIRING_SQL = `
UPDATE pairing_requests
SET status = 'cancelled', code_hash = ?3
WHERE id = ?1 AND account_id = ?2 AND status = 'waiting'
`;
export const OWNED_PAIRING_PROGRESS_SQL = `
SELECT pairing_requests.id, pairing_requests.status,
pairing_requests.expires_at, pairing_requests.claimed_host_id,
pairing_requests.claimed_at,
(SELECT MAX(attempts.created_at)
FROM pairing_claim_attempts AS attempts
WHERE attempts.pairing_request_id = pairing_requests.id)
AS last_claim_attempt_at
FROM pairing_requests
WHERE pairing_requests.id = ? AND pairing_requests.account_id = ?
`;
export type PairingProgressRow = {
id: string;
status: string;
expires_at: string;
claimed_host_id: string | null;
claimed_at: string | null;
last_claim_attempt_at: string | null;
};
export 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;
};
export function derivePairingProgress(
row: PairingProgressRow,
nowInput: string,
): PairingProgress {
const now = Date.parse(nowInput);
const expiresAt = Date.parse(row.expires_at);
if (!Number.isFinite(now) || !Number.isFinite(expiresAt)) {
throw new RangeError("invalid_pairing_progress_time");
}
const claimAttemptAt = row.last_claim_attempt_at
? Date.parse(row.last_claim_attempt_at)
: null;
const claimAttemptState = claimAttemptAt === null
? "not_seen"
: !Number.isFinite(claimAttemptAt) || claimAttemptAt > now + 5 * 60_000
? "invalid"
: "seen";
const lastClaimAttemptAt = claimAttemptState === "seen"
? row.last_claim_attempt_at
: null;
if (row.status === "waiting") {
return {
id: row.id,
status: expiresAt <= now ? "expired" : "waiting",
expiresAt: row.expires_at,
claimedHostId: null,
claimedAt: null,
claimAttemptState,
lastClaimAttemptAt,
};
}
if (row.status === "claimed") {
if (!row.claimed_host_id || !row.claimed_at) {
throw new RangeError("incomplete_claimed_pairing_progress");
}
return {
id: row.id,
status: "claimed",
expiresAt: row.expires_at,
claimedHostId: row.claimed_host_id,
claimedAt: row.claimed_at,
claimAttemptState,
lastClaimAttemptAt,
};
}
if (row.status === "locked" || row.status === "cancelled") {
return {
id: row.id,
status: row.status,
expiresAt: row.expires_at,
claimedHostId: null,
claimedAt: null,
claimAttemptState,
lastClaimAttemptAt,
};
}
throw new RangeError("unknown_pairing_progress_status");
}
const CURRENT_PAIRING_ACCESS = `
(
EXISTS (
SELECT 1 FROM beta_programs
WHERE state = 'active' AND starts_at <= ? AND (ends_at IS NULL OR ends_at > ?)
AND ${PUBLIC_BETA_GATE_READY_SQL}
)
OR EXISTS (
SELECT 1 FROM entitlement_grants
WHERE account_id = pairing_requests.account_id
AND state = 'active' AND starts_at <= ? AND (ends_at IS NULL OR ends_at > ?)
AND revoked_at IS NULL
)
)`;
const VALID_CLAIM = `
id = ? AND code_hash = ? AND status = 'waiting'
AND expires_at > ? AND locked_at IS NULL AND failed_attempts < 5 AND os = ?
AND ${CURRENT_PAIRING_ACCESS}
`;
/**
* Claim-time capacity check for CLAIM_HOST_SQL.
*
* The numbered parameters intentionally reuse that statement's current-time
* bindings: ?11/?12 for the latest public beta and ?13/?14 for grants. A
* waiting request reserves capacity while it is waiting, but claim converts
* it into an active host. Therefore the atomic claim boundary compares the
* current finite entitlement with active hosts only. If capacity was reduced
* after several pairing codes were issued, the first claims up to the new
* limit may succeed and later claims fail without touching existing hosts.
*/
const HOST_CLAIM_CAPACITY = `
(
EXISTS (
SELECT 1 FROM current_beta WHERE capacity_slots IS NULL
)
OR EXISTS (
SELECT 1 FROM entitlement_grants
WHERE account_id = pairing_requests.account_id
AND state = 'active' AND starts_at <= ?13
AND (ends_at IS NULL OR ends_at > ?14)
AND revoked_at IS NULL AND capacity_slots IS NULL
)
OR (
COALESCE((SELECT capacity_slots FROM current_beta), 0)
+ COALESCE((
SELECT SUM(capacity_slots) FROM entitlement_grants
WHERE account_id = pairing_requests.account_id
AND state = 'active' AND starts_at <= ?13
AND (ends_at IS NULL OR ends_at > ?14)
AND revoked_at IS NULL AND capacity_slots IS NOT NULL
), 0)
> (
SELECT COUNT(*) FROM hosts
WHERE account_id = pairing_requests.account_id
AND lifecycle = 'active' AND slot_state = 'active'
)
)
)`;
export const CLAIM_HOST_SQL = `
WITH current_beta AS (
SELECT capacity_slots
FROM beta_programs
WHERE state = 'active' AND starts_at <= ?11
AND (ends_at IS NULL OR ends_at > ?12)
AND ${PUBLIC_BETA_GATE_READY_SQL}
ORDER BY created_at DESC, id DESC
LIMIT 1
)
INSERT INTO hosts
(id, account_id, name, os, lifecycle, slot_state, connection_state,
daemon_version, ed25519_public, x25519_public, identity_fingerprint, claim_request_id,
claimed_at, created_at)
SELECT ?1, account_id, requested_name, os, 'active', 'active', 'offline',
?15, ?2, ?3, ?4, id, ?5, ?6
FROM pairing_requests
WHERE id = ?7 AND code_hash = ?8 AND status = 'waiting'
AND expires_at > ?9 AND locked_at IS NULL AND failed_attempts < 5
AND os = ?10
AND (
EXISTS (SELECT 1 FROM current_beta)
OR EXISTS (
SELECT 1 FROM entitlement_grants
WHERE account_id = pairing_requests.account_id
AND state = 'active' AND starts_at <= ?13
AND (ends_at IS NULL OR ends_at > ?14)
AND revoked_at IS NULL
)
)
AND ${HOST_CLAIM_CAPACITY}
ON CONFLICT(identity_fingerprint) DO UPDATE SET
name = excluded.name,
os = excluded.os,
lifecycle = 'active',
slot_state = 'active',
connection_state = 'offline',
daemon_version = COALESCE(excluded.daemon_version, hosts.daemon_version),
ed25519_public = excluded.ed25519_public,
x25519_public = excluded.x25519_public,
claim_request_id = excluded.claim_request_id,
claimed_at = excluded.claimed_at,
deactivated_at = NULL
WHERE hosts.account_id = excluded.account_id
AND hosts.lifecycle = 'deactivated'
AND hosts.slot_state = 'released'
`;
export const CLAIM_CREDENTIAL_SQL = `
INSERT INTO device_credentials
(id, host_id, token_hash, status, issued_at, created_at)
SELECT ?, ?, ?, 'active', ?, ?
FROM pairing_requests
WHERE ${VALID_CLAIM}
AND EXISTS (
SELECT 1 FROM hosts
WHERE id = ? AND identity_fingerprint = ?
AND claim_request_id = pairing_requests.id
)
`;
export const COMMIT_PAIRING_CLAIM_SQL = `
UPDATE pairing_requests
SET status = 'claimed', claimed_host_id = ?, claimed_at = ?, last_attempt_at = ?,
code_hash = ?
WHERE ${VALID_CLAIM}
AND EXISTS (
SELECT 1 FROM device_credentials
WHERE id = ? AND host_id = ? AND status = 'active'
)
`;
/**
* Publish a successfully claimed device set to the tenant reconciler.
*
* Parameters:
* ?1 current UTC timestamp, ?2 account id, ?3 pairing id, ?4 host id,
* ?5 claimed timestamp, ?6 credential id, ?7 device token hash.
*
* The positive pairing and credential predicates keep this update in the
* same fail-closed D1 batch as the claim. A failed or partial claim therefore
* cannot create provisioning work.
*/
export const ADVANCE_TENANT_CREDENTIAL_AFTER_CLAIM_SQL = `
UPDATE tenant_instances
SET credential_revision = credential_revision + 1,
lifecycle = CASE WHEN lifecycle = 'ready' THEN 'degraded' ELSE lifecycle END,
relay_ready = 0,
updated_at = ?
WHERE account_id = ?
AND EXISTS (
SELECT 1 FROM pairing_requests
WHERE id = ? AND status = 'claimed' AND claimed_host_id = ? AND claimed_at = ?
)
AND EXISTS (
SELECT 1 FROM device_credentials
WHERE id = ? AND host_id = ? AND token_hash = ? AND status = 'active'
)
`;
+62
View File
@@ -0,0 +1,62 @@
const REPLAY_DOMAIN = "nekonest-cloud/device-registration-replay/v1";
export const DEVICE_REGISTRATION_REPLAY_TTL_MS = 10 * 60_000;
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function registrationReplayKey(retryKey: string): Promise<CryptoKey> {
const material = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(`${REPLAY_DOMAIN}\0${retryKey}`),
);
return crypto.subtle.importKey("raw", material, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
export async function encryptRegistrationReplay<T>(input: {
retryKey: string;
pairingId: string;
requestHash: string;
response: T;
}): Promise<{ ciphertext: string; nonce: string }> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
additionalData: new TextEncoder().encode(`${input.pairingId}\0${input.requestHash}`),
},
await registrationReplayKey(input.retryKey),
new TextEncoder().encode(JSON.stringify(input.response)),
);
return {
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
nonce: bytesToBase64(nonce),
};
}
export async function decryptRegistrationReplay<T>(input: {
retryKey: string;
pairingId: string;
requestHash: string;
ciphertext: string;
nonce: string;
}): Promise<T> {
const plaintext = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: Uint8Array.from(base64ToBytes(input.nonce)).buffer,
additionalData: new TextEncoder().encode(`${input.pairingId}\0${input.requestHash}`),
},
await registrationReplayKey(input.retryKey),
Uint8Array.from(base64ToBytes(input.ciphertext)).buffer,
);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
}
+226
View File
@@ -0,0 +1,226 @@
export const RELAY_AUTHORIZATION_SNAPSHOT_VERSION = 1 as const;
export const RELAY_AUTHORIZATION_MAX_TTL_SECONDS = 5 * 60;
export const RELAY_AUTHORIZATION_REFRESH_SECONDS = 60;
export const RELAY_AUTHORIZATION_DELTA_SECONDS = 15;
const SNAPSHOT_DOMAIN = "nekonest-cloud/relay-authorization-snapshot/v1\n";
export const RELAY_SIGNING_KEY_FOR_SNAPSHOT_SQL = `
SELECT kid, public_key_jwk, private_key_ref
FROM relay_signing_keys
WHERE status = 'active' AND not_before <= ?1 AND not_after >= ?2
ORDER BY not_before DESC, kid DESC LIMIT 1`;
export type AuthorizedDevice = {
device_id: string;
name: string;
os: "windows" | "linux";
ed25519_public: string;
x25519_public: string;
credential_hash: string;
identity_fingerprint: string;
};
export type AuthorizedPhone = {
phone_id: string;
name: string;
credential_hash: string;
ed25519_public: string;
x25519_public: string;
identity_fingerprint: string;
};
export type RelayAuthorizationSnapshotPayload = {
snapshot_version: typeof RELAY_AUTHORIZATION_SNAPSHOT_VERSION;
tenant_id: string;
tenant_status: "active" | "suspended";
home_region: string;
relay_node_id: string;
placement_generation: number;
authorization_revision: number;
devices: AuthorizedDevice[];
phones?: AuthorizedPhone[];
issued_at: string;
expires_at: string;
};
export type SignedRelayAuthorizationSnapshot = {
algorithm: "Ed25519";
kid: string;
payload: RelayAuthorizationSnapshotPayload;
signature: string;
};
export type SnapshotPlacementAdmission = {
relay_node_id: string | null;
generation: number;
tenant_status: "active" | "suspended";
placement_state: string;
};
export function classifySnapshotPlacement(input: {
placement: SnapshotPlacementAdmission | null;
nodeId: string;
expectedGeneration: number;
}): "ready" | "wrong_node" | "stale_generation" | "suspended" | "provisioning" {
const placement = input.placement;
if (!placement || placement.relay_node_id !== input.nodeId) return "wrong_node";
if (placement.generation !== input.expectedGeneration) return "stale_generation";
if (placement.tenant_status !== "active") return "suspended";
if (!["active", "draining"].includes(placement.placement_state)) return "provisioning";
return "ready";
}
function canonicalNumber(value: number): string {
if (!Number.isFinite(value)) throw new TypeError("non_finite_json_number");
return Object.is(value, -0) ? "0" : JSON.stringify(value);
}
/** RFC 8785-compatible for the JSON subset used by authorization snapshots. */
export function canonicalJson(value: unknown): string {
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") return canonicalNumber(value);
if (Array.isArray(value)) {
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
return `{${entries
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
.join(",")}}`;
}
throw new TypeError("unsupported_json_value");
}
function snapshotBytes(payload: RelayAuthorizationSnapshotPayload): Uint8Array {
return new TextEncoder().encode(`${SNAPSHOT_DOMAIN}${canonicalJson(payload)}`);
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
}
function base64UrlDecode(value: string): Uint8Array {
if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new TypeError("invalid_base64url");
const padding = "=".repeat((4 - (value.length % 4)) % 4);
const binary = atob(`${value.replaceAll("-", "+").replaceAll("_", "/")}${padding}`);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function assertSnapshotLifetime(
payload: RelayAuthorizationSnapshotPayload,
nowMs?: number,
): void {
const issuedAt = Date.parse(payload.issued_at);
const expiresAt = Date.parse(payload.expires_at);
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) {
throw new RangeError("invalid_snapshot_time");
}
if (expiresAt <= issuedAt) throw new RangeError("invalid_snapshot_lifetime");
if (expiresAt - issuedAt > RELAY_AUTHORIZATION_MAX_TTL_SECONDS * 1_000) {
throw new RangeError("snapshot_ttl_exceeds_maximum");
}
if (nowMs !== undefined && (nowMs < issuedAt - 30_000 || nowMs >= expiresAt)) {
throw new RangeError("snapshot_not_current");
}
}
function assertSnapshotShape(payload: RelayAuthorizationSnapshotPayload): void {
if (payload.snapshot_version !== RELAY_AUTHORIZATION_SNAPSHOT_VERSION) {
throw new RangeError("unsupported_snapshot_version");
}
if (!/^tenant_[0-9a-f]{32}$/u.test(payload.tenant_id)) {
throw new TypeError("invalid_snapshot_tenant");
}
if (!/^node_[A-Za-z0-9._:-]{1,96}$/u.test(payload.relay_node_id)) {
throw new TypeError("invalid_snapshot_node");
}
if (!Number.isSafeInteger(payload.placement_generation) || payload.placement_generation < 1) {
throw new RangeError("invalid_placement_generation");
}
if (!Number.isSafeInteger(payload.authorization_revision) || payload.authorization_revision < 0) {
throw new RangeError("invalid_authorization_revision");
}
for (const device of payload.devices) {
if (typeof device.name !== "string" || device.name.length < 1 || device.name.length > 48) {
throw new TypeError("invalid_snapshot_device_name");
}
if (device.os !== "windows" && device.os !== "linux") {
throw new TypeError("invalid_snapshot_device_os");
}
if (!/^[A-Za-z0-9_-]{43}$/u.test(device.ed25519_public)
|| !/^[A-Za-z0-9_-]{43}$/u.test(device.x25519_public)) {
throw new TypeError("invalid_snapshot_device_public_key");
}
}
const ordered = [...payload.devices].sort((left, right) =>
left.device_id < right.device_id ? -1 : left.device_id > right.device_id ? 1 : 0,
);
if (ordered.some((device, index) => device !== payload.devices[index])) {
throw new TypeError("snapshot_devices_not_ordered");
}
const phones = payload.phones ?? [];
for (const phone of phones) {
if (!/^phone_[A-Za-z0-9._:-]{1,120}$/u.test(phone.phone_id)
|| typeof phone.name !== "string" || phone.name.length < 1 || phone.name.length > 48
|| !/^[0-9a-f]{64}$/u.test(phone.credential_hash)
|| !/^[A-Za-z0-9_-]{43}$/u.test(phone.ed25519_public)
|| !/^[A-Za-z0-9_-]{43}$/u.test(phone.x25519_public)
|| !/^[0-9a-f]{64}$/u.test(phone.identity_fingerprint)) {
throw new TypeError("invalid_snapshot_phone");
}
}
const orderedPhones = [...phones].sort((left, right) =>
left.phone_id < right.phone_id ? -1 : left.phone_id > right.phone_id ? 1 : 0,
);
if (orderedPhones.some((phone, index) => phone !== phones[index])) {
throw new TypeError("snapshot_phones_not_ordered");
}
assertSnapshotLifetime(payload);
}
export async function signRelayAuthorizationSnapshot(input: {
kid: string;
privateKey: CryptoKey;
payload: RelayAuthorizationSnapshotPayload;
}): Promise<SignedRelayAuthorizationSnapshot> {
if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(input.kid)) throw new TypeError("invalid_snapshot_kid");
assertSnapshotShape(input.payload);
const signature = await crypto.subtle.sign(
{ name: "Ed25519" },
input.privateKey,
Uint8Array.from(snapshotBytes(input.payload)).buffer,
);
return {
algorithm: "Ed25519",
kid: input.kid,
payload: input.payload,
signature: base64UrlEncode(new Uint8Array(signature)),
};
}
export async function verifyRelayAuthorizationSnapshot(input: {
snapshot: SignedRelayAuthorizationSnapshot;
publicKey: CryptoKey;
nowMs?: number;
}): Promise<boolean> {
try {
if (input.snapshot.algorithm !== "Ed25519") return false;
assertSnapshotShape(input.snapshot.payload);
assertSnapshotLifetime(input.snapshot.payload, input.nowMs ?? Date.now());
return crypto.subtle.verify(
{ name: "Ed25519" },
input.publicKey,
Uint8Array.from(base64UrlDecode(input.snapshot.signature)).buffer,
Uint8Array.from(snapshotBytes(input.snapshot.payload)).buffer,
);
} catch {
return false;
}
}
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
export const ADVANCE_AUTHORIZATION_AFTER_CLAIM_SQL = `
UPDATE tenant_authorization_state
SET revision = revision + 1, updated_at = ?1
WHERE tenant_id = (SELECT id FROM tenant_instances WHERE account_id = ?2)
AND EXISTS (
SELECT 1 FROM device_credentials
WHERE id = ?3 AND host_id = ?4 AND token_hash = ?5 AND status = 'active'
)
`;
export const ADVANCE_AUTHORIZATION_AFTER_REVOKE_SQL = `
UPDATE tenant_authorization_state
SET revision = revision + 1, updated_at = ?1
WHERE tenant_id = (SELECT id FROM tenant_instances WHERE account_id = ?2)
AND EXISTS (
SELECT 1 FROM hosts
WHERE id = ?3 AND lifecycle = 'deactivated' AND deactivated_at = ?4
)
`;
export const CONSUME_PHONE_HANDOFF_SQL = `
UPDATE phone_handoff_tickets
SET consumed_at = ?1, consumed_by_node_id = ?5,
pending_phone_name = ?6, pending_ed25519_public = ?7,
pending_x25519_public = ?8, pending_identity_fingerprint = ?9
WHERE id = ?2 AND ticket_hash = ?3 AND expected_origin = ?4
AND consumed_at IS NULL AND expires_at > ?1
`;
export const DELETE_SUPERSEDED_PENDING_PHONE_ROUTES_SQL = `
DELETE FROM phone_route_handles
WHERE status = 'pending'
AND EXISTS (
SELECT 1
FROM phone_handoff_tickets AS previous
INNER JOIN phone_handoff_tickets AS current ON current.id = ?1
WHERE previous.id != current.id
AND previous.tenant_id = current.tenant_id
AND previous.pending_identity_fingerprint = current.pending_identity_fingerprint
AND previous.completed_phone_id = phone_route_handles.phone_id
AND previous.completed_route_handle_hash = phone_route_handles.handle_hash
AND previous.expires_at <= ?2
AND previous.activation_nonce IS NULL
)
`;
export const DELETE_SUPERSEDED_PENDING_PHONE_PRINCIPALS_SQL = `
DELETE FROM relay_phone_principals
WHERE status = 'pending'
AND EXISTS (
SELECT 1
FROM phone_handoff_tickets AS previous
INNER JOIN phone_handoff_tickets AS current ON current.id = ?1
WHERE previous.id != current.id
AND previous.tenant_id = current.tenant_id
AND previous.pending_identity_fingerprint = current.pending_identity_fingerprint
AND previous.completed_phone_id = relay_phone_principals.phone_id
AND previous.completed_phone_token_hash = relay_phone_principals.token_hash
AND previous.expires_at <= ?2
AND previous.activation_nonce IS NULL
)
`;
export const CLAIM_PHONE_HANDOFF_ACTIVATION_SQL = `
UPDATE phone_handoff_tickets
SET activation_nonce = ?1
WHERE completed_route_handle_hash = ?2
AND completed_phone_token_hash = ?3
AND completed_at > ?4
AND consumed_by_node_id = ?5
AND activation_nonce IS NULL
AND EXISTS (
SELECT 1
FROM phone_route_handles AS handles
INNER JOIN relay_phone_principals AS phones ON phones.phone_id = handles.phone_id
INNER JOIN tenant_placements AS placements ON placements.tenant_id = handles.tenant_id
INNER JOIN tenant_authorization_state AS authorizations ON authorizations.tenant_id = handles.tenant_id
WHERE handles.handle_hash = ?2 AND handles.status = 'pending'
AND handles.revoked_at IS NULL AND phones.token_hash = ?3
AND phones.status = 'pending' AND phones.revoked_at IS NULL
AND phones.tenant_id = phone_handoff_tickets.tenant_id
AND phones.phone_id = phone_handoff_tickets.completed_phone_id
AND handles.tenant_id = phone_handoff_tickets.tenant_id
AND placements.relay_node_id = ?5
AND placements.state IN ('active', 'draining')
AND authorizations.status = 'active'
)
`;
export const ACTIVATE_PHONE_PRINCIPAL_SQL = `
UPDATE relay_phone_principals
SET status = 'active'
WHERE token_hash = ?2 AND status = 'pending' AND revoked_at IS NULL
AND EXISTS (
SELECT 1 FROM phone_handoff_tickets AS tickets
WHERE tickets.activation_nonce = ?1
AND tickets.completed_phone_id = relay_phone_principals.phone_id
AND tickets.completed_phone_token_hash = ?2
AND tickets.tenant_id = relay_phone_principals.tenant_id
)
`;
export const ACTIVATE_PHONE_ROUTE_SQL = `
UPDATE phone_route_handles
SET status = 'active'
WHERE handle_hash = ?2 AND status = 'pending' AND revoked_at IS NULL
AND EXISTS (
SELECT 1 FROM phone_handoff_tickets AS tickets
WHERE tickets.activation_nonce = ?1
AND tickets.completed_route_handle_hash = ?2
AND tickets.completed_phone_token_hash = ?3
AND tickets.completed_phone_id = phone_route_handles.phone_id
AND tickets.tenant_id = phone_route_handles.tenant_id
)
AND EXISTS (
SELECT 1 FROM relay_phone_principals AS phones
WHERE phones.phone_id = phone_route_handles.phone_id
AND phones.tenant_id = phone_route_handles.tenant_id
AND phones.token_hash = ?3
AND phones.status = 'active' AND phones.revoked_at IS NULL
)
`;
export const ADVANCE_AUTHORIZATION_AFTER_PHONE_ACTIVATION_SQL = `
UPDATE tenant_authorization_state
SET revision = revision + 1, updated_at = ?1
WHERE status = 'active'
AND tenant_id = (
SELECT tenant_id FROM phone_handoff_tickets
WHERE activation_nonce = ?2
)
AND EXISTS (
SELECT 1
FROM phone_handoff_tickets AS tickets
INNER JOIN relay_phone_principals AS phones
ON phones.phone_id = tickets.completed_phone_id
AND phones.tenant_id = tickets.tenant_id
INNER JOIN phone_route_handles AS handles
ON handles.phone_id = tickets.completed_phone_id
AND handles.tenant_id = tickets.tenant_id
AND handles.handle_hash = tickets.completed_route_handle_hash
WHERE tickets.activation_nonce = ?2
AND phones.status = 'active' AND phones.revoked_at IS NULL
AND handles.status = 'active' AND handles.revoked_at IS NULL
AND phones.token_hash = tickets.completed_phone_token_hash
)
`;
export const FINALIZE_PHONE_HANDOFF_ACTIVATION_SQL = `
UPDATE phone_handoff_tickets
SET activated_at = ?1
WHERE activation_nonce = ?2 AND activated_at IS NULL
AND EXISTS (
SELECT 1 FROM relay_phone_principals AS phones
WHERE phones.phone_id = phone_handoff_tickets.completed_phone_id
AND phones.tenant_id = phone_handoff_tickets.tenant_id
AND phones.token_hash = phone_handoff_tickets.completed_phone_token_hash
AND phones.status = 'active' AND phones.revoked_at IS NULL
)
AND EXISTS (
SELECT 1 FROM phone_route_handles AS handles
WHERE handles.phone_id = phone_handoff_tickets.completed_phone_id
AND handles.tenant_id = phone_handoff_tickets.tenant_id
AND handles.handle_hash = phone_handoff_tickets.completed_route_handle_hash
AND handles.status = 'active' AND handles.revoked_at IS NULL
)
`;
export const AUTHORIZE_PHONE_ROUTE_SQL = `
SELECT handles.tenant_id, regions.code AS home_region,
placements.relay_node_id, placements.generation,
phones.phone_id, phones.name, phones.ed25519_public,
phones.x25519_public, phones.identity_fingerprint
FROM phone_route_handles AS handles
INNER JOIN relay_phone_principals AS phones ON phones.phone_id = handles.phone_id
INNER JOIN tenant_placements AS placements ON placements.tenant_id = handles.tenant_id
INNER JOIN relay_regions AS regions ON regions.id = placements.home_region_id
INNER JOIN tenant_authorization_state AS authorizations ON authorizations.tenant_id = handles.tenant_id
WHERE handles.handle_hash = ?1 AND handles.status = 'active'
AND handles.revoked_at IS NULL AND phones.token_hash = ?2
AND phones.status = 'active' AND phones.revoked_at IS NULL
AND phones.tenant_id = handles.tenant_id
AND authorizations.status = 'active'
AND placements.state IN ('active', 'draining')
`;
export const PHONE_FOR_NODE_REVOCATION_SQL = `
SELECT placements.relay_node_id, phones.status
FROM tenant_placements AS placements
INNER JOIN relay_phone_principals AS phones
ON phones.tenant_id = placements.tenant_id
WHERE placements.tenant_id = ?1 AND phones.phone_id = ?2`;
export const REVOKE_PHONE_PRINCIPAL_SQL = `
UPDATE relay_phone_principals
SET status = 'revoked', revoked_at = ?1
WHERE phone_id = ?2 AND tenant_id = ?3
AND status = 'active' AND revoked_at IS NULL`;
export const REVOKE_PHONE_ROUTES_SQL = `
UPDATE phone_route_handles
SET status = 'revoked', revoked_at = ?1
WHERE phone_id = ?2 AND tenant_id = ?3
AND status = 'active' AND revoked_at IS NULL`;
export const ADVANCE_AUTHORIZATION_AFTER_PHONE_REVOKE_SQL = `
UPDATE tenant_authorization_state
SET revision = revision + 1, updated_at = ?1
WHERE tenant_id = ?2
AND EXISTS (
SELECT 1 FROM relay_phone_principals
WHERE phone_id = ?3 AND tenant_id = ?2
AND status = 'revoked' AND revoked_at = ?1
)`;
+17
View File
@@ -0,0 +1,17 @@
export type MigrationFailureFence = {
state: string;
source_node_id: string;
target_node_id: string;
source_generation: number;
target_generation: number;
};
export function authorityAfterMigrationFailure(record: MigrationFailureFence): {
nodeId: string;
generation: number;
} {
if (record.state === "draining") {
return { nodeId: record.target_node_id, generation: record.target_generation };
}
return { nodeId: record.source_node_id, generation: record.source_generation };
}
+380
View File
@@ -0,0 +1,380 @@
import { ensureDatabase, getD1 } from "./bootstrap";
import { type RelayNodePrincipal } from "./relay-control-plane";
import { DomainError } from "./repository";
import { authorityAfterMigrationFailure } from "./relay-migration-state";
export type RelayMigrationState =
| "quiescing"
| "copying"
| "switching"
| "draining"
| "completed"
| "failed";
type MigrationRecord = {
id: string;
tenant_id: string;
source_node_id: string;
target_node_id: string;
source_generation: number;
target_generation: number;
state: RelayMigrationState;
backup_ref: string | null;
manifest_sha256: string | null;
requested_by: string;
reason: string;
started_at: string;
updated_at: string;
switched_at: string | null;
completed_at: string | null;
last_error_code: string | null;
};
export type RelayMigrationAssignment = {
migration_id: string;
tenant_id: string;
role: "source" | "target";
source_node_id: string;
target_node_id: string;
source_generation: number;
target_generation: number;
state: "quiescing" | "copying" | "switching" | "draining";
backup_ref?: string;
manifest_sha256?: string;
finalize_after?: string;
};
function changed(result: D1Result<unknown>): boolean {
return Number(result.meta.changes ?? 0) === 1;
}
function validTenantId(value: string): boolean {
return /^tenant_[0-9a-f]{32}$/u.test(value);
}
function validNodeId(value: string): boolean {
return /^node_[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/u.test(value);
}
function validMigrationId(value: string): boolean {
return /^migration_[0-9a-f]{32}$/u.test(value);
}
function validBackupRef(value: string): boolean {
return /^[0-9a-f]{32}\/g[0-9]{20}-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{16}$/u.test(value);
}
function validHash(value: string): boolean {
return /^[0-9a-f]{64}$/u.test(value);
}
function transitionAudit(
db: D1Database,
record: MigrationRecord,
actorId: string,
action: string,
expectedState: RelayMigrationState,
now: string,
): D1PreparedStatement {
return db.prepare(
`INSERT INTO audit_events
(id, actor_id, action, target_type, target_id, reason, created_at)
SELECT ?1, ?2, ?3, 'relay_migration', ?4, ?5, ?6
WHERE EXISTS (
SELECT 1 FROM relay_migrations WHERE id = ?4 AND state = ?7
)`,
).bind(
`audit_${crypto.randomUUID().replaceAll("-", "")}`,
actorId,
action,
record.id,
`migration ${record.tenant_id} ${expectedState}`,
now,
expectedState,
);
}
async function deterministicMigrationId(actorId: string, idempotencyKey: string): Promise<string> {
if (!/^[A-Za-z0-9._:-]{8,128}$/u.test(idempotencyKey)) {
throw new DomainError("invalid_idempotency_key", "迁移幂等键无效");
}
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(`nekonest-cloud/relay-migration/v1\0${actorId}\0${idempotencyKey}`),
);
return `migration_${Array.from(new Uint8Array(digest).slice(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
export async function beginRelayMigration(input: {
tenantId: string;
targetNodeId: string;
actorId: string;
reason: string;
idempotencyKey: string;
}): Promise<MigrationRecord> {
await ensureDatabase();
const tenantId = input.tenantId.trim();
const targetNodeId = input.targetNodeId.trim();
const actorId = input.actorId.trim();
const reason = input.reason.trim();
if (!validTenantId(tenantId) || !validNodeId(targetNodeId) || !actorId || reason.length < 8 || reason.length > 500) {
throw new DomainError("invalid_relay_migration", "迁移租户、目标节点或原因无效");
}
const migrationId = await deterministicMigrationId(actorId, input.idempotencyKey.trim());
const db = getD1();
const existing = await db
.prepare("SELECT * FROM relay_migrations WHERE id = ?")
.bind(migrationId)
.first<MigrationRecord>();
if (existing) {
if (
existing.tenant_id !== tenantId ||
existing.target_node_id !== targetNodeId ||
existing.requested_by !== actorId ||
existing.reason !== reason
) {
throw new DomainError("idempotency_conflict", "迁移幂等键已用于不同请求", 409);
}
return existing;
}
const now = new Date().toISOString();
const results = await db.batch([
db
.prepare(
`INSERT INTO relay_migrations
(id, tenant_id, source_node_id, target_node_id,
source_generation, target_generation, state,
requested_by, reason, started_at, updated_at)
SELECT ?1, placements.tenant_id, placements.relay_node_id, targets.id,
placements.generation, placements.generation + 1, 'quiescing',
?4, ?5, ?6, ?6
FROM tenant_placements AS placements
INNER JOIN relay_nodes AS sources ON sources.id = placements.relay_node_id
INNER JOIN relay_nodes AS targets ON targets.id = ?3
WHERE placements.tenant_id = ?2 AND placements.state = 'active'
AND placements.relay_node_id IS NOT NULL
AND sources.status IN ('active', 'draining') AND targets.status = 'active'
AND targets.id <> placements.relay_node_id
AND NOT EXISTS (
SELECT 1 FROM relay_migrations AS active
WHERE active.tenant_id = placements.tenant_id
AND active.state IN ('quiescing', 'copying', 'switching', 'draining')
)
AND (
targets.capacity_tenants = 0 OR
(SELECT COUNT(*) FROM tenant_placements AS occupied
WHERE occupied.relay_node_id = targets.id
AND occupied.state IN ('active', 'draining')) < targets.capacity_tenants
)`,
)
.bind(migrationId, tenantId, targetNodeId, actorId, reason, now),
db
.prepare(
`UPDATE tenant_placements
SET state = 'quiescing', last_error_code = NULL, updated_at = ?1
WHERE tenant_id = ?2 AND state = 'active'
AND EXISTS (
SELECT 1 FROM relay_migrations
WHERE id = ?3 AND tenant_id = ?2
AND source_node_id = tenant_placements.relay_node_id
AND source_generation = tenant_placements.generation
AND state = 'quiescing'
)`,
)
.bind(now, tenantId, migrationId),
db
.prepare(
`INSERT INTO audit_events
(id, actor_id, action, target_type, target_id, reason, created_at)
SELECT ?1, ?2, 'relay.migration.started', 'relay_migration', ?3, ?4, ?5
WHERE EXISTS (
SELECT 1 FROM relay_migrations
WHERE id = ?3 AND tenant_id = ?6 AND state = 'quiescing'
)`,
)
.bind(`audit_${crypto.randomUUID().replaceAll("-", "")}`, actorId, migrationId, reason, now, tenantId),
]);
if (!changed(results[0]) || !changed(results[1])) {
const raced = await db
.prepare("SELECT * FROM relay_migrations WHERE id = ?")
.bind(migrationId)
.first<MigrationRecord>();
if (raced) return raced;
throw new DomainError("relay_migration_conflict", "租户当前不可迁移或目标节点容量不足", 409);
}
const created = await db
.prepare("SELECT * FROM relay_migrations WHERE id = ?")
.bind(migrationId)
.first<MigrationRecord>();
if (!created) throw new DomainError("relay_migration_indeterminate", "迁移创建结果不确定", 503, true, 5);
return created;
}
export async function relayMigrationAssignments(
principal: RelayNodePrincipal,
): Promise<RelayMigrationAssignment[]> {
await ensureDatabase();
const rows = await getD1()
.prepare(
`SELECT * FROM relay_migrations
WHERE state IN ('quiescing', 'copying', 'switching', 'draining')
AND ((state = 'quiescing' AND source_node_id = ?1)
OR (state IN ('copying', 'switching', 'draining') AND target_node_id = ?1))
ORDER BY started_at ASC, id ASC
LIMIT 8`,
)
.bind(principal.nodeId)
.all<MigrationRecord>();
return rows.results.map((row) => {
const assignment: RelayMigrationAssignment = {
migration_id: row.id,
tenant_id: row.tenant_id,
role: row.state === "quiescing" ? "source" : "target",
source_node_id: row.source_node_id,
target_node_id: row.target_node_id,
source_generation: row.source_generation,
target_generation: row.target_generation,
state: row.state as RelayMigrationAssignment["state"],
};
if (row.backup_ref) assignment.backup_ref = row.backup_ref;
if (row.manifest_sha256) assignment.manifest_sha256 = row.manifest_sha256;
if (row.state === "draining" && row.switched_at) {
assignment.finalize_after = new Date(new Date(row.switched_at).getTime() + 5 * 60_000).toISOString();
}
return assignment;
});
}
export async function advanceRelayMigration(input: {
principal: RelayNodePrincipal;
migrationId: string;
action: "quiesced" | "copied" | "switched" | "finalized" | "failed";
backupRef?: string;
manifestSha256?: string;
errorCode?: string;
}): Promise<MigrationRecord> {
await ensureDatabase();
const migrationId = input.migrationId.trim();
if (!validMigrationId(migrationId)) {
throw new DomainError("invalid_relay_migration", "迁移 ID 无效");
}
const db = getD1();
const record = await db
.prepare("SELECT * FROM relay_migrations WHERE id = ?")
.bind(migrationId)
.first<MigrationRecord>();
if (!record) throw new DomainError("relay_migration_not_found", "迁移不存在", 404);
const sourceAction = input.action === "quiesced";
const expectedNode = sourceAction ? record.source_node_id : record.target_node_id;
if (input.action === "failed") {
if (![record.source_node_id, record.target_node_id].includes(input.principal.nodeId)) {
throw new DomainError("relay_migration_forbidden", "节点不属于该迁移", 403);
}
} else if (input.principal.nodeId !== expectedNode) {
throw new DomainError("relay_migration_forbidden", "节点不能推进该迁移阶段", 403);
}
const transitions = {
quiesced: ["quiescing", "copying"],
copied: ["copying", "switching"],
switched: ["switching", "draining"],
finalized: ["draining", "completed"],
} as const;
if (input.action !== "failed" && record.state === transitions[input.action][1]) return record;
if (input.action !== "failed" && record.state !== transitions[input.action][0]) {
throw new DomainError("relay_migration_fence_conflict", "迁移阶段已经变化", 409);
}
const now = new Date().toISOString();
if (input.action === "quiesced") {
const backupRef = input.backupRef?.trim() ?? "";
const manifestSha256 = input.manifestSha256?.trim().toLowerCase() ?? "";
if (!validBackupRef(backupRef) || !validHash(manifestSha256)) {
throw new DomainError("invalid_relay_backup", "迁移备份引用或摘要无效");
}
const results = await db.batch([
db.prepare(
`UPDATE relay_migrations SET state = 'copying', backup_ref = ?1,
manifest_sha256 = ?2, updated_at = ?3
WHERE id = ?4 AND state = 'quiescing' AND source_node_id = ?5`,
).bind(backupRef, manifestSha256, now, migrationId, input.principal.nodeId),
db.prepare(
`UPDATE tenant_placements SET state = 'copying', updated_at = ?1
WHERE tenant_id = ?2 AND relay_node_id = ?3 AND generation = ?4 AND state = 'quiescing'`,
).bind(now, record.tenant_id, record.source_node_id, record.source_generation),
transitionAudit(db, record, input.principal.nodeId, "relay.migration.backup_ready", "copying", now),
]);
if (!changed(results[0]) || !changed(results[1])) throw new DomainError("relay_migration_fence_conflict", "迁移 quiesce 栅栏冲突", 409);
} else if (input.action === "copied") {
if (input.backupRef !== record.backup_ref || input.manifestSha256?.toLowerCase() !== record.manifest_sha256) {
throw new DomainError("relay_backup_mismatch", "目标节点恢复的备份与控制面不一致", 409);
}
const results = await db.batch([
db.prepare(
`UPDATE relay_migrations SET state = 'switching', updated_at = ?1
WHERE id = ?2 AND state = 'copying' AND target_node_id = ?3`,
).bind(now, migrationId, input.principal.nodeId),
db.prepare(
`UPDATE tenant_placements SET state = 'switching', updated_at = ?1
WHERE tenant_id = ?2 AND relay_node_id = ?3 AND generation = ?4 AND state = 'copying'`,
).bind(now, record.tenant_id, record.source_node_id, record.source_generation),
transitionAudit(db, record, input.principal.nodeId, "relay.migration.copy_verified", "switching", now),
]);
if (!changed(results[0]) || !changed(results[1])) throw new DomainError("relay_migration_fence_conflict", "迁移 copy 栅栏冲突", 409);
} else if (input.action === "switched") {
const results = await db.batch([
db.prepare(
`UPDATE relay_migrations SET state = 'draining', switched_at = ?1, updated_at = ?1
WHERE id = ?2 AND state = 'switching' AND target_node_id = ?3`,
).bind(now, migrationId, input.principal.nodeId),
db.prepare(
`UPDATE tenant_placements
SET relay_node_id = ?1, generation = ?2, state = 'draining', updated_at = ?3
WHERE tenant_id = ?4 AND relay_node_id = ?5 AND generation = ?6 AND state = 'switching'`,
).bind(record.target_node_id, record.target_generation, now, record.tenant_id, record.source_node_id, record.source_generation),
transitionAudit(db, record, input.principal.nodeId, "relay.migration.switched", "draining", now),
]);
if (!changed(results[0]) || !changed(results[1])) throw new DomainError("relay_migration_fence_conflict", "迁移 switch 栅栏冲突", 409);
} else if (input.action === "finalized") {
if (!record.switched_at || Date.now() < new Date(record.switched_at).getTime() + 5 * 60_000) {
throw new DomainError("relay_migration_drain_pending", "旧 generation 尚在排空窗口", 409, true, 5);
}
const results = await db.batch([
db.prepare(
`UPDATE relay_migrations SET state = 'completed', completed_at = ?1, updated_at = ?1
WHERE id = ?2 AND state = 'draining' AND target_node_id = ?3`,
).bind(now, migrationId, input.principal.nodeId),
db.prepare(
`UPDATE tenant_placements SET state = 'active', updated_at = ?1
WHERE tenant_id = ?2 AND relay_node_id = ?3 AND generation = ?4 AND state = 'draining'`,
).bind(now, record.tenant_id, record.target_node_id, record.target_generation),
transitionAudit(db, record, input.principal.nodeId, "relay.migration.completed", "completed", now),
]);
if (!changed(results[0]) || !changed(results[1])) throw new DomainError("relay_migration_fence_conflict", "迁移 finalize 栅栏冲突", 409);
} else {
const errorCode = input.errorCode?.trim().toLowerCase() ?? "relay_migration_failed";
if (!/^[a-z][a-z0-9_]{2,63}$/u.test(errorCode) || ["completed", "failed"].includes(record.state)) {
throw new DomainError("invalid_relay_migration_failure", "迁移失败码或阶段无效");
}
const authority = authorityAfterMigrationFailure(record);
const results = await db.batch([
db.prepare(
`UPDATE relay_migrations SET state = 'failed', last_error_code = ?1,
completed_at = ?2, updated_at = ?2
WHERE id = ?3 AND state = ?4`,
).bind(errorCode, now, migrationId, record.state),
db.prepare(
`UPDATE tenant_placements
SET relay_node_id = ?1, generation = ?2, state = 'active',
last_error_code = ?3, updated_at = ?4
WHERE tenant_id = ?5 AND relay_node_id = ?6 AND generation = ?7 AND state = ?8`,
).bind(authority.nodeId, authority.generation, errorCode, now, record.tenant_id, authority.nodeId, authority.generation, record.state),
transitionAudit(db, record, input.principal.nodeId, "relay.migration.failed", "failed", now),
]);
if (!changed(results[0]) || !changed(results[1])) throw new DomainError("relay_migration_fence_conflict", "迁移回滚栅栏冲突", 409);
}
const updated = await db
.prepare("SELECT * FROM relay_migrations WHERE id = ?")
.bind(migrationId)
.first<MigrationRecord>();
if (!updated) throw new DomainError("relay_migration_indeterminate", "迁移状态不确定", 503, true, 5);
return updated;
}
+162
View File
@@ -0,0 +1,162 @@
const ASSERTION_DOMAIN = "nekonest-cloud/relay-mtls-identity/v1";
const MAX_ASSERTION_SKEW_SECONDS = 30;
export type TrustedRelayMtlsIdentity = {
nodeId: string;
spiffeId: string;
certificateFingerprintSha256: string;
};
export const AUTHENTICATE_RELAY_NODE_IDENTITY_SQL = `
UPDATE relay_node_credentials
SET last_used_at = ?1
WHERE node_id = ?2
AND mtls_spiffe_id = ?3
AND certificate_fingerprint_sha256 = ?4
AND status = 'active'
AND issued_at <= ?1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > ?1)
AND EXISTS (
SELECT 1 FROM relay_nodes
WHERE id = ?2 AND status IN ('active', 'draining')
)`;
export class RelayMtlsIdentityError extends Error {
readonly code: "relay_mtls_unavailable" | "relay_mtls_identity_invalid";
readonly status: 401 | 503;
constructor(
code: "relay_mtls_unavailable" | "relay_mtls_identity_invalid",
message: string,
status: 401 | 503,
) {
super(message);
this.code = code;
this.status = status;
}
}
function assertionTranscript(input: {
method: string;
pathname: string;
nodeId: string;
spiffeId: string;
certificateFingerprintSha256: string;
timestampSeconds: number;
}): Uint8Array {
return new TextEncoder().encode([
ASSERTION_DOMAIN,
input.method.toUpperCase(),
input.pathname,
input.nodeId,
input.spiffeId,
input.certificateFingerprintSha256,
String(input.timestampSeconds),
].join("\0"));
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function equalHex(left: string, right: string): boolean {
let difference = left.length ^ right.length;
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
}
return difference === 0;
}
export async function createTrustedRelayMtlsAssertion(input: {
assertionSecret: string;
method: string;
pathname: string;
nodeId: string;
spiffeId: string;
certificateFingerprintSha256: string;
timestampSeconds: number;
}): Promise<string> {
if (input.assertionSecret.length < 32) throw new TypeError("relay_mtls_assertion_secret_too_short");
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(input.assertionSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
Uint8Array.from(assertionTranscript(input)).buffer,
);
return bytesToHex(new Uint8Array(signature));
}
/**
* Workers cannot inspect a client certificate directly. The production mTLS
* terminator must strip all x-neko-mtls-* headers from untrusted traffic,
* verify the certificate, and inject this short-lived HMAC assertion.
*/
export async function verifyTrustedRelayMtlsIdentity(input: {
request: Request;
assertionSecret: string;
nowMs?: number;
}): Promise<TrustedRelayMtlsIdentity> {
if (input.assertionSecret.length < 32) {
throw new RelayMtlsIdentityError(
"relay_mtls_unavailable",
"Relay mTLS ingress assertion is not configured",
503,
);
}
const headers = input.request.headers;
const nodeId = headers.get("x-neko-relay-node-id")?.trim() ?? "";
const spiffeId = headers.get("x-neko-mtls-spiffe-id")?.trim() ?? "";
const certificateFingerprintSha256 =
headers.get("x-neko-mtls-cert-sha256")?.trim().toLowerCase() ?? "";
const verified = headers.get("x-neko-mtls-verified")?.trim() ?? "";
const timestampRaw = headers.get("x-neko-mtls-timestamp")?.trim() ?? "";
const assertion = headers.get("x-neko-mtls-assertion")?.trim().toLowerCase() ?? "";
const timestampSeconds = Number(timestampRaw);
if (
verified !== "SUCCESS" ||
!/^node_[A-Za-z0-9._:-]{1,96}$/u.test(nodeId) ||
!/^spiffe:\/\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{3,240}$/u.test(spiffeId) ||
!/^[0-9a-f]{64}$/u.test(certificateFingerprintSha256) ||
!/^[0-9a-f]{64}$/u.test(assertion) ||
!Number.isSafeInteger(timestampSeconds)
) {
throw new RelayMtlsIdentityError(
"relay_mtls_identity_invalid",
"Relay mTLS identity is invalid",
401,
);
}
const nowSeconds = Math.floor((input.nowMs ?? Date.now()) / 1_000);
if (Math.abs(nowSeconds - timestampSeconds) > MAX_ASSERTION_SKEW_SECONDS) {
throw new RelayMtlsIdentityError(
"relay_mtls_identity_invalid",
"Relay mTLS assertion has expired",
401,
);
}
const expected = await createTrustedRelayMtlsAssertion({
assertionSecret: input.assertionSecret,
method: input.request.method,
pathname: new URL(input.request.url).pathname,
nodeId,
spiffeId,
certificateFingerprintSha256,
timestampSeconds,
});
if (!equalHex(assertion, expected)) {
throw new RelayMtlsIdentityError(
"relay_mtls_identity_invalid",
"Relay mTLS assertion is invalid",
401,
);
}
return { nodeId, spiffeId, certificateFingerprintSha256 };
}
+68
View File
@@ -0,0 +1,68 @@
export function relayPurgeCompletionAuditId(purgeId: string): string {
return `audit_${purgeId.slice("purge_".length)}`;
}
export function relayPurgeRecordCanReturn(
state: string,
completedProofValid: boolean,
): boolean {
return state !== "completed" || completedProofValid;
}
export const COMPLETED_RELAY_PURGE_PROOF_SQL = `
SELECT EXISTS (
SELECT 1 FROM relay_purge_jobs AS jobs
INNER JOIN audit_events AS audits
ON audits.id = ?2
AND audits.actor_id = jobs.relay_node_id
AND audits.action = 'relay.purge.completed'
AND audits.target_type = 'relay_purge'
AND audits.target_id = jobs.id
AND json_extract(audits.after_json, '$.evidence_sha256') = jobs.evidence_sha256
WHERE jobs.id = ?1 AND jobs.state = 'completed'
AND jobs.evidence_sha256 = ?3
AND EXISTS (
SELECT 1 FROM tenant_authorization_state
WHERE tenant_id = jobs.tenant_id AND status = 'deleted'
)
AND EXISTS (
SELECT 1 FROM tenant_placements
WHERE tenant_id = jobs.tenant_id AND relay_node_id IS NULL
AND generation = jobs.placement_generation + 1 AND state = 'deleted'
)
AND EXISTS (
SELECT 1 FROM tenant_instances
WHERE id = jobs.tenant_id AND lifecycle = 'deleted'
AND desired_state = 'deleted' AND observed_state = 'deleted'
)
AND EXISTS (
SELECT 1 FROM account_deletion_requests
WHERE id = jobs.deletion_request_id AND status = 'relay_purged'
)
AND EXISTS (
SELECT 1 FROM accounts
WHERE id = (
SELECT account_id FROM account_deletion_requests
WHERE id = jobs.deletion_request_id
) AND status = 'relay_purged'
)
AND NOT EXISTS (
SELECT 1 FROM device_credentials
WHERE host_id IN (
SELECT hosts.id FROM hosts
INNER JOIN tenant_instances AS tenants ON tenants.account_id = hosts.account_id
WHERE tenants.id = jobs.tenant_id
)
)
AND NOT EXISTS (SELECT 1 FROM phone_route_handles WHERE tenant_id = jobs.tenant_id)
AND NOT EXISTS (SELECT 1 FROM relay_phone_principals WHERE tenant_id = jobs.tenant_id)
AND NOT EXISTS (SELECT 1 FROM phone_handoff_tickets WHERE tenant_id = jobs.tenant_id)
AND NOT EXISTS (
SELECT 1 FROM hosts
WHERE account_id = (
SELECT account_id FROM tenant_instances WHERE id = jobs.tenant_id
)
AND (COALESCE(lifecycle, '') != 'deactivated'
OR COALESCE(slot_state, '') != 'released')
)
) AS proof_valid`;

Some files were not shown because too many files have changed in this diff Show More