Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
78 lines
2.5 KiB
TypeScript
78 lines
2.5 KiB
TypeScript
/**
|
|
* Typed fetch client for the panel backend. Same-origin; the session cookie rides automatically
|
|
* (HttpOnly — JS never reads it). Every call narrows the response and throws `ApiError` on failure
|
|
* so the UI can react (e.g. bounce to the login screen on 401).
|
|
*/
|
|
export interface HostView {
|
|
readonly hostId: string
|
|
readonly subdomain: string
|
|
readonly status: string
|
|
readonly lastSeen?: string
|
|
readonly createdAt?: string
|
|
readonly revokedAt?: string | null
|
|
readonly notAfter?: string
|
|
}
|
|
|
|
export interface PairingArtifacts {
|
|
readonly code: string
|
|
readonly expiresAt: string
|
|
readonly pairCommand: string
|
|
readonly qrDataUrl: string
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number
|
|
constructor(status: number, message: string) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
async function request(method: string, url: string, body?: unknown): Promise<Response> {
|
|
const init: RequestInit = { method, credentials: 'same-origin', headers: {} }
|
|
if (body !== undefined) {
|
|
init.headers = { 'content-type': 'application/json' }
|
|
init.body = JSON.stringify(body)
|
|
}
|
|
return fetch(url, init)
|
|
}
|
|
|
|
async function json<T>(res: Response): Promise<T> {
|
|
if (!res.ok) throw new ApiError(res.status, `request failed (${res.status})`)
|
|
return (await res.json()) as T
|
|
}
|
|
|
|
export async function getSession(): Promise<boolean> {
|
|
const res = await request('GET', '/api/session')
|
|
const data = await json<{ authenticated: boolean }>(res)
|
|
return data.authenticated === true
|
|
}
|
|
|
|
/** Attempt login. Returns true on success; throws ApiError (status) otherwise so callers can message. */
|
|
export async function login(password: string): Promise<boolean> {
|
|
const res = await request('POST', '/login', { password })
|
|
if (res.ok) return true
|
|
throw new ApiError(res.status, `login failed (${res.status})`)
|
|
}
|
|
|
|
export async function logout(): Promise<void> {
|
|
await request('POST', '/logout')
|
|
}
|
|
|
|
export async function getHosts(): Promise<readonly HostView[]> {
|
|
const res = await request('GET', '/api/hosts')
|
|
const data = await json<{ hosts: HostView[] }>(res)
|
|
return data.hosts ?? []
|
|
}
|
|
|
|
export async function createPairingCode(): Promise<PairingArtifacts> {
|
|
const res = await request('POST', '/api/pairing-codes')
|
|
return json<PairingArtifacts>(res)
|
|
}
|
|
|
|
export async function revokeHost(hostId: string): Promise<void> {
|
|
const res = await request('DELETE', `/api/hosts/${encodeURIComponent(hostId)}`)
|
|
if (!res.ok && res.status !== 204) throw new ApiError(res.status, `revoke failed (${res.status})`)
|
|
}
|