Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions tests/console_desk_mock_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,17 @@ assert.equal(resolveMockScenario("?mock=0", "ok"), null);
assert.equal(resolveMockScenario("?page=overview", "fail"), "fail");
assert.equal(resolveMockScenario("?mock=<script>", null), null);

// 5. Endpoint sources: session live, every other desk route mock until its backend lands.
// 5. Endpoint sources: session and credentials are live; the rest stay mock until their backend lands.
assert.deepEqual(DESK_SOURCES, {
session: "live", ribbon: "mock", overview: "mock", accounts: "mock", credentials: "mock",
session: "live", ribbon: "mock", overview: "mock", accounts: "mock", credentials: "live",
switchTargets: "mock", switchStrategies: "mock", performance: "mock",
alerts: "mock", alertDelivery: "mock", logs: "mock",
});
const calls = [];
const fetchJson = async path => { calls.push(path); return structuredClone(session.ok); };
const fetchJson = async path => {
calls.push(path);
return structuredClone(path === "/api/desk/credentials" ? credentials.ok : session.ok);
};
const api = createDeskApi({ forcedScenario: null, delayMs: 0, fetchJson });
const s = await api.session();
assert.deepEqual([s.source, s.scenario, calls], ["live", null, ["/api/session"]]);
Expand All @@ -202,21 +205,23 @@ const acc = await api.accounts();
assert.deepEqual([acc.source, acc.scenario], ["mock", "ok"]);
assert.equal(acc.data.brokers.length, 5);
const cred = await api.credentials();
assert.deepEqual([cred.source, cred.scenario], ["mock", "ok"]);
assert.deepEqual([cred.source, cred.scenario, calls], ["live", null, ["/api/session", "/api/desk/credentials"]]);
assert.ok(cred.data.items.some(item => item.phase === "7"), "fixture covers the 7-day phase");
const st = await api.switchTargets();
assert.deepEqual([st.source, st.scenario], ["mock", "ok"]);
const sc = await api.switchStrategies("schwab/core");
assert.deepEqual([sc.source, sc.scenario], ["mock", "ok"]);
assert.equal(sc.data.target_id, "schwab/core");
assert.equal(sc.data.domain, "us_equity");
assert.equal(calls.length, 1, "mock endpoints never hit the network");
assert.equal(calls.length, 2, "mock endpoints never hit the network; only live sources do");
r.data.tape.length = 0;
assert.ok((await api.ribbon()).data.tape.length > 0, "mock responses are copies");
const forced = createDeskApi({ forcedScenario: "degraded", delayMs: 0, fetchJson });
assert.equal((await forced.session()).source, "mock");
assert.equal((await forced.overview()).data.totals.status, "partial");
assert.equal(calls.length, 1);
calls.length = 0;
assert.equal((await forced.credentials()).source, "mock", "a forced scenario overrides the live sources");
assert.equal(calls.length, 0, "a forced scenario never hits the network");
const failing = createDeskApi({ forcedScenario: "fail", delayMs: 0, fetchJson });
await assert.rejects(failing.overview(), error => error instanceof HttpError && error.status === 503 && error.payload.error === "store_unavailable");
await assert.rejects(failing.switchStrategies("ibkr/tqqq"), HttpError);
Expand Down
107 changes: 107 additions & 0 deletions tests/desk_credentials_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import {
credentialExpiry, credentialItems, credentialPhase, credentialRotatedAt, deskCredentialsResponse,
} from "../web/strategy-switch-console/application/desk/credentials.js";
import { DeskError } from "../web/strategy-switch-console/application/desk/http.js";

const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url));
const ajv = new (require("ajv/dist/2020").default)({ strict: false });
require("ajv-formats")(ajv);
for (const name of ["common", "credentials", "error"]) {
ajv.addSchema(JSON.parse(readFileSync(new URL(`../schemas/desk/${name}.json`, import.meta.url))));
}
const conforms = (name, data) => {
const check = ajv.getSchema(`https://schemas.example.invalid/qsl/desk/${name}.json`);
assert.ok(check(data), ajv.errorsText(check.errors));
};

const record = {
id: "ibkr_flex", metadata_source: "operator_provided", metadata_revision: 4,
expires_at: "2030-01-09T12:00:00.000Z", phase: "7", days_remaining: 7,
severity: "urgent", requires_action: true, delivery: "not_reserved",
};

