UHPPOTE hardware bring-up + entry-flow blocker
Brought up the real UHPPOTE controller (serial 225088491, fw 09120) end to end and recorded a procurement-level blocker. Verified on hardware: - discovery (LAN scan), host-commanded openDoor on doors 1 & 2 (physically actuated; reason="remote open door"), and live button capture (reason="push button ok"). Driver/networking fixes (packages/devices/src/drivers/access-uhppote.ts): - broadcast to subnet-directed address (lib doesn't enable SO_BROADCAST for the global 255.255.255.255 -> EACCES); - Config broadcast must match the target's subnet for unicast reply routing (fixes the health-check timeout: 5s -> 24ms ready); - discover across all local subnets, dedupe by serial; - serialize all controller I/O (concurrent calls collided on UDP :60001). Server/UX: - load .env via node --env-file-if-exists (vars weren't being read before); - SETUP_AUTH_BYPASS hardened: env-gated, dev + loopback only, fails closed otherwise; surfaced as catalog.authBypass so the wizard drops the token field; - .env.example documents all vars; inline favicon stops a 404. - apps/server/scripts/: uhppote-listen (live events, restores prior listener) and uhppote-relay (guarded door-open test). BLOCKER (wiki/decisions/access-controller-button-flow.md): the controller's push-button input auto-opens the relay in firmware with no report-without-open mode, so ticket-first entry (button -> print -> open, fail-closed) is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable aux input + PULL SDK but that's unverified and needs a new driver. Entry-lane hardware decision paused to focus on the business side. wiki: access-controller-button-flow (blocker), zkteco-controller (stub + assessment), uhppote-controller callout, index + log.
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
# Copy to .env and fill in. The server refuses to start without a strong JWT_SECRET.
|
||||
# Copy this file to `.env` (same folder: apps/server/.env) and fill it in.
|
||||
# The dev/start scripts load it automatically via Node's --env-file-if-exists.
|
||||
#
|
||||
# Generate a strong secret:
|
||||
# openssl rand -hex 32
|
||||
# cp apps/server/.env.example apps/server/.env
|
||||
#
|
||||
# Required ----------------------------------------------------------------
|
||||
# The server refuses to start without a strong JWT_SECRET (>=32 chars).
|
||||
# Generate one with: openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
|
||||
# Optional
|
||||
# Optional ----------------------------------------------------------------
|
||||
# PORT=3000
|
||||
# HOST=0.0.0.0
|
||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||
# LOG_LEVEL=info
|
||||
# DATABASE_URL=./parking.sqlite
|
||||
|
||||
# Testing-only ------------------------------------------------------------
|
||||
# Bypass the admin auth on /api/setup/* so you can discover/assign devices
|
||||
# before the login flow exists. HARDENED: only honoured when NODE_ENV is not
|
||||
# "production" AND HOST is loopback (127.0.0.1 / ::1 / localhost); otherwise
|
||||
# the server refuses to start. Never set this in production.
|
||||
# SETUP_AUTH_BYPASS=1
|
||||
# HOST=127.0.0.1
|
||||
|
||||
@@ -5,21 +5,22 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "node --watch --experimental-strip-types src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "node --env-file-if-exists=.env --watch --experimental-strip-types src/index.ts",
|
||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@parking/db": "workspace:*",
|
||||
"@parking/devices": "workspace:*",
|
||||
"@parking/shared": "workspace:*",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/jwt": "10.1.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@parking/db": "workspace:*",
|
||||
"@parking/devices": "workspace:*",
|
||||
"@parking/shared": "workspace:*",
|
||||
"bcrypt": "6.0.0",
|
||||
"fastify": "5.8.5",
|
||||
"fastify-plugin": "6.0.0"
|
||||
"fastify-plugin": "6.0.0",
|
||||
"uhppoted": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "6.0.0",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Shared helpers for the UHPPOTE hardware test scripts.
|
||||
// Run directly against the device (independent of the HTTP server).
|
||||
//
|
||||
// node apps/server/scripts/uhppote-listen.mjs
|
||||
// node apps/server/scripts/uhppote-relay.mjs
|
||||
//
|
||||
// Env overrides:
|
||||
// UHPPOTE_SERIAL controller serial (default 225088491)
|
||||
// UHPPOTE_HOST controller IP (default 10.0.10.3)
|
||||
// UHPPOTE_BCAST Config broadcast (default derived from HOST subnet)
|
||||
// HOST_IP this host's IP the controller pushes events to
|
||||
// (default: auto-detected interface on the controller's subnet)
|
||||
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const uhppoted = require("uhppoted");
|
||||
|
||||
export const SERIAL = Number(process.env.UHPPOTE_SERIAL ?? 225088491);
|
||||
export const HOST = process.env.UHPPOTE_HOST ?? "10.0.10.3";
|
||||
|
||||
/** Subnet-directed broadcast for the interface that owns `ip`. */
|
||||
function broadcastForHost(ip) {
|
||||
const o = ip.split(".").map(Number);
|
||||
for (const ifaces of Object.values(networkInterfaces())) {
|
||||
for (const i of ifaces ?? []) {
|
||||
if (i.family !== "IPv4" || i.internal) continue;
|
||||
const a = i.address.split(".").map(Number);
|
||||
const m = i.netmask.split(".").map(Number);
|
||||
if (o.every((x, k) => (x & m[k]) === (a[k] & m[k]))) {
|
||||
return a.map((x, k) => (x & m[k]) | (~m[k] & 0xff)).join(".");
|
||||
}
|
||||
}
|
||||
}
|
||||
return "255.255.255.255";
|
||||
}
|
||||
|
||||
/** This host's own IP on the controller's subnet (where it should push events). */
|
||||
export function hostIpOnControllerSubnet(ip = HOST) {
|
||||
if (process.env.HOST_IP) return process.env.HOST_IP;
|
||||
const o = ip.split(".").map(Number);
|
||||
for (const ifaces of Object.values(networkInterfaces())) {
|
||||
for (const i of ifaces ?? []) {
|
||||
if (i.family !== "IPv4" || i.internal) continue;
|
||||
const a = i.address.split(".").map(Number);
|
||||
const m = i.netmask.split(".").map(Number);
|
||||
if (o.every((x, k) => (x & m[k]) === (a[k] & m[k]))) return i.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const BCAST = process.env.UHPPOTE_BCAST ?? broadcastForHost(HOST);
|
||||
|
||||
export function makeCtx(timeoutMs = 5000) {
|
||||
return {
|
||||
config: new uhppoted.Config(
|
||||
"parking",
|
||||
"0.0.0.0",
|
||||
`${BCAST}:60000`,
|
||||
"0.0.0.0:60001",
|
||||
timeoutMs,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
locale: "en-US",
|
||||
};
|
||||
}
|
||||
|
||||
export const controller = { id: SERIAL, address: HOST, protocol: "udp" };
|
||||
export { uhppoted };
|
||||
@@ -0,0 +1,100 @@
|
||||
// Live button/event listener for the UHPPOTE controller.
|
||||
//
|
||||
// Points the controller's event listener at THIS host, then prints each pushed
|
||||
// event in real time. Press the door buttons on the controller and watch them
|
||||
// appear. Ctrl-C to stop.
|
||||
//
|
||||
// node apps/server/scripts/uhppote-listen.mjs
|
||||
|
||||
import {
|
||||
controller,
|
||||
hostIpOnControllerSubnet,
|
||||
makeCtx,
|
||||
uhppoted,
|
||||
} from "./uhppote-common.mjs";
|
||||
|
||||
const ctx = makeCtx();
|
||||
|
||||
const hostIp = hostIpOnControllerSubnet();
|
||||
if (!hostIp) {
|
||||
console.error("Could not determine this host's IP on the controller's subnet.");
|
||||
console.error("Set HOST_IP=<your-ip-on-the-controller-LAN> and retry.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`controller : ${controller.id} @ ${controller.address}`);
|
||||
console.log(`this host : ${hostIp} (events will be pushed here on :60001)`);
|
||||
|
||||
// 0) Remember the controller's current listener so we can restore it on exit
|
||||
// (it was pointing somewhere else, e.g. 10.0.10.241).
|
||||
let prevListener = null;
|
||||
try {
|
||||
prevListener = await uhppoted.getListener(ctx, controller);
|
||||
console.log(`prior listener: ${prevListener.address}:${prevListener.port} (will restore on exit)`);
|
||||
} catch (e) {
|
||||
console.warn("getListener (non-fatal):", e.code ?? e.message);
|
||||
}
|
||||
|
||||
// 1) Tell the controller to push events to us.
|
||||
try {
|
||||
const r = await uhppoted.setListener(ctx, controller, hostIp, 60001);
|
||||
console.log("setListener:", JSON.stringify(r));
|
||||
} catch (e) {
|
||||
console.error("setListener failed:", e.code ?? e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2) (Best-effort) ensure door open/close + button events are recorded.
|
||||
try {
|
||||
await uhppoted.recordSpecialEvents(ctx, controller, true);
|
||||
console.log("recordSpecialEvents: enabled");
|
||||
} catch (e) {
|
||||
console.warn("recordSpecialEvents (non-fatal):", e.code ?? e.message);
|
||||
}
|
||||
|
||||
console.log("\n── listening — press the door buttons on the controller ──\n");
|
||||
|
||||
function describe(ev) {
|
||||
const e = ev?.state?.event ?? ev?.event;
|
||||
const buttons = ev?.state?.buttons;
|
||||
const doors = ev?.state?.doors;
|
||||
const parts = [];
|
||||
if (e) {
|
||||
parts.push(
|
||||
`event#${e.index} type=${e.type?.event ?? e.type?.code} door=${e.door} granted=${e.granted} reason="${e.reason?.reason ?? e.reason?.code}" @${e.timestamp}`,
|
||||
);
|
||||
}
|
||||
if (buttons) {
|
||||
const pressed = Object.entries(buttons).filter(([, v]) => v).map(([k]) => k);
|
||||
parts.push(`buttons=[${pressed.join(",") || "none"}]`);
|
||||
}
|
||||
if (doors) {
|
||||
const open = Object.entries(doors).filter(([, v]) => v).map(([k]) => k);
|
||||
parts.push(`doorsOpen=[${open.join(",") || "none"}]`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
uhppoted.listen(
|
||||
ctx,
|
||||
(event) => {
|
||||
console.log(`[${new Date().toISOString()}] ${describe(event)}`);
|
||||
},
|
||||
(err) => {
|
||||
console.error("listen error:", err?.message ?? err);
|
||||
},
|
||||
);
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
// Restore the controller's previous listener so we don't hijack it.
|
||||
if (prevListener && prevListener.address && prevListener.address !== "0.0.0.0") {
|
||||
try {
|
||||
await uhppoted.setListener(ctx, controller, prevListener.address, prevListener.port);
|
||||
console.log(`\nrestored listener -> ${prevListener.address}:${prevListener.port}`);
|
||||
} catch (e) {
|
||||
console.warn("\ncould not restore listener:", e.code ?? e.message);
|
||||
}
|
||||
}
|
||||
console.log("stopped.");
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Guarded relay (door-open) test for the UHPPOTE controller.
|
||||
//
|
||||
// Prompts before firing each relay so a door only opens when you're ready and
|
||||
// watching. This is a 2-door controller, so it tests doors 1 and 2 by default.
|
||||
//
|
||||
// node apps/server/scripts/uhppote-relay.mjs # doors 1,2 (prompted)
|
||||
// node apps/server/scripts/uhppote-relay.mjs 1 # only door 1
|
||||
// YES=1 node apps/server/scripts/uhppote-relay.mjs # no prompts (fires!)
|
||||
//
|
||||
// SAFETY: openDoor only expresses INTENT to open. The controller / barrier
|
||||
// operator owns the close timing and anti-crush — we never time a close.
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { controller, makeCtx, uhppoted } from "./uhppote-common.mjs";
|
||||
|
||||
const ctx = makeCtx();
|
||||
const doors = process.argv.slice(2).map(Number).filter((n) => n >= 1 && n <= 4);
|
||||
const targets = doors.length ? doors : [1, 2];
|
||||
const autoYes = process.env.YES === "1";
|
||||
|
||||
console.log(`controller : ${controller.id} @ ${controller.address}`);
|
||||
console.log(`testing doors: ${targets.join(", ")}${autoYes ? " (auto, no prompts)" : ""}\n`);
|
||||
|
||||
const rl = autoYes ? null : createInterface({ input: stdin, output: stdout });
|
||||
|
||||
for (const door of targets) {
|
||||
if (rl) {
|
||||
const ans = await rl.question(`Open door ${door}? [y/N] `);
|
||||
if (ans.trim().toLowerCase() !== "y") {
|
||||
console.log(` skipped door ${door}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await uhppoted.openDoor(ctx, controller, door);
|
||||
console.log(` door ${door}: openDoor -> ${JSON.stringify(res)}`);
|
||||
} catch (e) {
|
||||
console.log(` door ${door}: ERROR ${e.code ?? e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
rl?.close();
|
||||
console.log("\ndone.");
|
||||
@@ -24,12 +24,26 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
|
||||
// TEMPORARY hardware-bench escape hatch. When SETUP_AUTH_BYPASS=1, the setup
|
||||
// endpoints skip the admin guard so devices can be discovered/assigned before
|
||||
// the login flow exists. Remove once real admin login is wired.
|
||||
//
|
||||
// Hardened (flagged by security review): this can NEVER silently open auth in
|
||||
// a deployable config. It is honoured ONLY when all hold, else the server
|
||||
// FAILS CLOSED (throws) rather than running unauthenticated:
|
||||
// (a) NODE_ENV !== 'production'
|
||||
// (b) the listener is bound to loopback (HOST is 127.0.0.1 / ::1 / localhost)
|
||||
// See server.ts TODO + wiki/concepts/first-run-setup.md.
|
||||
const { guard: adminGuard, bypassed: authBypass } = resolveAdminGuard(app);
|
||||
|
||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||
// `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE).
|
||||
// `authBypass` tells the UI the setup endpoints aren't requiring a token
|
||||
// (testing only), so it can drop the admin-token requirement.
|
||||
app.get("/api/setup/catalog", async () => {
|
||||
const catalog = registry.catalog();
|
||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||
return { ...catalog, discoverable };
|
||||
return { ...catalog, discoverable, authBypass };
|
||||
});
|
||||
|
||||
// Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc).
|
||||
@@ -37,7 +51,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
|
||||
app.get<{ Params: { driverId: string } }>(
|
||||
"/api/setup/discover/:driverId",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const driver = registry.get(req.params.driverId);
|
||||
if (!driver) return reply.code(404).send({ error: `unknown driver: ${req.params.driverId}` });
|
||||
@@ -67,7 +81,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Current setup status + assignments.
|
||||
app.get(
|
||||
"/api/setup/state",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async () => {
|
||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||
const assignments = await db.select().from(laneDevices).all();
|
||||
@@ -79,7 +93,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// registry before persisting; rejects unknown drivers / missing config.
|
||||
app.post<{ Body: AssignBody }>(
|
||||
"/api/setup/assign",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { lane, category, driverId, config } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
@@ -107,7 +121,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Mark first-run setup complete.
|
||||
app.post(
|
||||
"/api/setup/complete",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async () => {
|
||||
const completedAt = new Date().toISOString();
|
||||
await db
|
||||
@@ -118,3 +132,38 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
|
||||
/**
|
||||
* Resolve the setup admin guard. Returns the real admin role guard unless the
|
||||
* SETUP_AUTH_BYPASS escape hatch is both requested AND safe; if it's requested
|
||||
* but unsafe, throws so the server fails closed instead of running open.
|
||||
* `bypassed` is surfaced to the UI so it can drop the admin-token requirement.
|
||||
*/
|
||||
function resolveAdminGuard(app: FastifyInstance): {
|
||||
guard: ReturnType<typeof requireRole>;
|
||||
bypassed: boolean;
|
||||
} {
|
||||
if (process.env.SETUP_AUTH_BYPASS !== "1") {
|
||||
return { guard: requireRole("admin"), bypassed: false };
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const isLoopback = LOOPBACK_HOSTS.has(host);
|
||||
|
||||
if (isProd || !isLoopback) {
|
||||
// Fail closed: never honour an auth bypass in production or on a non-loopback
|
||||
// listener (that would expose unauthenticated setup endpoints on the network).
|
||||
throw new Error(
|
||||
`SETUP_AUTH_BYPASS refused: requires NODE_ENV!=production (is "${process.env.NODE_ENV ?? "undefined"}") ` +
|
||||
`and a loopback HOST (is "${host}"). Set HOST=127.0.0.1 for local testing, or unset the bypass.`,
|
||||
);
|
||||
}
|
||||
|
||||
app.log.warn(
|
||||
`⚠️ SETUP_AUTH_BYPASS=1 — /api/setup/* admin auth DISABLED on ${host} (testing only)`,
|
||||
);
|
||||
return { guard: async () => {}, bypassed: true };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Parking System</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -52,16 +52,22 @@ export function SetupWizard() {
|
||||
style={{ width: "4rem" }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: 1 }}>
|
||||
Admin token{" "}
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="needed to scan / assign"
|
||||
style={{ width: "60%" }}
|
||||
/>
|
||||
</label>
|
||||
{catalog.authBypass ? (
|
||||
<span style={{ flex: 1, color: "#92400e" }}>
|
||||
⚠️ auth bypass on (testing) — no token needed
|
||||
</span>
|
||||
) : (
|
||||
<label style={{ flex: 1 }}>
|
||||
Admin token{" "}
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="needed to scan / assign"
|
||||
style={{ width: "60%" }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{CATEGORIES.map(({ key, title }) => (
|
||||
@@ -71,6 +77,7 @@ export function SetupWizard() {
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
token={token}
|
||||
authBypass={catalog.authBypass}
|
||||
selectedId={picked[key]}
|
||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||||
/>
|
||||
@@ -84,6 +91,7 @@ function CategoryPicker({
|
||||
entries,
|
||||
discoverableIds,
|
||||
token,
|
||||
authBypass,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
@@ -91,6 +99,7 @@ function CategoryPicker({
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
token: string;
|
||||
authBypass: boolean;
|
||||
selectedId: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
@@ -144,10 +153,12 @@ function CategoryPicker({
|
||||
|
||||
{canDiscover && (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<button type="button" onClick={scan} disabled={scanning || !token}>
|
||||
<button type="button" onClick={scan} disabled={scanning || (!authBypass && !token)}>
|
||||
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||
</button>
|
||||
{!token && <span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>}
|
||||
{!authBypass && !token && (
|
||||
<span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>
|
||||
)}
|
||||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||
{found && found.length > 0 && (
|
||||
|
||||
@@ -22,6 +22,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||
/** Driver ids that support LAN discovery. */
|
||||
discoverable: string[];
|
||||
/** True when setup endpoints skip admin auth (testing only) — no token needed. */
|
||||
authBypass: boolean;
|
||||
};
|
||||
|
||||
export async function fetchCatalog(): Promise<Catalog> {
|
||||
|
||||
Reference in New Issue
Block a user