@aexol/spectral 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Fetch the admin-managed list of allowed base models from the backend.
3
+ *
4
+ * Used by `PiBridge` at startup to register synthetic providers
5
+ * (`spectral-proxy-anthropic` / `spectral-proxy-openai`) that route every
6
+ * inference call through the backend's `/v1/messages` and
7
+ * `/v1/chat/completions` endpoints. The backend authenticates the call
8
+ * with the machine JWT (Bearer) and forwards to the upstream provider
9
+ * using its own (centralized) API keys.
10
+ *
11
+ * Why GraphQL (not REST):
12
+ * - The backend already exposes the whitelist via `availableBaseModels`
13
+ * in its public GraphQL schema (`schema.graphql`); there is no
14
+ * equivalent REST endpoint and we don't want to add one just for the
15
+ * CLI.
16
+ * - The query is parameterless and authenticated via the same Bearer
17
+ * machine JWT used by `/v1/*`, so a single `fetch` call is enough —
18
+ * no Zeus client setup required.
19
+ *
20
+ * Caching:
21
+ * - In-memory TTL cache (default 5 min), keyed by `${backendUrl}|${jwt}`.
22
+ * Pi sessions are short-lived but a single `spectral serve` process
23
+ * creates many of them, so we don't want to hammer the GraphQL
24
+ * endpoint on every reconnect.
25
+ */
26
+ const cache = new Map();
27
+ /** Test-only helper: drops every cached entry. */
28
+ export function clearAllowedModelsCache() {
29
+ cache.clear();
30
+ }
31
+ const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled } }`;
32
+ /**
33
+ * Fetch the whitelist of allowed base models. Throws on any failure with a
34
+ * message tailored for an operator running `spectral serve` — the caller
35
+ * (PiBridge.start) lets the throw propagate so the WS subscriber sees a
36
+ * clear error event instead of a silent fall-through to "no models".
37
+ */
38
+ export async function fetchAllowedModels(opts) {
39
+ const ttlMs = opts.cacheTtlMs ?? 5 * 60 * 1000;
40
+ const key = `${opts.backendUrl}|${opts.machineJwt}`;
41
+ if (!opts.bypassCache) {
42
+ const hit = cache.get(key);
43
+ if (hit && Date.now() - hit.fetchedAt < hit.ttlMs) {
44
+ return hit.models;
45
+ }
46
+ }
47
+ const fetchImpl = opts.fetchImpl ?? fetch;
48
+ const url = `${opts.backendUrl.replace(/\/$/, "")}/graphql`;
49
+ let res;
50
+ try {
51
+ res = await fetchImpl(url, {
52
+ method: "POST",
53
+ headers: {
54
+ Authorization: `Bearer ${opts.machineJwt}`,
55
+ "Content-Type": "application/json",
56
+ },
57
+ body: JSON.stringify({ query: QUERY }),
58
+ });
59
+ }
60
+ catch (err) {
61
+ const msg = err instanceof Error ? err.message : String(err);
62
+ throw new Error(`Failed to fetch allowed models from backend (${url}): ${msg}. ` +
63
+ `Check SPECTRAL_BACKEND_URL and machine JWT.`);
64
+ }
65
+ if (!res.ok) {
66
+ let detail = "";
67
+ try {
68
+ detail = await res.text();
69
+ }
70
+ catch {
71
+ // ignore
72
+ }
73
+ throw new Error(`Failed to fetch allowed models from backend (HTTP ${res.status} ${res.statusText})` +
74
+ (detail ? `: ${detail.slice(0, 500)}` : "") +
75
+ ". Check SPECTRAL_BACKEND_URL and machine JWT.");
76
+ }
77
+ let payload;
78
+ try {
79
+ payload = (await res.json());
80
+ }
81
+ catch (err) {
82
+ throw new Error(`Failed to fetch allowed models: backend returned non-JSON (${err instanceof Error ? err.message : String(err)})`);
83
+ }
84
+ if (payload.errors && payload.errors.length > 0) {
85
+ const msg = payload.errors
86
+ .map((e) => (typeof e?.message === "string" ? e.message : "unknown"))
87
+ .join("; ");
88
+ throw new Error(`Failed to fetch allowed models: GraphQL errors: ${msg}`);
89
+ }
90
+ const rows = payload.data?.availableBaseModels;
91
+ if (!Array.isArray(rows)) {
92
+ throw new Error(`Failed to fetch allowed models: response missing availableBaseModels array`);
93
+ }
94
+ const models = [];
95
+ for (const row of rows) {
96
+ const name = typeof row?.name === "string" ? row.name : null;
97
+ const provider = typeof row?.provider === "string" ? row.provider : null;
98
+ if (!name || !provider)
99
+ continue; // skip malformed rows defensively
100
+ const model = { modelId: name, displayName: name, provider };
101
+ if (typeof row?.userModelId === "string") {
102
+ model.userModelId = row.userModelId;
103
+ }
104
+ models.push(model);
105
+ }
106
+ cache.set(key, { fetchedAt: Date.now(), ttlMs, models });
107
+ return models;
108
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Machine registration with the Aexol backend.
3
+ *
4
+ * Contract (Batch 1 backend):
5
+ * POST <backend>/api/machines/register
6
+ * Headers: Authorization: Bearer <teamApiKey>
7
+ * Body: { name?: string, hostname: string, version: string, pid: number }
8
+ * 200: { machineId: string, jwt: string, name: string }
9
+ *
10
+ * Why this lives in its own module:
11
+ * - It's the ONLY consumer of the team API key. Once registration succeeds,
12
+ * the team key is dropped on the floor — only the short-lived `machineJwt`
13
+ * is persisted (in `machine.json`) and threaded into `RelayClient`. Keeps
14
+ * the blast radius of an in-memory leak minimal.
15
+ * - It owns JWT expiry decoding so `serve.ts` doesn't have to know about
16
+ * JWT internals; a single boundary for "is the saved record still good".
17
+ *
18
+ * Failure mode: registration is fail-fast (no retry). The user is sitting
19
+ * at a CLI prompt; if their team key is wrong or the backend is down, the
20
+ * right answer is a clear error message, not a hang. WS reconnect (which
21
+ * IS retried) is the long-running path; that's `RelayClient`'s job.
22
+ */
23
+ import { hostname } from "node:os";
24
+ import { loadMachine, saveMachine } from "./machine-store.js";
25
+ /**
26
+ * Decode the `exp` claim of a JWT without verifying the signature.
27
+ * We're not using this for AuthN — only for "should we re-register
28
+ * proactively". The backend is the source of truth on validity; if our
29
+ * cheap pre-check is wrong, the WS handshake will reject and we'll fall
30
+ * through to a full re-register on the next `spectral serve`.
31
+ *
32
+ * Returns `null` if the token isn't a parseable JWT or has no `exp`.
33
+ */
34
+ export function decodeJwtExp(jwt) {
35
+ const parts = jwt.split(".");
36
+ if (parts.length < 2)
37
+ return null;
38
+ try {
39
+ // base64url -> base64. Node 20's Buffer accepts 'base64url' directly.
40
+ const payloadJson = Buffer.from(parts[1], "base64url").toString("utf8");
41
+ const payload = JSON.parse(payloadJson);
42
+ return typeof payload.exp === "number" ? payload.exp : null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** True when the JWT is past expiry (with skew). Treats undecodable as expired. */
49
+ export function isJwtExpired(jwt, skewSec = 60) {
50
+ const exp = decodeJwtExp(jwt);
51
+ if (exp === null)
52
+ return true;
53
+ return Date.now() / 1000 + skewSec >= exp;
54
+ }
55
+ /**
56
+ * Ensure a machine is registered. Returns the record either from the cached
57
+ * `machine.json` (when the JWT is still fresh) or from a freshly issued one.
58
+ *
59
+ * Re-registration triggers:
60
+ * - No `machine.json` on disk (first run).
61
+ * - The cached JWT is expired or near-expired.
62
+ *
63
+ * Why we don't re-register on `--machine-name` change: the CLI flag controls
64
+ * the *next* registration's display name, but switching it shouldn't burn a
65
+ * fresh `machineId` on every restart. If users want a clean break they can
66
+ * delete `machine.json`.
67
+ */
68
+ export async function ensureMachineRegistered(deps) {
69
+ const skew = deps.expirySkewSec ?? 60;
70
+ const existing = await loadMachine();
71
+ if (existing && !isJwtExpired(existing.machineJwt, skew)) {
72
+ return { reused: true, record: existing };
73
+ }
74
+ const fetchImpl = deps.fetchImpl ?? fetch;
75
+ const url = `${deps.backendUrl.replace(/\/$/, "")}/api/machines/register`;
76
+ const body = {
77
+ // Reuse the cached display name on JWT-expiry refresh so the backend's
78
+ // machine list stays stable; only on a clean install do we fall back to
79
+ // hostname or the CLI override.
80
+ name: deps.machineNameOverride ?? existing?.machineName ?? hostname(),
81
+ hostname: hostname(),
82
+ version: deps.version,
83
+ pid: deps.pid ?? process.pid,
84
+ };
85
+ let res;
86
+ try {
87
+ res = await fetchImpl(url, {
88
+ method: "POST",
89
+ headers: {
90
+ Authorization: `Bearer ${deps.apiKey}`,
91
+ "Content-Type": "application/json",
92
+ },
93
+ body: JSON.stringify(body),
94
+ });
95
+ }
96
+ catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ throw new Error(`Failed to reach backend at ${url}: ${msg}`);
99
+ }
100
+ if (!res.ok) {
101
+ let detail = "";
102
+ try {
103
+ detail = await res.text();
104
+ }
105
+ catch {
106
+ // ignore
107
+ }
108
+ throw new Error(`Machine registration failed (${res.status} ${res.statusText})` +
109
+ (detail ? `: ${detail.slice(0, 500)}` : ""));
110
+ }
111
+ let payload;
112
+ try {
113
+ payload = await res.json();
114
+ }
115
+ catch (err) {
116
+ throw new Error(`Machine registration: backend returned non-JSON (${err instanceof Error ? err.message : String(err)})`);
117
+ }
118
+ const obj = payload;
119
+ if (typeof obj.machineId !== "string" ||
120
+ typeof obj.jwt !== "string" ||
121
+ typeof obj.name !== "string") {
122
+ throw new Error(`Machine registration: backend response missing required fields (machineId, jwt, name)`);
123
+ }
124
+ const record = {
125
+ machineId: obj.machineId,
126
+ machineName: obj.name,
127
+ machineJwt: obj.jwt,
128
+ teamId: typeof obj.teamId === "string" ? obj.teamId : undefined,
129
+ registeredAt: Date.now(),
130
+ hostname: hostname(),
131
+ version: deps.version,
132
+ };
133
+ await saveMachine(record);
134
+ return { reused: false, record };
135
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Typed errors thrown by REST handlers.
3
+ *
4
+ * Each error carries a stable `code` that the relay envelope dispatcher
5
+ * (Batch 3) maps to a wire-level status. Until then, the handlers throw
6
+ * and the relay loop just acks — Batch 3 will catch and translate.
7
+ *
8
+ * Why typed errors instead of returning `{ ok: false, code }` discriminated
9
+ * unions? Two reasons:
10
+ * - The handlers are also called directly from unit tests; `throw` lets
11
+ * tests use `expect(...).rejects.toThrow(NotFoundError)` ergonomically.
12
+ * - The Batch 3 dispatcher needs a single `try/catch` boundary; a sum
13
+ * type per handler would force every handler to return its own variant.
14
+ */
15
+ export class HandlerError extends Error {
16
+ code;
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = "HandlerError";
21
+ }
22
+ }
23
+ export class BadRequestError extends HandlerError {
24
+ constructor(message) {
25
+ super("BAD_REQUEST", message);
26
+ this.name = "BadRequestError";
27
+ }
28
+ }
29
+ export class NotFoundError extends HandlerError {
30
+ constructor(message) {
31
+ super("NOT_FOUND", message);
32
+ this.name = "NotFoundError";
33
+ }
34
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Pure REST handlers for `/api/projects/*`.
3
+ *
4
+ * Extracted from the deleted Hono `routes.ts` so the relay envelope dispatcher
5
+ * (Batch 3) can call them with a parsed payload instead of a Hono context.
6
+ *
7
+ * Contract:
8
+ * - Inputs are already-parsed JSON objects (or `unknown` shapes the handler
9
+ * validates). The dispatcher upstream parses the relay envelope body.
10
+ * - Outputs are the wire shapes the landing client already expects, so the
11
+ * Batch 3 dispatcher can JSON-stringify them straight into the response
12
+ * envelope without remapping.
13
+ * - Errors are thrown as typed `HandlerError` subclasses; the dispatcher
14
+ * maps `BAD_REQUEST` → 400-equivalent and `NOT_FOUND` → 404-equivalent.
15
+ * - Stream teardown on project delete is the caller's responsibility — the
16
+ * handler returns the cascaded session ids so the dispatcher can hand
17
+ * them to `SessionStreamManager.disposeProjectStreams` BEFORE the SQL
18
+ * cascade has already torn down (in our model the cascade has already
19
+ * happened by the time we return; that's intentional and matches the
20
+ * old route's ordering — the manager is best-effort cleanup).
21
+ */
22
+ import { validateProjectPath } from "../paths.js";
23
+ import { BadRequestError, NotFoundError } from "./errors.js";
24
+ export function handleListProjects(store) {
25
+ return store.listProjects();
26
+ }
27
+ export function handleCreateProject(store, body) {
28
+ if (typeof body.name !== "string" || !body.name.trim()) {
29
+ throw new BadRequestError("name (non-empty string) is required");
30
+ }
31
+ if (typeof body.path !== "string" || !body.path.trim()) {
32
+ throw new BadRequestError("path (non-empty string) is required");
33
+ }
34
+ const validation = validateProjectPath(body.path);
35
+ if (!validation.ok) {
36
+ throw new BadRequestError(validation.error ?? "Invalid path");
37
+ }
38
+ return store.createProject({ name: body.name, path: validation.path });
39
+ }
40
+ export function handleGetProject(store, id) {
41
+ const project = store.getProject(id);
42
+ if (!project)
43
+ throw new NotFoundError("Project not found");
44
+ return project;
45
+ }
46
+ export function handleUpdateProject(store, id, body) {
47
+ const update = {};
48
+ if (body.name !== undefined) {
49
+ if (typeof body.name !== "string") {
50
+ throw new BadRequestError("name must be a string");
51
+ }
52
+ update.name = body.name;
53
+ }
54
+ if (body.path !== undefined) {
55
+ if (typeof body.path !== "string") {
56
+ throw new BadRequestError("path must be a string");
57
+ }
58
+ const validation = validateProjectPath(body.path);
59
+ if (!validation.ok) {
60
+ throw new BadRequestError(validation.error ?? "Invalid path");
61
+ }
62
+ update.path = validation.path;
63
+ }
64
+ if (update.name === undefined && update.path === undefined) {
65
+ throw new BadRequestError("At least one of name, path must be provided");
66
+ }
67
+ const updated = store.updateProject(id, update);
68
+ if (!updated)
69
+ throw new NotFoundError("Project not found");
70
+ return updated;
71
+ }
72
+ export function handleDeleteProject(store, id) {
73
+ const project = store.getProject(id);
74
+ if (!project)
75
+ throw new NotFoundError("Project not found");
76
+ const result = store.deleteProject(id);
77
+ if (!result.deleted)
78
+ throw new NotFoundError("Project not found");
79
+ return { sessionIds: result.sessionIds };
80
+ }
81
+ export function handleListSessionsByProject(store, id) {
82
+ const project = store.getProject(id);
83
+ if (!project)
84
+ throw new NotFoundError("Project not found");
85
+ return store.listSessionsByProject(id);
86
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure REST handlers for `/api/sessions/*`.
3
+ *
4
+ * Same shape as projects handlers — see that file's header for the contract.
5
+ *
6
+ * Note: session deletion does not tear down the in-flight pi stream itself.
7
+ * The Batch 3 dispatcher must call `manager.disposeSessionStream(id)` BEFORE
8
+ * invoking `handleDeleteSession` so the FK cascade doesn't leave a zombie
9
+ * bridge driving events at a row that no longer exists. This matches the
10
+ * ordering the old Hono route used.
11
+ */
12
+ import { BadRequestError, NotFoundError } from "./errors.js";
13
+ export function handleCreateSession(store, body) {
14
+ if (typeof body.projectId !== "string" || !body.projectId) {
15
+ throw new BadRequestError("projectId (string) is required");
16
+ }
17
+ const project = store.getProject(body.projectId);
18
+ if (!project)
19
+ throw new BadRequestError("Unknown projectId");
20
+ const title = typeof body.title === "string" ? body.title : undefined;
21
+ return store.createSession({ projectId: body.projectId, title });
22
+ }
23
+ export function handleGetSessionDetail(store, id) {
24
+ const detail = store.getSession(id);
25
+ if (!detail)
26
+ throw new NotFoundError("Session not found");
27
+ return detail;
28
+ }
29
+ export function handleUpdateSession(store, id, body) {
30
+ if (typeof body.title !== "string") {
31
+ throw new BadRequestError("title (string) is required");
32
+ }
33
+ const updated = store.renameSession(id, body.title);
34
+ if (!updated)
35
+ throw new NotFoundError("Session not found");
36
+ return updated;
37
+ }
38
+ export function handleDeleteSession(store, id) {
39
+ const deleted = store.deleteSession(id);
40
+ if (!deleted)
41
+ throw new NotFoundError("Session not found");
42
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Filesystem path helpers for project management.
3
+ *
4
+ * Projects in `spectral serve` are user-owned directories on disk. We accept
5
+ * paths typed by the user from the browser, so two small concerns:
6
+ *
7
+ * - Tilde expansion: `~/foo/bar` is the natural way to refer to a path
8
+ * inside `$HOME`. Node's `path` module does not expand tildes; we do it
9
+ * ourselves with a leading-segment check (NOT a global string replace —
10
+ * `~user/...` and embedded `~` are intentionally left alone).
11
+ * - Validation: the path must exist, must be a directory, and must be
12
+ * readable. We do NOT attempt to verify writability — pi may want to
13
+ * create files inside it, but failure modes there surface naturally
14
+ * through pi's tools, and a write probe here would create stray files.
15
+ *
16
+ * Both helpers are synchronous because they're called on hot HTTP paths
17
+ * (POST /api/projects) where blocking is acceptable: the operation is rare
18
+ * and the user is waiting on the response anyway. `node:fs` sync calls in
19
+ * this regime are simpler than juggling promises through validation.
20
+ */
21
+ import { existsSync, statSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { isAbsolute, resolve, sep } from "node:path";
24
+ /**
25
+ * Expand a leading `~` or `~/` to the user's home directory and return an
26
+ * absolute, normalized path.
27
+ *
28
+ * Behavior:
29
+ * - `~` → `$HOME`
30
+ * - `~/foo` → `$HOME/foo`
31
+ * - `/abs/path` → `/abs/path` (unchanged, normalized)
32
+ * - `relative` → resolved against `$HOME` (NOT process.cwd, since the
33
+ * server runs from `$HOME` and "relative to home" matches the user's
34
+ * mental model when typing into a form)
35
+ * - `~user/...` → returned as-is and resolved against `$HOME`. We do NOT
36
+ * attempt to look up other users' home directories — out of scope.
37
+ */
38
+ export function expandPath(input) {
39
+ const trimmed = input.trim();
40
+ if (!trimmed)
41
+ return homedir();
42
+ // Tilde expansion — leading-segment only. `~` alone or `~/...`.
43
+ if (trimmed === "~")
44
+ return homedir();
45
+ if (trimmed.startsWith("~/") || trimmed.startsWith(`~${sep}`)) {
46
+ return resolve(homedir(), trimmed.slice(2));
47
+ }
48
+ if (isAbsolute(trimmed))
49
+ return resolve(trimmed);
50
+ // Relative path → resolve against $HOME. Matches the form-typing UX where
51
+ // a user enters `projects/foo` expecting `~/projects/foo`.
52
+ return resolve(homedir(), trimmed);
53
+ }
54
+ /**
55
+ * Validate a user-supplied project path.
56
+ *
57
+ * Returns `{ok:true, path}` for a real, accessible directory. On failure,
58
+ * `error` carries a message suitable for surfacing to the user. The expanded
59
+ * path is included in both cases so callers can echo it back in errors.
60
+ */
61
+ export function validateProjectPath(input) {
62
+ const path = expandPath(input);
63
+ let st;
64
+ try {
65
+ if (!existsSync(path)) {
66
+ return { ok: false, path, error: `Path does not exist: ${path}` };
67
+ }
68
+ st = statSync(path);
69
+ }
70
+ catch (err) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ return { ok: false, path, error: `Cannot access path: ${msg}` };
73
+ }
74
+ if (!st.isDirectory()) {
75
+ return { ok: false, path, error: `Path is not a directory: ${path}` };
76
+ }
77
+ return { ok: true, path };
78
+ }