// §3.20 vocabulary: every bucket the contract can express is passed through.
for (const [phase, action] of [["30", true], ["7", true], ["1", true], ["expired", true],
["not_due", false], ["unknown", false]]) {
assert.equal(credentialPhase(phase), phase);
const [item] = credentialItems({ ...record, phase });
assert.equal(item.phase, phase);
assert.equal(item.requires_action, action, `phase ${phase}`);
conforms("credentials", { ok: true, schema_version: "qsl.desk.credentials.v1", generated_at: new Date().toISOString(), items: [item] });
}
// The maintenance module buckets 14 days; the desk contract has no 14 and the UI
// already folds it into 30 (normalizeCredentialPhase). The backend must apply the
// same rule, or the guard rejects the payload before the UI can normalise it.
assert.equal(credentialPhase("14"), "30");
const folded = credentialItems({ ...record, phase: "14" })[0];
assert.equal(folded.phase, "30");
assert.equal(folded.requires_action, true, "a 14-day credential is inside the action window");
conforms("credentials", { ok: true, schema_version: "qsl.desk.credentials.v1", generated_at: new Date().toISOString(), items: [folded] });
// An out-of-vocabulary phase is unknown, never a neighbouring bucket.
assert.equal(credentialPhase("14d"), "unknown");
assert.equal(credentialPhase(14), "unknown");
assert.equal(credentialPhase(undefined), "unknown");
assert.equal(credentialPhase("5"), "unknown");
const coerced = credentialItems({ ...record, phase: "5" })[0];
assert.equal(coerced.phase, "unknown");
assert.equal(coerced.requires_action, false);

// Absence of metadata is unknown, not "does not expire".
assert.deepEqual(credentialExpiry({ expires_at: null }), { status: "unknown", reason: "never_read" });
assert.deepEqual(credentialExpiry({}), { status: "unknown", reason: "never_read" });
assert.deepEqual(credentialExpiry({ expires_at: "not a date" }), { status: "unknown", reason: "never_read" });
// The record is operator-supplied metadata; the projection must not upgrade it
// into a broker-validated fact.
assert.deepEqual(credentialExpiry(record), {
status: "known", at: "2030-01-09T12:00:00.000Z", source: "operator_provided",
});

// A record without an id proves nothing; surface a failure instead of an empty list.
for (const bad of [null, undefined, [], {}, { id: "" }, { id: 7 }]) {
assert.throws(() => credentialItems(bad), error => error instanceof DeskError && error.status === 502);
}

// A completed rotation is persisted by finalizeCredentialMaintenance and the UI
// renders it; reporting null would claim "Never rotated" after a real rotation.
assert.equal(credentialRotatedAt({ last_rotated_at: "2029-12-01T09:30:00Z" }), "2029-12-01T09:30:00.000Z");
assert.equal(credentialRotatedAt({}), null);
assert.equal(credentialRotatedAt({ last_rotated_at: null }), null);
assert.equal(credentialRotatedAt({ last_rotated_at: "not a date" }), null);
const rotated = credentialItems({ ...record, last_rotated_at: "2029-12-01T09:30:00Z" })[0];
assert.equal(rotated.last_rotated_at, "2029-12-01T09:30:00.000Z");
assert.equal(credentialItems(record)[0].last_rotated_at, null);
conforms("credentials", { ok: true, schema_version: "qsl.desk.credentials.v1", generated_at: new Date().toISOString(), items: [rotated] });

const env = {};
const get = (deps) => deskCredentialsResponse(new Request("https://console.example/api/desk/credentials"), env, deps);
const body = async (deps) => { const response = await get(deps); return [response.status, await response.json()]; };
const okDeps = { auth: async () => ({ allowed: true, login: "synthetic" }), command: async () => record };

