feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"d1": "DB",
|
||||||
|
"r2": null
|
||||||
|
}
|
||||||
@@ -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 NekoNest’s per-device E2E pairing and receive that device’s 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.
|
||||||
@@ -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>}</>; }
|
||||||
@@ -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>批准申请会在同一数据库动作中签发 1–3 台、有期限的非货币邀请;拒绝不会创建权益。申请顺序不代表承诺或优先级。</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 -`;
|
||||||
|
}
|
||||||
@@ -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>首批申请限 1–3 台,实际名额以审核结果为准。</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>一次性配对码已经从页面状态中清除。控制平面已建立可撤销设备凭据并推进授权 revision;daemon 会继续连接同一个服务地址。</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { RouteLoadingState } from "../components/RouteStates";
|
||||||
|
|
||||||
|
export default function DashboardLoading() {
|
||||||
|
return (
|
||||||
|
<RouteLoadingState
|
||||||
|
area="控制台"
|
||||||
|
description="正在核对账户、公测权益、主机和服务状态,请稍候。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} 台主机。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: "NekoNest Cloud|把本地 coding-agent 接回手机",
|
||||||
|
template: "%s|NekoNest 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
@@ -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 Code、Kimi CLI、Grok 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>VPS、DNS、TLS</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { RouteLoadingState } from "../components/RouteStates";
|
||||||
|
|
||||||
|
export default function StatusLoading() {
|
||||||
|
return (
|
||||||
|
<RouteLoadingState
|
||||||
|
area="服务状态"
|
||||||
|
description="正在核对免费公测服务的最新公告和恢复记录。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
`;
|
||||||
@@ -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
|
||||||
|
`;
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
`;
|
||||||
@@ -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
@@ -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'
|
||||||
|
)
|
||||||
|
`;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
)`;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user