// Unauthenticated callers are rejected before the store is touched.
assert.deepEqual(await body({ auth: async () => { throw new DeskError("login_required", 401); }, command: async () => { throw new Error("must not run"); } }),
[401, { ok: false, error: "login required", reason_code: "login_required" }]);
// Store failures are reported as a desk failure, never as an empty healthy list.
const [failedStatus, failed] = await body({ auth: async () => ({ allowed: true }), command: async () => { throw new Error("sqlite exploded at /secret/path"); } });
assert.equal(failedStatus, 502);
assert.deepEqual(failed, { ok: false, error: "read_failed", reason_code: "read_failed" });
// Methods other than GET are not served.
assert.equal((await deskCredentialsResponse(new Request("https://console.example/api/desk/credentials", { method: "POST" }), env, okDeps)).status, 404);

const [status, payload] = await body(okDeps);
assert.equal(status, 200);
conforms("credentials", payload);
assert.equal(payload.schema_version, "qsl.desk.credentials.v1");
assert.equal(payload.items.length, 1);
assert.equal(payload.items[0].id, "ibkr_flex");
assert.equal(payload.items[0].platform, "ibkr");
assert.equal(payload.items[0].kind, "flex_token");
assert.equal(payload.items[0].expiry.at, "2030-01-09T12:00:00.000Z");
assert.equal(payload.items[0].rotation.supported, true);
// The response carries no database revision or delivery bookkeeping.
assert.ok(!JSON.stringify(payload).includes("metadata_revision"));
assert.ok(!JSON.stringify(payload).includes("not_reserved"));

console.log("desk_credentials_validation: PASS");
78 changes: 78 additions & 0 deletions web/strategy-switch-console/application/desk/credentials.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// §3.20 credentials read model.
//
// This is a projection, not a new source of truth: the expiry record already
// lives in the RuntimeInstance SQLite database and is served to the console by
// credentialMaintenanceCommand(). Nothing here reads a broker, and nothing here
// upgrades operator-supplied metadata into a validated credential: `source`
// stays "operator_provided" because that is what the record actually is.
//
// The desk vocabulary is the contract vocabulary, which is coarser than the
// maintenance one: schemas/desk/common.json#/$defs/Phase has no 14, and the desk
// UI already folds it (frontend/src/desk/credentials/model.ts
// normalizeCredentialPhase: "14" -> "30"). Mirror that rule here so the two
// layers cannot disagree, and report anything outside both as unknown rather
// than coercing it into a neighbouring bucket.
import { DeskError, deskResponse, deskErrorResponse } from "./http.js";

const PLATFORM = "ibkr";
const LABEL = "IBKR Flex";
const KIND = "flex_token";
const LIMIT_DAYS = 30;
// The console already exposes rotation for this credential; point at the page
// that owns it instead of inventing a new route.
const ROTATION_ROUTE = "/?page=health#credential-rotation";
const PHASES = new Set(["unknown", "not_due", "30", "7", "1", "expired"]);
const DUE = new Set(["30", "7", "1", "expired"]);

export function credentialPhase(value) {
if (value === "14") return "30";
return typeof value === "string" && PHASES.has(value) ? value : "unknown";
}

/** An expiry we cannot read is `unknown`, never `none`: absence is not a fact. */
export function credentialExpiry(record) {
const raw = typeof record.expires_at === "string" ? record.expires_at : null;
const at = raw === null ? NaN : Date.parse(raw);
if (!Number.isFinite(at)) return { status: "unknown", reason: "never_read" };
return { status: "known", at: new Date(at).toISOString(), source: "operator_provided" };
}

/** A rotation timestamp is recorded only by a completed rotation; otherwise null. */
export function credentialRotatedAt(record) {
const raw = typeof record.last_rotated_at === "string" ? record.last_rotated_at : null;
const at = raw === null ? NaN : Date.parse(raw);
return Number.isFinite(at) ? new Date(at).toISOString() : null;
}

export function credentialItems(record) {
if (!record || typeof record !== "object" || Array.isArray(record) || typeof record.id !== "string" || !record.id) {
throw new DeskError("read_failed", 502);
}
const phase = credentialPhase(record.phase);
return [{
id: record.id,
platform: PLATFORM,
label: LABEL,
kind: KIND,
expiry: credentialExpiry(record),
limit_days: LIMIT_DAYS,
phase,
// §3.20: action follows the phase vocabulary; unknown is not proven due.
requires_action: DUE.has(phase),
rotation: { supported: true, route: ROTATION_ROUTE },
runbook: null,
// A completed rotation is recorded in the same row; never overwrite it with null.
last_rotated_at: credentialRotatedAt(record),
}];
}

export async function deskCredentialsResponse(request, env, deps) {
try {
if (request.method !== "GET") throw new DeskError("not_found", 404);
await deps.auth(request, env);
let record;
try { record = await deps.command(env, { action: "credential_maintenance_read" }); }
catch { throw new DeskError("read_failed", 502); }
return deskResponse("credentials", { items: credentialItems(record) });
} catch (error) { return deskErrorResponse(error); }
}
5 changes: 4 additions & 1 deletion web/strategy-switch-console/credentialMaintenance.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,13 @@ export function initializeCredentialMaintenance(sql) {
}
const key = (p) => JSON.stringify([ID, p.expires_at, p.phase]);
function projection(sql) {
const row = sql.exec("SELECT revision, expires_at FROM credential_maintenance_metadata WHERE id = ?", ID).toArray()[0];
const row = sql.exec("SELECT revision, expires_at, last_rotated_at FROM credential_maintenance_metadata WHERE id = ?", ID).toArray()[0];
const expiry = credentialExpiryProjection(row?.expires_at ?? null);
const p = { id: ID, metadata_source: "operator_provided", metadata_revision: row?.revision ?? 0, expires_at: expiry.expires_at,
phase: expiry.phase, days_remaining: expiry.days_remaining, severity: expiry.severity,
// Recorded only by a completed rotation (finalizeCredentialMaintenance).
// Null means "no completed rotation on file", which is not "never rotated".
last_rotated_at: row?.last_rotated_at ?? null,
requires_action: expiry.phase !== "not_due", delivery: "not_reserved" };
if (DUE.has(p.phase)) p.delivery = sql.exec("SELECT state FROM credential_maintenance_delivery WHERE delivery_key = ?", key(p)).toArray()[0]?.state ?? "not_reserved";
return p;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const DESK_SOURCES: Record<MockEndpoint, DeskSource> = {
ribbon: "mock",
overview: "mock",
accounts: "mock",
credentials: "mock",
credentials: "live",
switchTargets: "mock",
switchStrategies: "mock",
// /api/desk/performance has no backend yet; the benchmark curves follow this source (see benchmarks()).
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"test:contracts": "node ../../tests/desk_contract_validation.mjs",
"test:operations": "node ../../tests/desk_helpers_validation.mjs && node ../../tests/desk_readback_validation.mjs && npm --prefix frontend run test:operations"
"test:operations": "node ../../tests/desk_helpers_validation.mjs && node ../../tests/desk_readback_validation.mjs && node ../../tests/desk_credentials_validation.mjs && npm --prefix frontend run test:operations"
},
"devDependencies": {
"ajv": "8.17.1",
Expand Down
42 changes: 21 additions & 21 deletions web/strategy-switch-console/v2_asset_map.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { performanceBenchmarkResponse } from "./application/console_api/performa
import { binanceDetailCommand } from "./binance_detail_archive.js";
import { createDeskAuth, DeskError, deskErrorResponse } from "./application/desk/http.js";
import { DeskReadbackStore, deskReadbackResponse, buildReadback, readbackTargets } from "./application/desk/readback.js";
import { deskCredentialsResponse } from "./application/desk/credentials.js";
import { auditPageQuery, auditEventInPage, auditEventOrder, auditNextCursor } from "./application/console_api/audit_pagination.js";
import { runtimeReadbackProjection } from "./application/console_api/runtime_readback.js";
import { initialEnrollmentBindingsMatch, initialEnrollmentDigest } from "./application/console_api/initial_enrollment.js";
Expand Down Expand Up @@ -868,6 +869,9 @@ export default {
runControls: consoleRunControls,
});
}
if (url.pathname === "/api/desk/credentials") {
return await deskCredentialsResponse(request, env, { auth: deskAuth, command: runtimeInstanceCommand });
}
if (url.pathname.startsWith("/api/desk/targets/") || url.pathname === "/api/desk/readbacks" || url.pathname.startsWith("/api/desk/readbacks/")) {
return await deskReadbackResponse(request, env, {
auth: deskAuth, command: runtimeInstanceCommand, options: loadAccountOptionsConfig,
Expand Down
Loading