From 68eb5e22fb2a42abec19e5ae1cedec1e3fbbead5 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:55:16 +0530 Subject: [PATCH 01/21] feat(node): session auth for the dashboard First-run setup, password login with PBKDF2 sessions, CSRF double-submit, and admin/viewer roles, persisted via new Queue settings-KV accessors under the cross-SDK auth key layout. Shared-token mode stays as a legacy option. --- sdks/node/src/cli/commands/dashboard.ts | 18 +- sdks/node/src/contrib/express.ts | 9 +- sdks/node/src/contrib/fastify.ts | 9 +- sdks/node/src/dashboard/authHandlers.ts | 126 +++++ sdks/node/src/dashboard/authStore.ts | 468 +++++++++++++++++++ sdks/node/src/dashboard/errors.ts | 16 + sdks/node/src/dashboard/handlers.ts | 12 +- sdks/node/src/dashboard/index.ts | 41 +- sdks/node/src/dashboard/requestContext.ts | 67 +++ sdks/node/src/dashboard/routes.ts | 77 ++- sdks/node/src/dashboard/server.ts | 192 +++++++- sdks/node/src/dashboard/testing.ts | 34 ++ sdks/node/src/index.ts | 10 +- sdks/node/src/queue.ts | 20 + sdks/node/test/dashboard/server.test.ts | 90 ++-- sdks/node/test/dashboard/sessionAuth.test.ts | 226 +++++++++ 16 files changed, 1343 insertions(+), 72 deletions(-) create mode 100644 sdks/node/src/dashboard/authHandlers.ts create mode 100644 sdks/node/src/dashboard/authStore.ts create mode 100644 sdks/node/src/dashboard/errors.ts create mode 100644 sdks/node/src/dashboard/requestContext.ts create mode 100644 sdks/node/src/dashboard/testing.ts create mode 100644 sdks/node/test/dashboard/sessionAuth.test.ts diff --git a/sdks/node/src/cli/commands/dashboard.ts b/sdks/node/src/cli/commands/dashboard.ts index 7775c0fa..f740cc48 100644 --- a/sdks/node/src/cli/commands/dashboard.ts +++ b/sdks/node/src/cli/commands/dashboard.ts @@ -4,20 +4,34 @@ import { serveDashboard } from "../../dashboard"; import { connect, type GlobalOptions } from "../connect"; import { positiveIntFlag } from "../parse"; +interface DashboardFlags { + port?: string; + host?: string; + token?: string; + insecureCookies?: boolean; +} + export function registerDashboard(program: Command): void { program .command("dashboard") .description("Serve the web dashboard") .option("-p, --port ", "port to listen on", "8787") .option("--host ", "host to bind", "127.0.0.1") - .action(async (options: { port?: string; host?: string }, command: Command) => { + .option("--token ", "legacy shared-token gate (disables the login flow)") + .option("--insecure-cookies", "drop the Secure cookie attribute for plain-HTTP dev") + .action(async (options: DashboardFlags, command: Command) => { const queue = connect(command.optsWithGlobals() as GlobalOptions); const host = options.host ?? "127.0.0.1"; const port = positiveIntFlag(options.port, "port") ?? 8787; if (port > 65535) { throw new Error(`--port must be <= 65535, got ${port}`); } - const server = serveDashboard(queue, { port, host }); + const server = serveDashboard(queue, { + port, + host, + auth: options.token ? { token: options.token } : undefined, + secureCookies: options.insecureCookies ? false : undefined, + }); // Confirm the bind succeeded before reporting success (e.g. port in use). await Promise.race([ once(server, "listening"), diff --git a/sdks/node/src/contrib/express.ts b/sdks/node/src/contrib/express.ts index c3bd416c..ae78ebe3 100644 --- a/sdks/node/src/contrib/express.ts +++ b/sdks/node/src/contrib/express.ts @@ -20,6 +20,10 @@ export type TaskitoRouterOptions = RestOptions; export interface TaskitoDashboardOptions { /** Path to the built SPA assets (defaults to the package's bundled `static/dashboard`). */ staticDir?: string; + /** Legacy shared-token gate. When set, the session-auth login flow is disabled. */ + auth?: { token: string }; + /** Mark session cookies `Secure` (default true). Disable only for plain-HTTP dev. */ + secureCookies?: boolean; } /** @@ -70,7 +74,10 @@ export function taskitoDashboard( queue: Queue, options: TaskitoDashboardOptions = {}, ): RequestHandler { - const handler = createDashboardHandler(queue, options.staticDir ?? defaultStaticDir()); + const handler = createDashboardHandler(queue, options.staticDir ?? defaultStaticDir(), { + auth: options.auth, + secureCookies: options.secureCookies, + }); return (req, res) => { handler(req, res); }; diff --git a/sdks/node/src/contrib/fastify.ts b/sdks/node/src/contrib/fastify.ts index 574203b1..48fcfb6e 100644 --- a/sdks/node/src/contrib/fastify.ts +++ b/sdks/node/src/contrib/fastify.ts @@ -22,6 +22,10 @@ export interface TaskitoDashboardPluginOptions { queue: Queue; /** Path to the built SPA assets (defaults to the package's bundled `static/dashboard`). */ staticDir?: string; + /** Legacy shared-token gate. When set, the session-auth login flow is disabled. */ + auth?: { token: string }; + /** Mark session cookies `Secure` (default true). Disable only for plain-HTTP dev. */ + secureCookies?: boolean; } /** @@ -66,7 +70,10 @@ export const taskitoDashboardPlugin: FastifyPluginAsync { - const handler = createDashboardHandler(options.queue, options.staticDir ?? defaultStaticDir()); + const handler = createDashboardHandler(options.queue, options.staticDir ?? defaultStaticDir(), { + auth: options.auth, + secureCookies: options.secureCookies, + }); const prefix = fastify.prefix; // Leave the request body stream intact so the dashboard handler can read POST bodies. diff --git a/sdks/node/src/dashboard/authHandlers.ts b/sdks/node/src/dashboard/authHandlers.ts new file mode 100644 index 00000000..5cd6c121 --- /dev/null +++ b/sdks/node/src/dashboard/authHandlers.ts @@ -0,0 +1,126 @@ +// Session-auth route handlers: setup, login, logout, whoami, change-password. +// Cookie side effects (set/clear) are applied by the server, which reads the +// session off login results and redacts the raw token from the JSON body. + +import type { Queue } from "../index"; +import { AuthStore, type DashboardSession, type DashboardUser } from "./authStore"; +import { badRequest, notFound } from "./errors"; +import type { RequestContext } from "./requestContext"; + +function requireField(body: unknown, key: string): string { + const value = (body as Record | undefined)?.[key]; + if (typeof value !== "string" || !value) { + throw badRequest(`missing or empty field '${key}'`); + } + return value; +} + +function serializeUser(user: DashboardUser) { + return { + username: user.username, + role: user.role, + created_at: user.createdAt, + last_login_at: user.lastLoginAt, + }; +} + +function serializeSession(session: DashboardSession) { + return { + username: session.username, + role: session.role, + expires_at: session.expiresAt, + csrf_token: session.csrfToken, + }; +} + +/** Public: whether the SPA should show the first-run setup page. */ +export function authStatus(queue: Queue) { + return { setup_required: new AuthStore(queue).countUsers() === 0 }; +} + +/** Create the first admin user. Only callable when zero users exist. */ +export async function setup(queue: Queue, body: unknown) { + const store = new AuthStore(queue); + if (store.countUsers() > 0) { + throw badRequest("setup already complete"); + } + const username = requireField(body, "username"); + const password = requireField(body, "password"); + let user: DashboardUser; + try { + user = await store.createUser(username, password, "admin"); + } catch (error) { + throw badRequest(error instanceof Error ? error.message : "invalid input"); + } + return { user: serializeUser(user) }; +} + +/** + * Verify credentials and create a session. The same generic error covers + * unknown user and bad password so neither is revealed. The `token` field is + * consumed by the server (session cookie) and stripped from the response. + */ +export async function login(queue: Queue, body: unknown) { + const store = new AuthStore(queue); + if (store.countUsers() === 0) { + throw badRequest("setup_required"); + } + const username = requireField(body, "username"); + const password = requireField(body, "password"); + const user = await store.authenticate(username, password); + if (!user) { + throw badRequest("invalid_credentials"); + } + const session = store.createSession(user); + return { + user: serializeUser(user), + session: { ...serializeSession(session), token: session.token }, + }; +} + +/** Invalidate the current session. Idempotent. */ +export function logout(queue: Queue, ctx: RequestContext) { + if (ctx.session) { + new AuthStore(queue).deleteSession(ctx.session.token); + } + return { ok: true }; +} + +/** The current user, or 404 `not_authenticated` without a valid session. */ +export function whoami(queue: Queue, ctx: RequestContext) { + if (!ctx.session) { + throw notFound("not_authenticated"); + } + const store = new AuthStore(queue); + const user = store.getUser(ctx.session.username); + if (!user) { + // Session valid but user deleted — invalidate and treat as logged out. + store.deleteSession(ctx.session.token); + throw notFound("not_authenticated"); + } + return { + user: serializeUser(user), + csrf_token: ctx.session.csrfToken, + expires_at: ctx.session.expiresAt, + }; +} + +/** Change the current user's password. Requires the old password. */ +export async function changePassword(queue: Queue, body: unknown, ctx: RequestContext) { + if (!ctx.session) { + throw badRequest("not_authenticated"); + } + const oldPassword = requireField(body, "old_password"); + const newPassword = requireField(body, "new_password"); + const store = new AuthStore(queue); + const user = await store.authenticate(ctx.session.username, oldPassword); + if (!user) { + throw badRequest("invalid_credentials"); + } + try { + await store.updatePassword(user.username, newPassword); + } catch (error) { + throw badRequest(error instanceof Error ? error.message : "invalid input"); + } + return { ok: true }; +} diff --git a/sdks/node/src/dashboard/authStore.ts b/sdks/node/src/dashboard/authStore.ts new file mode 100644 index 00000000..910164c8 --- /dev/null +++ b/sdks/node/src/dashboard/authStore.ts @@ -0,0 +1,468 @@ +// Authentication primitives for the dashboard. +// +// Users and sessions are persisted through the queue's settings key/value +// store — the same store that backs webhooks and dashboard settings — so the +// feature works uniformly across SQLite, Postgres, and Redis with no new +// tables. The key layout and hash format follow the cross-SDK contract: +// +// - `auth:users` — JSON object `{username: {password_hash, role, ...}}` +// - `auth:session:` — JSON object describing one active session +// +// Password hashes use PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP +// 2023+ baseline), encoded as `pbkdf2_sha256$$$`. + +import { pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"; +import { promisify } from "node:util"; +import type { Queue } from "../index"; +import { createLogger } from "../utils"; + +const log = createLogger("dashboard"); +const pbkdf2 = promisify(pbkdf2Cb); + +// ── Storage keys ──────────────────────────────────────────────────────── + +export const USERS_KEY = "auth:users"; +export const SESSION_PREFIX = "auth:session:"; + +// ── Crypto parameters ─────────────────────────────────────────────────── + +export const PBKDF2_ITERATIONS = 600_000; +const PBKDF2_SALT_BYTES = 16; +const PBKDF2_HASH_BYTES = 32; +const SESSION_TOKEN_BYTES = 32; + +/** Session lifetime: 24 hours (seconds). */ +export const DEFAULT_SESSION_TTL_SECONDS = 24 * 60 * 60; + +// ── Validation ────────────────────────────────────────────────────────── + +const USERNAME_MAX_LEN = 64; +const PASSWORD_MIN_LEN = 8; +const PASSWORD_MAX_LEN = 256; +const VALID_ROLES = new Set(["admin", "viewer"]); +const USERNAME_RE = /^[\p{L}\p{N}._-]+$/u; + +/** Sentinel `password_hash` prefix for OAuth-only users — password login always fails. */ +export const OAUTH_PASSWORD_HASH_PREFIX = "oauth:"; + +// Fixed hash used to keep authentication timing constant for unknown users. +const DUMMY_HASH = + "pbkdf2_sha256$600000$" + + "00000000000000000000000000000000$" + + "0000000000000000000000000000000000000000000000000000000000000000"; + +// ── Password hashing ──────────────────────────────────────────────────── + +/** Hash a password with PBKDF2-HMAC-SHA256 into a self-describing string. */ +export async function hashPassword(password: string): Promise { + const salt = randomBytes(PBKDF2_SALT_BYTES); + const digest = await pbkdf2(password, salt, PBKDF2_ITERATIONS, PBKDF2_HASH_BYTES, "sha256"); + return `pbkdf2_sha256$${PBKDF2_ITERATIONS}$${salt.toString("hex")}$${digest.toString("hex")}`; +} + +/** Constant-time verify of a password against an encoded hash. */ +export async function verifyPassword(password: string, encoded: string): Promise { + if (encoded.startsWith(OAUTH_PASSWORD_HASH_PREFIX)) { + return false; + } + const parts = encoded.split("$"); + if (parts.length !== 4 || parts[0] !== "pbkdf2_sha256") { + return false; + } + const iterations = Number(parts[1]); + if (!Number.isInteger(iterations) || iterations <= 0) { + return false; + } + let salt: Buffer; + let expected: Buffer; + try { + salt = Buffer.from(parts[2] ?? "", "hex"); + expected = Buffer.from(parts[3] ?? "", "hex"); + } catch { + return false; + } + if (expected.length === 0) { + return false; + } + const candidate = await pbkdf2(password, salt, iterations, expected.length, "sha256"); + return candidate.length === expected.length && timingSafeEqual(candidate, expected); +} + +/** Cryptographically secure URL-safe session token. */ +export function generateSessionToken(): string { + return randomBytes(SESSION_TOKEN_BYTES).toString("base64url"); +} + +// ── Types ─────────────────────────────────────────────────────────────── + +/** A persisted dashboard user. */ +export interface DashboardUser { + username: string; + passwordHash: string; + role: string; + createdAt: number; + lastLoginAt: number | null; + email: string | null; + displayName: string | null; +} + +/** An active dashboard session. */ +export interface DashboardSession { + token: string; + username: string; + role: string; + createdAt: number; + expiresAt: number; + csrfToken: string; +} + +export const isOauthUser = (user: DashboardUser): boolean => + user.passwordHash.startsWith(OAUTH_PASSWORD_HASH_PREFIX); + +export const sessionExpired = (session: DashboardSession, now = nowSeconds()): boolean => + now >= session.expiresAt; + +const nowSeconds = (): number => Math.floor(Date.now() / 1000); + +// ── Validation helpers ────────────────────────────────────────────────── + +function validateUsername(username: string): void { + if (!username) { + throw new Error("username must not be empty"); + } + if (username.length > USERNAME_MAX_LEN) { + throw new Error(`username must be <= ${USERNAME_MAX_LEN} chars`); + } + if (!USERNAME_RE.test(username)) { + throw new Error("username may only contain letters, digits, '.', '_', or '-'"); + } +} + +function validatePassword(password: string): void { + if (password.length < PASSWORD_MIN_LEN) { + throw new Error(`password must be >= ${PASSWORD_MIN_LEN} chars`); + } + if (password.length > PASSWORD_MAX_LEN) { + throw new Error(`password must be <= ${PASSWORD_MAX_LEN} chars`); + } +} + +function validateRole(role: string): void { + if (!VALID_ROLES.has(role)) { + throw new Error(`role must be one of ${[...VALID_ROLES].sort().join(", ")}`); + } +} + +/** + * Role for a freshly-created OAuth user. Any path to `admin` requires a + * verified email. An explicit admin list wins over the first-user-wins + * fallback; with no list, the very first user becomes `admin`. + */ +export function oauthBootstrapRole(options: { + email: string | null; + emailVerified: boolean; + adminEmails: readonly string[]; + userTableEmpty: boolean; +}): string { + if (!options.emailVerified || !options.email) { + return "viewer"; + } + const normalised = options.email.toLowerCase(); + if (options.adminEmails.length > 0) { + return options.adminEmails.some((e) => e.toLowerCase() === normalised) ? "admin" : "viewer"; + } + return options.userTableEmpty ? "admin" : "viewer"; +} + +// ── Persisted row shapes (cross-SDK snake_case JSON) ──────────────────── + +interface UserRow { + password_hash: string; + role: string; + created_at: number; + last_login_at: number | null; + email?: string | null; + display_name?: string | null; +} + +interface SessionRow { + username: string; + role: string; + created_at: number; + expires_at: number; + csrf_token: string; +} + +// ── Auth store ────────────────────────────────────────────────────────── + +/** Read/write users and sessions through the queue's settings store. */ +export class AuthStore { + constructor(private readonly queue: Queue) {} + + // ── Users ───────────────────────────────────────────────────────── + + private loadUsers(): Record { + const raw = this.queue.getSetting(USERS_KEY); + if (!raw) { + return {}; + } + try { + const data = JSON.parse(raw); + return data && typeof data === "object" && !Array.isArray(data) ? data : {}; + } catch { + log.warn(() => "auth:users entry is not valid JSON; treating as empty"); + return {}; + } + } + + private saveUsers(users: Record): void { + this.queue.setSetting(USERS_KEY, JSON.stringify(users)); + } + + countUsers(): number { + return Object.keys(this.loadUsers()).length; + } + + listUsers(): DashboardUser[] { + return Object.entries(this.loadUsers()).map(([name, row]) => rowToUser(name, row)); + } + + getUser(username: string): DashboardUser | undefined { + const row = this.loadUsers()[username]; + return row ? rowToUser(username, row) : undefined; + } + + async createUser(username: string, password: string, role = "admin"): Promise { + validateUsername(username); + validatePassword(password); + validateRole(role); + const users = this.loadUsers(); + if (users[username]) { + throw new Error(`user '${username}' already exists`); + } + users[username] = { + password_hash: await hashPassword(password), + role, + created_at: Date.now(), + last_login_at: null, + }; + this.saveUsers(users); + return rowToUser(username, users[username]); + } + + async updatePassword(username: string, newPassword: string): Promise { + validatePassword(newPassword); + const users = this.loadUsers(); + const row = users[username]; + if (!row) { + throw new Error(`user '${username}' does not exist`); + } + row.password_hash = await hashPassword(newPassword); + this.saveUsers(users); + } + + deleteUser(username: string): boolean { + const users = this.loadUsers(); + if (!users[username]) { + return false; + } + delete users[username]; + this.saveUsers(users); + return true; + } + + /** The user iff username+password match; updates `last_login_at`. */ + async authenticate(username: string, password: string): Promise { + const users = this.loadUsers(); + const row = users[username]; + if (!row) { + // Dummy verify keeps timing constant for unknown vs. known usernames. + await verifyPassword(password, DUMMY_HASH); + return undefined; + } + if (!(await verifyPassword(password, row.password_hash))) { + return undefined; + } + row.last_login_at = Date.now(); + this.saveUsers(users); + return rowToUser(username, row); + } + + // ── OAuth users ─────────────────────────────────────────────────── + + /** + * Look up or create the user row backing an OAuth identity. Username is + * `:`. On first sight the role comes from + * {@link oauthBootstrapRole}; later logins refresh email/display name only. + */ + getOrCreateOauthUser(options: { + slot: string; + subject: string; + email: string | null; + name: string | null; + emailVerified: boolean; + adminEmails?: readonly string[]; + }): DashboardUser { + const username = `${options.slot}:${options.subject}`; + const users = this.loadUsers(); + const existing = users[username]; + if (existing) { + if (options.email && existing.email !== options.email) { + existing.email = options.email; + } + if (options.name && existing.display_name !== options.name) { + existing.display_name = options.name; + } + existing.last_login_at = Date.now(); + this.saveUsers(users); + return rowToUser(username, existing); + } + const role = oauthBootstrapRole({ + email: options.email, + emailVerified: options.emailVerified, + adminEmails: options.adminEmails ?? [], + userTableEmpty: Object.keys(users).length === 0, + }); + const now = Date.now(); + users[username] = { + password_hash: `${OAUTH_PASSWORD_HASH_PREFIX}${options.slot}`, + role, + created_at: now, + last_login_at: now, + email: options.email, + display_name: options.name, + }; + this.saveUsers(users); + return rowToUser(username, users[username]); + } + + // ── Sessions ────────────────────────────────────────────────────── + + createSession(user: DashboardUser, ttlSeconds = DEFAULT_SESSION_TTL_SECONDS): DashboardSession { + const now = nowSeconds(); + const session: DashboardSession = { + token: generateSessionToken(), + username: user.username, + role: user.role, + createdAt: now, + expiresAt: now + ttlSeconds, + csrfToken: generateSessionToken(), + }; + const row: SessionRow = { + username: session.username, + role: session.role, + created_at: session.createdAt, + expires_at: session.expiresAt, + csrf_token: session.csrfToken, + }; + this.queue.setSetting(SESSION_PREFIX + session.token, JSON.stringify(row)); + return session; + } + + getSession(token: string): DashboardSession | undefined { + if (!token) { + return undefined; + } + const raw = this.queue.getSetting(SESSION_PREFIX + token); + if (!raw) { + return undefined; + } + let row: SessionRow; + try { + row = JSON.parse(raw); + } catch { + return undefined; + } + if ( + typeof row?.username !== "string" || + typeof row.role !== "string" || + typeof row.expires_at !== "number" || + typeof row.csrf_token !== "string" + ) { + return undefined; + } + const session: DashboardSession = { + token, + username: row.username, + role: row.role, + createdAt: row.created_at ?? 0, + expiresAt: row.expires_at, + csrfToken: row.csrf_token, + }; + if (sessionExpired(session)) { + this.deleteSession(token); + return undefined; + } + return session; + } + + deleteSession(token: string): boolean { + return token ? this.queue.deleteSetting(SESSION_PREFIX + token) : false; + } + + /** Best-effort cleanup of expired session entries. Returns count removed. */ + pruneExpiredSessions(): number { + const now = nowSeconds(); + let removed = 0; + for (const [key, value] of Object.entries(this.queue.listSettings())) { + if (!key.startsWith(SESSION_PREFIX)) { + continue; + } + let expiresAt: number; + try { + expiresAt = Number(JSON.parse(value)?.expires_at ?? 0); + } catch { + continue; + } + if (!Number.isFinite(expiresAt) || expiresAt <= now) { + this.queue.deleteSetting(key); + removed += 1; + } + } + return removed; + } +} + +function rowToUser(username: string, row: UserRow): DashboardUser { + return { + username, + passwordHash: row.password_hash, + role: row.role, + createdAt: Number(row.created_at) || 0, + lastLoginAt: + row.last_login_at === null || row.last_login_at === undefined + ? null + : Number(row.last_login_at), + email: typeof row.email === "string" && row.email ? row.email : null, + displayName: typeof row.display_name === "string" && row.display_name ? row.display_name : null, + }; +} + +// ── Bootstrap from environment ────────────────────────────────────────── + +/** + * Idempotently create the first admin from `TASKITO_DASHBOARD_ADMIN_USER` / + * `TASKITO_DASHBOARD_ADMIN_PASSWORD`. The password is removed from the + * process environment immediately after it is read so it cannot later be + * harvested from `/proc//environ` or a crash reporter. + */ +export async function bootstrapAdminFromEnv(queue: Queue): Promise { + const username = process.env.TASKITO_DASHBOARD_ADMIN_USER; + const password = process.env.TASKITO_DASHBOARD_ADMIN_PASSWORD; + delete process.env.TASKITO_DASHBOARD_ADMIN_PASSWORD; + if (!username || !password) { + return undefined; + } + const store = new AuthStore(queue); + if (store.getUser(username)) { + return undefined; + } + try { + const user = await store.createUser(username, password, "admin"); + log.info(() => `bootstrapped admin user '${username}' from environment`); + return user; + } catch (error) { + log.warn(() => `failed to bootstrap admin '${username}' from env: ${String(error)}`); + return undefined; + } +} diff --git a/sdks/node/src/dashboard/errors.ts b/sdks/node/src/dashboard/errors.ts new file mode 100644 index 00000000..961b4fe9 --- /dev/null +++ b/sdks/node/src/dashboard/errors.ts @@ -0,0 +1,16 @@ +// Typed HTTP errors thrown by dashboard handlers; the server maps them to +// JSON error responses without leaking internals. + +/** Handler-level error carrying an HTTP status and a stable error code. */ +export class DashboardError extends Error { + readonly status: number; + + constructor(status: number, code: string) { + super(code); + this.name = "DashboardError"; + this.status = status; + } +} + +export const badRequest = (code: string): DashboardError => new DashboardError(400, code); +export const notFound = (code: string): DashboardError => new DashboardError(404, code); diff --git a/sdks/node/src/dashboard/handlers.ts b/sdks/node/src/dashboard/handlers.ts index 60c17f54..a013efeb 100644 --- a/sdks/node/src/dashboard/handlers.ts +++ b/sdks/node/src/dashboard/handlers.ts @@ -229,11 +229,12 @@ function parseWebhookInput(body: unknown): Partial { return input; } -// Open-mode auth (no login): the minimal boot responses the SPA needs. -export function authStatus() { +// Token-mode auth boot responses: with a shared-token gate there is no user +// store, so the SPA gets a fixed identity once past the token check. +export function openAuthStatus() { return { setup_required: false }; } -export function whoami() { +export function openWhoami() { return { user: { username: "viewer", role: "admin", created_at: 0, last_login_at: 0 }, csrf_token: "open", @@ -241,6 +242,11 @@ export function whoami() { }; } +/** Available login providers. OAuth providers are added when configured. */ +export function authProviders() { + return { password_enabled: true, providers: [] }; +} + export function cancel(queue: Queue, id: string) { return { cancelled: queue.cancelJob(id) || queue.requestCancel(id) }; } diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts index 7cbb8c28..4dcde5ea 100644 --- a/sdks/node/src/dashboard/index.ts +++ b/sdks/node/src/dashboard/index.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import type { Queue } from "../index"; import { createLogger } from "../utils"; import type { DashboardAuth } from "./auth"; +import { bootstrapAdminFromEnv } from "./authStore"; import { createDashboardServer } from "./server"; const log = createLogger("dashboard"); @@ -15,10 +16,16 @@ export interface DashboardOptions { /** Path to the built SPA assets. Defaults to the package's bundled `static/dashboard`. */ staticDir?: string; /** - * Require a bearer token on every `/api/*` request (except `/api/auth/status`). - * Omit for open mode. Open `/?token=` once to use the SPA behind it. + * Legacy shared-token gate: require this bearer token on every `/api/*` + * request (except `/api/auth/status`). When set, the session-auth login + * flow is disabled. Open `/?token=` once to use the SPA behind it. */ auth?: DashboardAuth; + /** + * Mark session cookies `Secure` so browsers only send them over HTTPS + * (default true). Disable only for plain-HTTP development. + */ + secureCookies?: boolean; } // Built relative to dist/ at runtime. The path is assembled dynamically so the @@ -28,14 +35,22 @@ const defaultStaticDir = (): string => fileURLToPath(new URL(STATIC_REL, import. /** * Start the web dashboard over `queue` — serves the React SPA plus a JSON API. + * Without `auth`, the dashboard runs the full session flow: first-run setup, + * password login, CSRF protection, and admin/viewer roles. An initial admin + * can be bootstrapped from `TASKITO_DASHBOARD_ADMIN_USER` / + * `TASKITO_DASHBOARD_ADMIN_PASSWORD`. * Returns the listening HTTP server; call `.close()` to stop it. */ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Server { - const server = createDashboardServer( - queue, - options.staticDir ?? defaultStaticDir(), - options.auth, - ); + if (!options.auth) { + void bootstrapAdminFromEnv(queue).catch((error) => { + log.warn(() => `admin bootstrap failed: ${String(error)}`); + }); + } + const server = createDashboardServer(queue, options.staticDir ?? defaultStaticDir(), { + auth: options.auth, + secureCookies: options.secureCookies, + }); // A bind failure (e.g. EADDRINUSE) without an 'error' listener crashes the process. server.on("error", (error) => { log.error(() => "dashboard server error", error); @@ -45,4 +60,14 @@ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Se } export type { DashboardAuth } from "./auth"; -export { createDashboardHandler, createDashboardServer } from "./server"; +export { + AuthStore, + bootstrapAdminFromEnv, + type DashboardSession, + type DashboardUser, +} from "./authStore"; +export { + createDashboardHandler, + createDashboardServer, + type DashboardHandlerOptions, +} from "./server"; diff --git a/sdks/node/src/dashboard/requestContext.ts b/sdks/node/src/dashboard/requestContext.ts new file mode 100644 index 00000000..57d43419 --- /dev/null +++ b/sdks/node/src/dashboard/requestContext.ts @@ -0,0 +1,67 @@ +// Per-request authentication context: session + CSRF material parsed from +// the incoming request, handed to handlers that need the calling user. + +import type { IncomingMessage } from "node:http"; +import type { DashboardSession } from "./authStore"; + +// Session cookie: HttpOnly + SameSite=Strict — never readable from JS. +export const SESSION_COOKIE = "taskito_session"; +// CSRF cookie: NOT HttpOnly — the SPA reads it and echoes it in the header. +export const CSRF_COOKIE = "taskito_csrf"; +export const CSRF_HEADER = "x-csrf-token"; + +/** Auth state attached to a single HTTP request. */ +export interface RequestContext { + session: DashboardSession | undefined; + csrfCookie: string | undefined; + csrfHeader: string | undefined; +} + +export const isAuthenticated = (ctx: RequestContext): boolean => ctx.session !== undefined; + +/** + * Double-submit cookie check: a non-empty CSRF cookie, a matching + * `X-CSRF-Token` header, and both equal to the session's stored CSRF token + * (defends against an attacker who pre-seeds the cookie). + */ +export function csrfValid(ctx: RequestContext): boolean { + if (!ctx.session || !ctx.csrfCookie || !ctx.csrfHeader) { + return false; + } + return ctx.csrfCookie === ctx.csrfHeader && ctx.csrfCookie === ctx.session.csrfToken; +} + +/** Parse a raw `Cookie:` header; first value wins for duplicate names. */ +export function parseCookies(header: string | undefined): Map { + const cookies = new Map(); + if (!header) { + return cookies; + } + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) { + continue; + } + const name = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (name && !cookies.has(name)) { + cookies.set(name, value); + } + } + return cookies; +} + +/** Build the request context from raw headers and a session resolver. */ +export function buildContext( + req: IncomingMessage, + resolveSession: (token: string) => DashboardSession | undefined, +): RequestContext { + const cookies = parseCookies(req.headers.cookie); + const token = cookies.get(SESSION_COOKIE) ?? ""; + const header = req.headers[CSRF_HEADER]; + return { + session: token ? resolveSession(token) : undefined, + csrfCookie: cookies.get(CSRF_COOKIE), + csrfHeader: typeof header === "string" ? header : undefined, + }; +} diff --git a/sdks/node/src/dashboard/routes.ts b/sdks/node/src/dashboard/routes.ts index 113f3cc7..763fe1d3 100644 --- a/sdks/node/src/dashboard/routes.ts +++ b/sdks/node/src/dashboard/routes.ts @@ -1,18 +1,89 @@ import type { Queue } from "../index"; +import * as auth from "./authHandlers"; import * as h from "./handlers"; +import type { RequestContext } from "./requestContext"; export interface Route { method: string; pattern: RegExp; - handle: (queue: Queue, url: URL, params: string[], body: unknown) => unknown | Promise; + handle: ( + queue: Queue, + url: URL, + params: string[], + body: unknown, + ctx: RequestContext, + ) => unknown | Promise; +} + +// ── Auth policy ───────────────────────────────────────────────────────── + +/** Exact paths that bypass the session/CSRF gate. */ +export const PUBLIC_PATHS = new Set([ + "/api/auth/status", + "/api/auth/login", + "/api/auth/setup", + "/api/auth/providers", + "/health", + "/readiness", + "/metrics", +]); + +/** Prefixes that bypass auth — OAuth paths carry a provider slot in the URL. */ +export const PUBLIC_PATH_PREFIXES = ["/api/auth/oauth/start/", "/api/auth/oauth/callback/"]; + +/** State-changing paths a non-admin may call on their own session. */ +const SELF_SERVICE_PATHS = new Set(["/api/auth/logout", "/api/auth/change-password"]); + +/** Paths exempt from CSRF because no session exists yet. */ +const CSRF_EXEMPT_PATHS = new Set(["/api/auth/login", "/api/auth/setup"]); + +export function isPublicPath(path: string): boolean { + return PUBLIC_PATHS.has(path) || PUBLIC_PATH_PREFIXES.some((p) => path.startsWith(p)); +} + +export function isStateChangingMethod(method: string): boolean { + return method === "POST" || method === "PUT" || method === "DELETE" || method === "PATCH"; +} + +export function isCsrfExempt(path: string): boolean { + return CSRF_EXEMPT_PATHS.has(path); +} + +export function requiresAdmin(path: string, method: string): boolean { + return isStateChangingMethod(method) && !SELF_SERVICE_PATHS.has(path); } const id = (params: string[]): string => params[0] ?? ""; /** Route table, walked in order: exact paths first, then parameterized ones. */ export const routes: Route[] = [ - { method: "GET", pattern: /^\/api\/auth\/status$/, handle: () => h.authStatus() }, - { method: "GET", pattern: /^\/api\/auth\/whoami$/, handle: () => h.whoami() }, + { method: "GET", pattern: /^\/api\/auth\/status$/, handle: (q) => auth.authStatus(q) }, + { + method: "GET", + pattern: /^\/api\/auth\/whoami$/, + handle: (q, _u, _p, _b, c) => auth.whoami(q, c), + }, + { + method: "POST", + pattern: /^\/api\/auth\/setup$/, + handle: (q, _u, _p, body) => auth.setup(q, body), + }, + { + method: "POST", + pattern: /^\/api\/auth\/login$/, + handle: (q, _u, _p, body) => auth.login(q, body), + }, + { + method: "POST", + pattern: /^\/api\/auth\/logout$/, + handle: (q, _u, _p, _b, c) => auth.logout(q, c), + }, + { + method: "POST", + pattern: /^\/api\/auth\/change-password$/, + handle: (q, _u, _p, body, c) => auth.changePassword(q, body, c), + }, + { method: "GET", pattern: /^\/api\/auth\/providers$/, handle: () => h.authProviders() }, { method: "GET", pattern: /^\/api\/stats$/, handle: (q) => h.stats(q) }, { method: "GET", pattern: /^\/api\/stats\/queues$/, handle: (q, url) => h.statsQueues(q, url) }, { method: "GET", pattern: /^\/api\/queues\/paused$/, handle: (q) => h.queuesPaused(q) }, diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index 7ea954d8..dbcf2b13 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -1,4 +1,11 @@ // Dashboard HTTP dispatch: /api/* -> JSON handlers, everything else -> the SPA. +// +// Two auth modes: +// - Session mode (default): full login flow — first-run setup, password +// sessions, CSRF double-submit, and admin/viewer roles, all persisted in +// the queue's settings store. +// - Token mode (legacy, `auth: {token}`): a single shared token gates the +// API; the SPA gets a fixed identity once past the token check. import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { Queue } from "../index"; @@ -11,7 +18,17 @@ import { setTokenCookie, tokenMatches, } from "./auth"; -import { routes } from "./routes"; +import { AuthStore } from "./authStore"; +import { DashboardError } from "./errors"; +import { openAuthStatus, openWhoami } from "./handlers"; +import { + buildContext, + CSRF_COOKIE, + csrfValid, + type RequestContext, + SESSION_COOKIE, +} from "./requestContext"; +import { isCsrfExempt, isPublicPath, isStateChangingMethod, requiresAdmin, routes } from "./routes"; import { StaticAssets } from "./static"; const log = createLogger("dashboard"); @@ -19,21 +36,39 @@ const log = createLogger("dashboard"); /** Max request body size (1 MiB) — reject larger payloads to bound memory. */ const MAX_BODY_BYTES = 1024 * 1024; +/** Options accepted by {@link createDashboardHandler} / {@link createDashboardServer}. */ +export interface DashboardHandlerOptions { + /** Legacy shared-token gate. When set, the session-auth flow is disabled. */ + auth?: DashboardAuth; + /** Mark session cookies `Secure` (default true). Disable only for plain-HTTP dev. */ + secureCookies?: boolean; +} + +/** Accept the pre-options `DashboardAuth` third argument for compatibility. */ +function normalizeOptions( + options?: DashboardHandlerOptions | DashboardAuth, +): DashboardHandlerOptions { + if (options && "token" in options && typeof options.token === "string") { + return { auth: options }; + } + return (options as DashboardHandlerOptions | undefined) ?? {}; +} + /** * Build a Node `http` request handler that serves the dashboard SPA from `staticDir` * plus the `/api/*` JSON contract over `queue`. Use this to mount the dashboard into an * existing server (e.g. an Express or Fastify app); {@link createDashboardServer} wraps - * it in a standalone server. When `auth` is given, every `/api/*` request (except - * `/api/auth/status`) requires the token. + * it in a standalone server. */ export function createDashboardHandler( queue: Queue, staticDir: string, - auth?: DashboardAuth, + options?: DashboardHandlerOptions | DashboardAuth, ): (req: IncomingMessage, res: ServerResponse) => void { const assets = new StaticAssets(staticDir); + const resolved = normalizeOptions(options); return (req, res) => { - void dispatch(queue, assets, req, res, auth).catch((error) => { + void dispatch(queue, assets, req, res, resolved).catch((error) => { log.error(() => "dashboard dispatch failed", error); if (!res.headersSent) { sendJson(res, 500, { error: "internal server error" }); @@ -46,9 +81,9 @@ export function createDashboardHandler( export function createDashboardServer( queue: Queue, staticDir: string, - auth?: DashboardAuth, + options?: DashboardHandlerOptions | DashboardAuth, ): Server { - return createServer(createDashboardHandler(queue, staticDir, auth)); + return createServer(createDashboardHandler(queue, staticDir, options)); } async function dispatch( @@ -56,13 +91,14 @@ async function dispatch( assets: StaticAssets, req: IncomingMessage, res: ServerResponse, - auth: DashboardAuth | undefined, + options: DashboardHandlerOptions, ): Promise { const url = new URL(req.url ?? "/", "http://localhost"); const path = url.pathname; + const auth = options.auth; - // A valid `?token=` bootstraps an httpOnly cookie so the SPA's later API calls - // authenticate without re-passing the token. + // Token mode: a valid `?token=` bootstraps an httpOnly cookie so the SPA's + // later API calls authenticate without re-passing the token. if (auth && url.searchParams.get("token") && tokenMatches(auth.token, presentedToken(req, url))) { setTokenCookie(res, auth.token); } @@ -75,11 +111,55 @@ async function dispatch( return; } - if (auth && !isPublicApiPath(path) && !tokenMatches(auth.token, presentedToken(req, url))) { - sendJson(res, 401, { error: "unauthorized" }); + if (auth) { + await dispatchTokenMode(queue, req, res, url, path, auth); return; } + const store = new AuthStore(queue); + const ctx = buildContext(req, (token) => store.getSession(token)); + const denied = authorize(store, ctx, path, req.method ?? "GET"); + if (denied) { + sendJson(res, denied.status, { error: denied.code }); + return; + } + await runRoute(queue, req, res, url, path, ctx, options.secureCookies !== false); +} + +/** The session/CSRF/role gate. Returns the denial, or undefined to proceed. */ +function authorize( + store: AuthStore, + ctx: RequestContext, + path: string, + method: string, +): { status: number; code: string } | undefined { + if (isPublicPath(path)) { + return undefined; + } + if (store.countUsers() === 0) { + return { status: 503, code: "setup_required" }; + } + if (!ctx.session) { + return { status: 401, code: "not_authenticated" }; + } + if (isStateChangingMethod(method) && !isCsrfExempt(path) && !csrfValid(ctx)) { + return { status: 403, code: "csrf_failed" }; + } + if (requiresAdmin(path, method) && ctx.session.role !== "admin") { + return { status: 403, code: "forbidden" }; + } + return undefined; +} + +async function runRoute( + queue: Queue, + req: IncomingMessage, + res: ServerResponse, + url: URL, + path: string, + ctx: RequestContext, + secureCookies: boolean, +): Promise { for (const route of routes) { if (route.method !== req.method) { continue; @@ -91,16 +171,17 @@ async function dispatch( const params = match.slice(1).map((value) => decodeURIComponent(value ?? "")); const body = req.method === "POST" || req.method === "PUT" ? await readBody(req) : undefined; try { - const result = await route.handle(queue, url, params, body); + const result = await route.handle(queue, url, params, body, ctx); if (result === undefined) { sendJson(res, 404, { error: "not found" }); return; } - if (path === "/api/auth/whoami") { - setAuthCookies(res); - } - sendJson(res, 200, result); + sendJson(res, 200, applyAuthCookies(res, path, result, secureCookies)); } catch (error) { + if (error instanceof DashboardError) { + sendJson(res, error.status, { error: error.message }); + return; + } if (error instanceof WebhookValidationError) { sendJson(res, 400, { error: error.message }); return; @@ -115,6 +196,79 @@ async function dispatch( sendJson(res, 404, { error: "not found" }); } +/** + * Cookie side effects for auth flows: a successful login sets the session + + * CSRF cookies (and the raw token is redacted from the JSON body); logout + * clears them. + */ +function applyAuthCookies( + res: ServerResponse, + path: string, + result: unknown, + secure: boolean, +): unknown { + if (path === "/api/auth/logout") { + res.setHeader("set-cookie", clearSessionCookies(secure)); + return result; + } + if (path !== "/api/auth/login") { + return result; + } + const login = result as { + session?: { token?: string; csrf_token?: string; expires_at?: number }; + }; + const session = login.session; + if (!session?.token || !session.csrf_token) { + return result; + } + const ttl = Math.max(0, (session.expires_at ?? 0) - Math.floor(Date.now() / 1000)); + res.setHeader("set-cookie", sessionCookies(session.token, session.csrf_token, ttl, secure)); + const { token: _redacted, ...rest } = session; + return { ...login, session: rest }; +} + +function sessionCookies(token: string, csrf: string, ttl: number, secure: boolean): string[] { + const attrs = `SameSite=Strict; Path=/; Max-Age=${ttl}${secure ? "; Secure" : ""}`; + return [`${SESSION_COOKIE}=${token}; HttpOnly; ${attrs}`, `${CSRF_COOKIE}=${csrf}; ${attrs}`]; +} + +function clearSessionCookies(secure: boolean): string[] { + const attrs = `SameSite=Strict; Path=/; Max-Age=0${secure ? "; Secure" : ""}`; + return [`${SESSION_COOKIE}=; HttpOnly; ${attrs}`, `${CSRF_COOKIE}=; ${attrs}`]; +} + +/** Legacy shared-token dispatch: stub auth boot endpoints + token-gated API. */ +async function dispatchTokenMode( + queue: Queue, + req: IncomingMessage, + res: ServerResponse, + url: URL, + path: string, + auth: DashboardAuth, +): Promise { + if (!isPublicApiPath(path) && !tokenMatches(auth.token, presentedToken(req, url))) { + sendJson(res, 401, { error: "unauthorized" }); + return; + } + if (path.startsWith("/api/auth/")) { + if (path === "/api/auth/status") { + sendJson(res, 200, openAuthStatus()); + } else if (path === "/api/auth/whoami") { + setOpenAuthCookies(res); + sendJson(res, 200, openWhoami()); + } else { + sendJson(res, 404, { error: "not found" }); + } + return; + } + const openCtx: RequestContext = { + session: undefined, + csrfCookie: undefined, + csrfHeader: undefined, + }; + await runRoute(queue, req, res, url, path, openCtx, true); +} + /** Read and JSON-parse a request body (undefined when empty, invalid, or oversized). * Caps total size at {@link MAX_BODY_BYTES} and resolves on stream error/abort. */ function readBody(req: IncomingMessage): Promise { @@ -155,8 +309,8 @@ function readBody(req: IncomingMessage): Promise { }); } -/** Open-mode session/CSRF cookies so the SPA proceeds without a login. */ -function setAuthCookies(res: ServerResponse): void { +/** Token-mode session/CSRF cookies so the SPA proceeds without a login. */ +function setOpenAuthCookies(res: ServerResponse): void { res.setHeader("set-cookie", [ "taskito_session=open; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400", "taskito_csrf=open; SameSite=Strict; Path=/; Max-Age=86400", diff --git a/sdks/node/src/dashboard/testing.ts b/sdks/node/src/dashboard/testing.ts new file mode 100644 index 00000000..1fec5034 --- /dev/null +++ b/sdks/node/src/dashboard/testing.ts @@ -0,0 +1,34 @@ +// Test seam for session-authenticated dashboard requests. Internal — used by +// the SDK's own tests; not exported from the package barrel. + +import type { Queue } from "../index"; +import { AuthStore, type DashboardSession } from "./authStore"; + +export interface SeededAuth { + session: DashboardSession; + /** Headers carrying the session cookie + CSRF pair for state-changing calls. */ + headers: Record; +} + +/** Create an admin (or given-role) user plus a live session for tests. */ +export async function seedAdminAndSession( + queue: Queue, + options: { username?: string; password?: string; role?: string } = {}, +): Promise { + const store = new AuthStore(queue); + const user = await store.createUser( + options.username ?? "admin", + options.password ?? "password123", + options.role ?? "admin", + ); + const session = store.createSession(user); + return { session, headers: authedHeaders(session) }; +} + +/** Cookie + CSRF headers for an existing session. */ +export function authedHeaders(session: DashboardSession): Record { + return { + cookie: `taskito_session=${session.token}; taskito_csrf=${session.csrfToken}`, + "x-csrf-token": session.csrfToken, + }; +} diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts index 678a21f4..d28103ab 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -1,5 +1,13 @@ export { currentJob, type JobContext } from "./context"; -export { type DashboardAuth, type DashboardOptions, serveDashboard } from "./dashboard"; +export { + AuthStore, + bootstrapAdminFromEnv, + type DashboardAuth, + type DashboardOptions, + type DashboardSession, + type DashboardUser, + serveDashboard, +} from "./dashboard"; export { CryptoError, InterceptionError, diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index 5601b730..72c359c6 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -659,6 +659,26 @@ export class Queue { return this.native.listPausedQueues(); } + /** Read a dashboard settings key, or `null` when unset. */ + getSetting(key: string): string | null { + return this.native.getSetting(key); + } + + /** Write a dashboard settings key. */ + setSetting(key: string, value: string): void { + this.native.setSetting(key, value); + } + + /** Delete a dashboard settings key. Returns false if it didn't exist. */ + deleteSetting(key: string): boolean { + return this.native.deleteSetting(key); + } + + /** All dashboard settings as a key → value record. */ + listSettings(): Record { + return this.native.listSettings(); + } + /** Registered workers (heartbeat + identity). */ listWorkers(): Promise { return this.native.listWorkers(); diff --git a/sdks/node/test/dashboard/server.test.ts b/sdks/node/test/dashboard/server.test.ts index 924b9316..b17c6081 100644 --- a/sdks/node/test/dashboard/server.test.ts +++ b/sdks/node/test/dashboard/server.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { seedAdminAndSession } from "../../src/dashboard/testing"; import { Queue, serveDashboard, type Worker } from "../../src/index"; const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -18,8 +19,22 @@ beforeAll(() => { } }, 120_000); +/** Start a session-mode dashboard with a seeded admin session. */ +async function startDash(queue: Queue): Promise<{ + base: string; + headers: Record; + server: Server; +}> { + const { headers } = await seedAdminAndSession(queue); + const server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false }); + await once(server, "listening"); + const address = server.address() as AddressInfo; + return { base: `http://127.0.0.1:${address.port}`, headers, server }; +} + let server: Server | undefined; let base = ""; +let headers: Record = {}; beforeEach(async () => { const db = join(mkdtempSync(join(tmpdir(), "taskito-dash-")), "q.db"); @@ -27,10 +42,7 @@ beforeEach(async () => { queue.task("add", (a: number, b: number) => a + b); queue.enqueue("add", [1, 2]); queue.pauseQueue("emails"); - server = serveDashboard(queue, { port: 0, staticDir }); - await once(server, "listening"); - const address = server.address() as AddressInfo; - base = `http://127.0.0.1:${address.port}`; + ({ base, headers, server } = await startDash(queue)); }); afterEach(() => { @@ -39,12 +51,14 @@ afterEach(() => { }); it("serves queue stats", async () => { - const stats = (await (await fetch(`${base}/api/stats`)).json()) as { pending: number }; + const stats = (await (await fetch(`${base}/api/stats`, { headers })).json()) as { + pending: number; + }; expect(stats.pending).toBe(1); }); it("serves jobs in the snake_case SPA contract", async () => { - const jobs = (await (await fetch(`${base}/api/jobs`)).json()) as Array<{ + const jobs = (await (await fetch(`${base}/api/jobs`, { headers })).json()) as Array<{ task_name: string; created_at: number; }>; @@ -53,19 +67,22 @@ it("serves jobs in the snake_case SPA contract", async () => { }); it("exposes paused queues", async () => { - const paused = await (await fetch(`${base}/api/queues/paused`)).json(); + const paused = await (await fetch(`${base}/api/queues/paused`, { headers })).json(); expect(paused).toContain("emails"); }); -it("fakes open-mode auth so the SPA boots", async () => { +it("answers whoami for the seeded session", async () => { const status = (await (await fetch(`${base}/api/auth/status`)).json()) as { setup_required: boolean; }; expect(status.setup_required).toBe(false); - const who = await fetch(`${base}/api/auth/whoami`); - expect(who.headers.get("set-cookie") ?? "").toMatch(/taskito_csrf/); - const body = (await who.json()) as { user: { role: string } }; - expect(body.user.role).toBe("admin"); + const who = (await (await fetch(`${base}/api/auth/whoami`, { headers })).json()) as { + user: { username: string; role: string }; + csrf_token: string; + }; + expect(who.user.username).toBe("admin"); + expect(who.user.role).toBe("admin"); + expect(who.csrf_token.length).toBeGreaterThan(10); }); it("serves the SPA shell with deep-link fallback", async () => { @@ -78,21 +95,23 @@ it("creates and lists webhooks via the dashboard api", async () => { const created = (await ( await fetch(`${base}/api/webhooks`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ url: "http://example.com/h", events: ["job.completed"], secret: "x" }), }) ).json()) as { id: string; has_secret: boolean; secret: string | null }; expect(created.has_secret).toBe(true); expect(created.secret).toBe("x"); - const list = (await (await fetch(`${base}/api/webhooks`)).json()) as Array<{ + const list = (await (await fetch(`${base}/api/webhooks`, { headers })).json()) as Array<{ id: string; secret?: unknown; }>; expect(list.map((w) => w.id)).toContain(created.id); expect(list[0]?.secret).toBeUndefined(); - const eventTypes = (await (await fetch(`${base}/api/event-types`)).json()) as string[]; + const eventTypes = (await ( + await fetch(`${base}/api/event-types`, { headers }) + ).json()) as string[]; expect(eventTypes).toContain("job.completed"); }); @@ -101,12 +120,12 @@ it("lists a running worker", async () => { const queue = new Queue({ dbPath: db }); queue.task("noop", () => null); const worker: Worker = queue.runWorker({ queues: ["default"] }); - const srv = serveDashboard(queue, { port: 0, staticDir }); - await once(srv, "listening"); - const { port } = srv.address() as AddressInfo; + const dash = await startDash(queue); try { - const workers = (await (await fetch(`http://127.0.0.1:${port}/api/workers`)).json()) as Array<{ + const workers = (await ( + await fetch(`${dash.base}/api/workers`, { headers: dash.headers }) + ).json()) as Array<{ pool_type: string; worker_id: string; }>; @@ -115,7 +134,7 @@ it("lists a running worker", async () => { expect(typeof workers[0]?.worker_id).toBe("string"); } finally { worker.stop(); - srv.close(); + dash.server.close(); } }); @@ -125,16 +144,14 @@ it("aggregates metrics after a job completes", async () => { queue.task("add", (a: number, b: number) => a + b); const id = queue.enqueue("add", [2, 3]); const worker: Worker = queue.runWorker(); - const srv = serveDashboard(queue, { port: 0, staticDir }); - await once(srv, "listening"); - const { port } = srv.address() as AddressInfo; + const dash = await startDash(queue); try { await queue.result(id); let metrics: Record = {}; for (let i = 0; i < 60 && metrics.add === undefined; i++) { metrics = (await ( - await fetch(`http://127.0.0.1:${port}/api/metrics?since=3600`) + await fetch(`${dash.base}/api/metrics?since=3600`, { headers: dash.headers }) ).json()) as Record; if (metrics.add === undefined) { await new Promise((resolve) => setTimeout(resolve, 25)); @@ -144,7 +161,7 @@ it("aggregates metrics after a job completes", async () => { expect(metrics.add?.success_count).toBeGreaterThanOrEqual(1); } finally { worker.stop(); - srv.close(); + dash.server.close(); } }); @@ -159,27 +176,30 @@ it("serves workflow runs, detail, dag, and children", async () => { .step("b", "b", { after: "a" }) .submit(); const worker: Worker = queue.runWorker(); - const srv = serveDashboard(queue, { port: 0, staticDir }); - await once(srv, "listening"); - const { port } = srv.address() as AddressInfo; - const root = `http://127.0.0.1:${port}/api/workflows/runs`; + const dash = await startDash(queue); + const root = `${dash.base}/api/workflows/runs`; + const headers_ = dash.headers; try { await handle.wait({ timeoutMs: 8000 }); - const list = (await (await fetch(root)).json()) as { + const list = (await (await fetch(root, { headers: headers_ })).json()) as { runs: Array<{ id: string; state: string }>; }; expect(list.runs.find((r) => r.id === handle.runId)?.state).toBe("completed"); - const detail = (await (await fetch(`${root}/${handle.runId}`)).json()) as { + const detail = (await ( + await fetch(`${root}/${handle.runId}`, { headers: headers_ }) + ).json()) as { run: { state: string }; nodes: Array<{ node_name: string; status: string }>; }; expect(detail.run.state).toBe("completed"); expect(detail.nodes.map((n) => n.node_name).sort()).toEqual(["a", "b"]); - const dag = (await (await fetch(`${root}/${handle.runId}/dag`)).json()) as { dag: string }; + const dag = (await ( + await fetch(`${root}/${handle.runId}/dag`, { headers: headers_ }) + ).json()) as { dag: string }; const parsed = JSON.parse(dag.dag) as { edges: Array<{ from: string; to: string; weight: number }>; nodes: Array<{ name: string; node_name: string; status: string; id: string; deps: string[] }>; @@ -193,12 +213,14 @@ it("serves workflow runs, detail, dag, and children", async () => { expect(b?.id).not.toBe("b"); // resolved to the job id, not the node name expect(parsed.nodes.find((n) => n.name === "a")?.deps).toEqual([]); - const children = (await (await fetch(`${root}/${handle.runId}/children`)).json()) as { + const children = (await ( + await fetch(`${root}/${handle.runId}/children`, { headers: headers_ }) + ).json()) as { children: unknown[]; }; expect(children.children).toEqual([]); } finally { worker.stop(); - srv.close(); + dash.server.close(); } }); diff --git a/sdks/node/test/dashboard/sessionAuth.test.ts b/sdks/node/test/dashboard/sessionAuth.test.ts new file mode 100644 index 00000000..2bc6f56f --- /dev/null +++ b/sdks/node/test/dashboard/sessionAuth.test.ts @@ -0,0 +1,226 @@ +import { execSync } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + AuthStore, + bootstrapAdminFromEnv, + hashPassword, + verifyPassword, +} from "../../src/dashboard/authStore"; +import { authedHeaders, seedAdminAndSession } from "../../src/dashboard/testing"; +import { Queue, serveDashboard } from "../../src/index"; + +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const staticDir = join(pkgRoot, "static", "dashboard"); + +beforeAll(() => { + if (!existsSync(join(staticDir, "index.html"))) { + execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 120_000); + +let server: Server | undefined; +let queue: Queue; +let base = ""; + +beforeEach(async () => { + const db = join(mkdtempSync(join(tmpdir(), "taskito-dashsess-")), "q.db"); + queue = new Queue({ dbPath: db }); + queue.task("add", (a: number, b: number) => a + b); + server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false }); + await once(server, "listening"); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(() => { + server?.close(); + server = undefined; +}); + +const post = (path: string, body: unknown, headers: Record = {}) => + fetch(`${base}${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + +describe("password hashing", () => { + it("round-trips and rejects wrong passwords", async () => { + const encoded = await hashPassword("hunter22!"); + expect(encoded).toMatch(/^pbkdf2_sha256\$600000\$[0-9a-f]{32}\$[0-9a-f]{64}$/); + expect(await verifyPassword("hunter22!", encoded)).toBe(true); + expect(await verifyPassword("hunter23!", encoded)).toBe(false); + }); + + it("verifies a hash produced by the cross-SDK reference implementation", async () => { + // Golden vector: pbkdf2_hmac('sha256', 'password123', 'saltsaltsaltsalt', + // 1000 iters, 32 bytes). Pins the `pbkdf2_sha256$$$` + // wire format shared across SDK auth stores. + const reference = + "pbkdf2_sha256$1000$73616c7473616c7473616c7473616c74$" + + "9db0df06e36654310bbab81b2e3b7b1bf700b4f8cd1f13a88d64cf9fa7594408"; + expect(await verifyPassword("password123", reference)).toBe(true); + expect(await verifyPassword("password124", reference)).toBe(false); + }); + + it("rejects the oauth sentinel hash", async () => { + expect(await verifyPassword("anything", "oauth:google")).toBe(false); + }); +}); + +describe("setup flow", () => { + it("gates every non-public API route until the first admin exists", async () => { + const res = await fetch(`${base}/api/stats`); + expect(res.status).toBe(503); + expect(((await res.json()) as { error: string }).error).toBe("setup_required"); + + const status = (await (await fetch(`${base}/api/auth/status`)).json()) as { + setup_required: boolean; + }; + expect(status.setup_required).toBe(true); + }); + + it("creates the first admin via setup, then rejects a second setup", async () => { + const created = await post("/api/auth/setup", { username: "root", password: "password123" }); + expect(created.status).toBe(200); + const body = (await created.json()) as { user: { username: string; role: string } }; + expect(body.user).toMatchObject({ username: "root", role: "admin" }); + + const again = await post("/api/auth/setup", { username: "x", password: "password123" }); + expect(again.status).toBe(400); + }); +}); + +describe("login and sessions", () => { + beforeEach(async () => { + await new AuthStore(queue).createUser("root", "password123", "admin"); + }); + + it("logs in, sets session cookies, and redacts the raw token", async () => { + const res = await post("/api/auth/login", { username: "root", password: "password123" }); + expect(res.status).toBe(200); + const cookies = res.headers.getSetCookie(); + expect(cookies.some((c) => c.startsWith("taskito_session=") && c.includes("HttpOnly"))).toBe( + true, + ); + expect(cookies.some((c) => c.startsWith("taskito_csrf=") && !c.includes("HttpOnly"))).toBe( + true, + ); + const body = (await res.json()) as { session: Record }; + expect(body.session.token).toBeUndefined(); + expect(typeof body.session.csrf_token).toBe("string"); + }); + + it("rejects a wrong password and an unknown user with the same error", async () => { + const wrong = await post("/api/auth/login", { username: "root", password: "nope-nope" }); + const unknown = await post("/api/auth/login", { username: "ghost", password: "nope-nope" }); + expect(wrong.status).toBe(400); + expect(unknown.status).toBe(400); + expect(await wrong.json()).toEqual(await unknown.json()); + }); + + it("requires a session for protected GETs and accepts a valid one", async () => { + expect((await fetch(`${base}/api/stats`)).status).toBe(401); + const { headers } = await seedAdminAndSession(queue, { username: "seeded" }); + expect((await fetch(`${base}/api/stats`, { headers })).status).toBe(200); + }); + + it("logout invalidates the session and clears cookies", async () => { + const { session, headers } = await seedAdminAndSession(queue, { username: "seeded" }); + const res = await post("/api/auth/logout", {}, headers); + expect(res.status).toBe(200); + expect(res.headers.getSetCookie().some((c) => c.includes("Max-Age=0"))).toBe(true); + expect(new AuthStore(queue).getSession(session.token)).toBeUndefined(); + }); + + it("whoami self-heals a session whose user was deleted", async () => { + const { headers } = await seedAdminAndSession(queue, { username: "doomed" }); + new AuthStore(queue).deleteUser("doomed"); + expect((await fetch(`${base}/api/auth/whoami`, { headers })).status).toBe(404); + }); +}); + +describe("csrf", () => { + it("rejects state-changing requests without or with a mismatched token", async () => { + const { session } = await seedAdminAndSession(queue); + const cookieOnly = { cookie: `taskito_session=${session.token}` }; + const missing = await post("/api/queues/emails/pause", {}, cookieOnly); + expect(missing.status).toBe(403); + expect(((await missing.json()) as { error: string }).error).toBe("csrf_failed"); + + const mismatched = await post( + "/api/queues/emails/pause", + {}, + { + cookie: `taskito_session=${session.token}; taskito_csrf=${session.csrfToken}`, + "x-csrf-token": "attacker-value", + }, + ); + expect(mismatched.status).toBe(403); + }); + + it("accepts a valid double-submit pair", async () => { + const { headers } = await seedAdminAndSession(queue); + const res = await post("/api/queues/emails/pause", {}, headers); + expect(res.status).toBe(200); + }); +}); + +describe("rbac", () => { + it("blocks viewers from admin actions but allows reads and self-service", async () => { + await new AuthStore(queue).createUser("root", "password123", "admin"); + const { session } = await seedAdminAndSession(queue, { + username: "watcher", + role: "viewer", + }); + const headers = authedHeaders(session); + + expect((await fetch(`${base}/api/stats`, { headers })).status).toBe(200); + + const denied = await post("/api/queues/emails/pause", {}, headers); + expect(denied.status).toBe(403); + expect(((await denied.json()) as { error: string }).error).toBe("forbidden"); + + const changed = await post( + "/api/auth/change-password", + { old_password: "password123", new_password: "password456" }, + headers, + ); + expect(changed.status).toBe(200); + + const logout = await post("/api/auth/logout", {}, headers); + expect(logout.status).toBe(200); + }); +}); + +describe("env bootstrap", () => { + it("creates the admin once and scrubs the password from the environment", async () => { + process.env.TASKITO_DASHBOARD_ADMIN_USER = "envadmin"; + process.env.TASKITO_DASHBOARD_ADMIN_PASSWORD = "password123"; + try { + const user = await bootstrapAdminFromEnv(queue); + expect(user?.username).toBe("envadmin"); + expect(process.env.TASKITO_DASHBOARD_ADMIN_PASSWORD).toBeUndefined(); + // Idempotent: second call is a no-op. + process.env.TASKITO_DASHBOARD_ADMIN_PASSWORD = "password123"; + expect(await bootstrapAdminFromEnv(queue)).toBeUndefined(); + } finally { + delete process.env.TASKITO_DASHBOARD_ADMIN_USER; + delete process.env.TASKITO_DASHBOARD_ADMIN_PASSWORD; + } + }); +}); + +describe("providers listing", () => { + it("is public and password-only until OAuth is configured", async () => { + const res = await fetch(`${base}/api/auth/providers`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ password_enabled: true, providers: [] }); + }); +}); From fa1e55d9df7778cc6366dde75fd6ad6e11719c67 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:00:33 +0530 Subject: [PATCH 02/21] feat(node): settings API, probes, scaler, and job detail routes Adds /api/settings CRUD (protected namespaces hidden), /health + /readiness + Prometheus /metrics with optional bearer gate, the KEDA scaler payload, heartbeat-aggregated /api/resources, job errors/logs routes, and dead-letter purge. --- sdks/node/src/dashboard/handlers.ts | 26 +++ sdks/node/src/dashboard/opsHandlers.ts | 166 +++++++++++++++++++ sdks/node/src/dashboard/routes.ts | 35 ++++ sdks/node/src/dashboard/server.ts | 48 ++++++ sdks/node/src/dashboard/settingsHandlers.ts | 77 +++++++++ sdks/node/test/dashboard/opsSettings.test.ts | 162 ++++++++++++++++++ 6 files changed, 514 insertions(+) create mode 100644 sdks/node/src/dashboard/opsHandlers.ts create mode 100644 sdks/node/src/dashboard/settingsHandlers.ts create mode 100644 sdks/node/test/dashboard/opsSettings.test.ts diff --git a/sdks/node/src/dashboard/handlers.ts b/sdks/node/src/dashboard/handlers.ts index a013efeb..ddf9630e 100644 --- a/sdks/node/src/dashboard/handlers.ts +++ b/sdks/node/src/dashboard/handlers.ts @@ -247,6 +247,32 @@ export function authProviders() { return { password_enabled: true, providers: [] }; } +/** Error history for a job, one row per failed attempt. */ +export async function jobErrors(queue: Queue, id: string) { + return (await queue.getJobErrors(id)).map((e) => ({ + attempt: e.attempt, + error: e.error, + failed_at: e.failedAt, + })); +} + +/** Task-log entries for a job (oldest first). */ +export function jobLogs(queue: Queue, id: string) { + return queue.taskLogs(id).map((l) => ({ + job_id: l.jobId, + task_name: l.taskName, + level: l.level, + message: l.message, + extra: l.extra ?? null, + logged_at: l.loggedAt, + })); +} + +/** Purge every dead-letter entry. */ +export async function purgeDeadLetters(queue: Queue) { + return { purged: await queue.purgeDead(0) }; +} + export function cancel(queue: Queue, id: string) { return { cancelled: queue.cancelJob(id) || queue.requestCancel(id) }; } diff --git a/sdks/node/src/dashboard/opsHandlers.ts b/sdks/node/src/dashboard/opsHandlers.ts new file mode 100644 index 00000000..7d5516be --- /dev/null +++ b/sdks/node/src/dashboard/opsHandlers.ts @@ -0,0 +1,166 @@ +// Operational endpoints: liveness/readiness probes, the KEDA scaler payload, +// and worker-resource health aggregated from heartbeats. + +import type { Queue } from "../index"; + +/** Basic liveness check — always ok. */ +export function health() { + return { status: "ok" }; +} + +/** Readiness: storage reachable, workers alive, resources healthy. */ +export async function readiness(queue: Queue) { + const checks: Record = {}; + let allOk = true; + + try { + await queue.stats(); + checks.storage = "ok"; + } catch (error) { + checks.storage = `error: ${String(error)}`; + allOk = false; + } + + try { + const workers = await queue.listWorkers(); + checks.workers = { count: workers.length, status: workers.length > 0 ? "ok" : "none" }; + } catch (error) { + checks.workers = `error: ${String(error)}`; + allOk = false; + } + + try { + const resources = await resourceStatus(queue); + if (resources.length > 0) { + const unhealthy = resources.filter((r) => r.health !== "healthy").map((r) => r.name); + checks.resources = { + count: resources.length, + unhealthy, + status: unhealthy.length > 0 ? "degraded" : "ok", + }; + if (unhealthy.length > 0) { + allOk = false; + } + } + } catch (error) { + checks.resources = `error: ${String(error)}`; + allOk = false; + } + + return { status: allOk ? "ready" : "degraded", checks }; +} + +export interface ResourceStatusEntry { + name: string; + scope: string; + health: string; + init_duration_ms: number; + recreations: number; + depends_on: string[]; +} + +/** + * Worker-resource health aggregated from heartbeat snapshots: any + * `unhealthy` report wins; mixed healthy/unhealthy is `degraded`; all + * healthy → `healthy`; advertised but not yet reported → `not_initialized`. + */ +export async function resourceStatus(queue: Queue): Promise { + const observed = new Map(); + const advertised = new Set(); + for (const worker of await queue.listWorkers()) { + for (const name of parseJsonArray(worker.resources)) { + advertised.add(name); + } + const report = parseJsonObject(worker.resourceHealth); + for (const [name, healthValue] of Object.entries(report)) { + const entries = observed.get(name) ?? []; + entries.push(String(healthValue).toLowerCase()); + observed.set(name, entries); + } + } + + const names = new Set([...advertised, ...observed.keys()]); + return [...names].sort().map((name) => { + const reports = observed.get(name) ?? []; + let healthState = "not_initialized"; + if (reports.length > 0) { + const unhealthy = reports.filter((r) => r !== "healthy").length; + healthState = + unhealthy === 0 ? "healthy" : unhealthy === reports.length ? "unhealthy" : "degraded"; + } + return { + name, + scope: "worker", + health: healthState, + init_duration_ms: 0, + recreations: 0, + depends_on: [], + }; + }); +} + +/** KEDA-compatible scaler payload for the whole queue or one named queue. */ +export async function scaler(queue: Queue, url: URL) { + const targetQueueDepth = positiveIntOr(url.searchParams.get("target"), 10); + const queueName = url.searchParams.get("queue"); + + const stats = await queue.stats(); + const workers = await queue.listWorkers(); + const totalCapacity = workers.reduce((sum, w) => sum + (w.threads ?? 0), 0); + + const response: Record = { + metricName: "taskito_queue_depth", + metricValue: stats.pending, + isActive: stats.pending > 0, + liveWorkers: workers.length, + totalCapacity, + targetQueueDepth, + }; + if (totalCapacity > 0) { + response.workerUtilization = Math.round((stats.running / totalCapacity) * 1000) / 1000; + } + + if (queueName) { + const queueStats = await queue.statsByQueue(queueName); + response.metricValue = queueStats.pending; + response.isActive = queueStats.pending > 0; + response.metricName = `taskito_queue_depth_${queueName}`; + } + + const perQueue: Record = {}; + for (const [name, s] of Object.entries(await queue.statsAllQueues())) { + perQueue[name] = { pending: s.pending, running: s.running }; + } + response.perQueue = perQueue; + + return response; +} + +function positiveIntOr(value: string | null, fallback: number): number { + const parsed = value === null ? Number.NaN : Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseJsonArray(raw: string | undefined | null): string[] { + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } +} + +function parseJsonObject(raw: string | undefined | null): Record { + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} diff --git a/sdks/node/src/dashboard/routes.ts b/sdks/node/src/dashboard/routes.ts index 763fe1d3..b0000eeb 100644 --- a/sdks/node/src/dashboard/routes.ts +++ b/sdks/node/src/dashboard/routes.ts @@ -1,7 +1,9 @@ import type { Queue } from "../index"; import * as auth from "./authHandlers"; import * as h from "./handlers"; +import * as ops from "./opsHandlers"; import type { RequestContext } from "./requestContext"; +import * as settings from "./settingsHandlers"; export interface Route { method: string; @@ -88,8 +90,41 @@ export const routes: Route[] = [ { method: "GET", pattern: /^\/api\/stats\/queues$/, handle: (q, url) => h.statsQueues(q, url) }, { method: "GET", pattern: /^\/api\/queues\/paused$/, handle: (q) => h.queuesPaused(q) }, { method: "GET", pattern: /^\/api\/jobs$/, handle: (q, url) => h.jobs(q, url) }, + { + method: "GET", + pattern: /^\/api\/jobs\/([^/]+)\/errors$/, + handle: (q, _url, p) => h.jobErrors(q, id(p)), + }, + { + method: "GET", + pattern: /^\/api\/jobs\/([^/]+)\/logs$/, + handle: (q, _url, p) => h.jobLogs(q, id(p)), + }, { method: "GET", pattern: /^\/api\/jobs\/([^/]+)$/, handle: (q, _url, p) => h.job(q, id(p)) }, { method: "GET", pattern: /^\/api\/dead-letters$/, handle: (q, url) => h.deadLetters(q, url) }, + { + method: "POST", + pattern: /^\/api\/dead-letters\/purge$/, + handle: (q) => h.purgeDeadLetters(q), + }, + { method: "GET", pattern: /^\/api\/settings$/, handle: (q) => settings.listSettings(q) }, + { + method: "GET", + pattern: /^\/api\/settings\/(.+)$/, + handle: (q, _url, p) => settings.getSetting(q, id(p)), + }, + { + method: "PUT", + pattern: /^\/api\/settings\/(.+)$/, + handle: (q, _url, p, body) => settings.putSetting(q, id(p), body), + }, + { + method: "DELETE", + pattern: /^\/api\/settings\/(.+)$/, + handle: (q, _url, p) => settings.deleteSetting(q, id(p)), + }, + { method: "GET", pattern: /^\/api\/scaler$/, handle: (q, url) => ops.scaler(q, url) }, + { method: "GET", pattern: /^\/api\/resources$/, handle: (q) => ops.resourceStatus(q) }, { method: "GET", pattern: /^\/api\/metrics$/, handle: (q, url) => h.metrics(q, url) }, { method: "GET", diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index dbcf2b13..8133482a 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -21,6 +21,7 @@ import { import { AuthStore } from "./authStore"; import { DashboardError } from "./errors"; import { openAuthStatus, openWhoami } from "./handlers"; +import { health, readiness } from "./opsHandlers"; import { buildContext, CSRF_COOKIE, @@ -103,6 +104,11 @@ async function dispatch( setTokenCookie(res, auth.token); } + if (path === "/health" || path === "/readiness" || path === "/metrics") { + await serveProbe(queue, req, res, path); + return; + } + if (!path.startsWith("/api/")) { if (assets.serve(path, res)) { return; @@ -309,6 +315,48 @@ function readBody(req: IncomingMessage): Promise { }); } +/** + * Probe endpoints outside the session gate. `/health` is always public; + * `/readiness` and `/metrics` are optionally gated by + * `TASKITO_DASHBOARD_METRICS_TOKEN` as a bearer token. + */ +async function serveProbe( + queue: Queue, + req: IncomingMessage, + res: ServerResponse, + path: string, +): Promise { + if (path === "/health") { + sendJson(res, 200, health()); + return; + } + if (!metricsTokenOk(req)) { + sendJson(res, 401, { error: "unauthorized" }); + return; + } + if (path === "/readiness") { + sendJson(res, 200, await readiness(queue)); + return; + } + try { + const { register } = await import("prom-client"); + const body = await register.metrics(); + res.writeHead(200, { "content-type": register.contentType }); + res.end(body); + } catch { + sendJson(res, 501, { error: "prom-client not installed" }); + } +} + +/** Probe-friendly by default; a set env token requires a matching bearer header. */ +function metricsTokenOk(req: IncomingMessage): boolean { + const token = process.env.TASKITO_DASHBOARD_METRICS_TOKEN; + if (!token) { + return true; + } + return tokenMatches(`Bearer ${token}`, req.headers.authorization ?? ""); +} + /** Token-mode session/CSRF cookies so the SPA proceeds without a login. */ function setOpenAuthCookies(res: ServerResponse): void { res.setHeader("set-cookie", [ diff --git a/sdks/node/src/dashboard/settingsHandlers.ts b/sdks/node/src/dashboard/settingsHandlers.ts new file mode 100644 index 00000000..ddc5226d --- /dev/null +++ b/sdks/node/src/dashboard/settingsHandlers.ts @@ -0,0 +1,77 @@ +// Dashboard settings (key/value config) route handlers. Values are opaque +// strings to the storage layer; the SPA stores JSON blobs (branding, +// integrations, external links). + +import type { Queue } from "../index"; +import { badRequest, notFound } from "./errors"; + +const MAX_KEY_LENGTH = 256; +const MAX_VALUE_LENGTH = 64 * 1024; // 64 KiB — enough for any dashboard config blob + +// Internal namespaces that must never be exposed or mutated through the +// public settings API. `auth:` holds password hashes and live sessions +// (reading or overwriting these is a full auth bypass); `webhook`-prefixed +// keys hold subscription rows with plaintext HMAC secrets plus delivery +// logs. Protected keys are treated as absent. +const PROTECTED_PREFIXES = ["auth:", "webhooks:", "webhook:"]; + +const isProtected = (key: string): boolean => PROTECTED_PREFIXES.some((p) => key.startsWith(p)); + +function validateKey(key: string): void { + if (!key) { + throw badRequest("setting key must not be empty"); + } + if (key.length > MAX_KEY_LENGTH) { + throw badRequest(`setting key exceeds ${MAX_KEY_LENGTH} characters`); + } + if ([...key].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) { + throw badRequest("setting key must not contain control characters"); + } + if (isProtected(key)) { + throw badRequest("setting key is reserved"); + } +} + +/** All settings as `{key: value}`, minus protected namespaces. */ +export function listSettings(queue: Queue): Record { + return Object.fromEntries( + Object.entries(queue.listSettings()).filter(([key]) => !isProtected(key)), + ); +} + +/** A single setting, or 404. Protected keys read as absent. */ +export function getSetting(queue: Queue, key: string) { + if (isProtected(key)) { + throw notFound(`setting '${key}' not found`); + } + const value = queue.getSetting(key); + if (value === null) { + throw notFound(`setting '${key}' not found`); + } + return { key, value }; +} + +/** Insert or update a setting from a `PUT` body of `{"value": ...}`. */ +export function putSetting(queue: Queue, key: string, body: unknown) { + validateKey(key); + if (!body || typeof body !== "object" || !("value" in body)) { + throw badRequest("body must be a JSON object with a 'value' field"); + } + const raw = (body as { value: unknown }).value; + // Accept any JSON-serialisable type — re-encode for storage so callers + // don't need to stringify themselves. + const value = typeof raw === "string" ? raw : JSON.stringify(raw); + if (value.length > MAX_VALUE_LENGTH) { + throw badRequest(`setting value exceeds ${MAX_VALUE_LENGTH} bytes`); + } + queue.setSetting(key, value); + return { key, value }; +} + +/** Delete a setting. Returns `{deleted: bool}`. Protected keys read as absent. */ +export function deleteSetting(queue: Queue, key: string) { + if (isProtected(key)) { + throw notFound(`setting '${key}' not found`); + } + return { deleted: queue.deleteSetting(key) }; +} diff --git a/sdks/node/test/dashboard/opsSettings.test.ts b/sdks/node/test/dashboard/opsSettings.test.ts new file mode 100644 index 00000000..836d186a --- /dev/null +++ b/sdks/node/test/dashboard/opsSettings.test.ts @@ -0,0 +1,162 @@ +import { execSync } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { seedAdminAndSession } from "../../src/dashboard/testing"; +import { Queue, serveDashboard } from "../../src/index"; + +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const staticDir = join(pkgRoot, "static", "dashboard"); + +beforeAll(() => { + if (!existsSync(join(staticDir, "index.html"))) { + execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 120_000); + +let server: Server | undefined; +let queue: Queue; +let base = ""; +let headers: Record = {}; + +beforeEach(async () => { + const db = join(mkdtempSync(join(tmpdir(), "taskito-dashops-")), "q.db"); + queue = new Queue({ dbPath: db }); + queue.task("add", (a: number, b: number) => a + b); + ({ headers } = await seedAdminAndSession(queue)); + server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false }); + await once(server, "listening"); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(() => { + server?.close(); + server = undefined; +}); + +const put = (path: string, body: unknown) => + fetch(`${base}${path}`, { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +describe("settings api", () => { + it("round-trips a setting and lists it", async () => { + const created = await put("/api/settings/branding.title", { value: "Ops" }); + expect(created.status).toBe(200); + expect(await created.json()).toEqual({ key: "branding.title", value: "Ops" }); + + const got = (await ( + await fetch(`${base}/api/settings/branding.title`, { headers }) + ).json()) as { + value: string; + }; + expect(got.value).toBe("Ops"); + + const all = (await (await fetch(`${base}/api/settings`, { headers })).json()) as Record< + string, + string + >; + expect(all["branding.title"]).toBe("Ops"); + }); + + it("json-encodes non-string values", async () => { + await put("/api/settings/links", { value: [{ label: "Docs" }] }); + const got = (await (await fetch(`${base}/api/settings/links`, { headers })).json()) as { + value: string; + }; + expect(JSON.parse(got.value)).toEqual([{ label: "Docs" }]); + }); + + it("hides and refuses protected namespaces", async () => { + const all = (await (await fetch(`${base}/api/settings`, { headers })).json()) as Record< + string, + string + >; + expect(Object.keys(all).some((k) => k.startsWith("auth:"))).toBe(false); + + expect((await fetch(`${base}/api/settings/auth:users`, { headers })).status).toBe(404); + expect((await put("/api/settings/auth:users", { value: "{}" })).status).toBe(400); + expect( + (await fetch(`${base}/api/settings/auth:users`, { method: "DELETE", headers })).status, + ).toBe(404); + }); + + it("deletes settings and 404s unknown keys", async () => { + await put("/api/settings/tmp", { value: "x" }); + const del = (await ( + await fetch(`${base}/api/settings/tmp`, { method: "DELETE", headers }) + ).json()) as { deleted: boolean }; + expect(del.deleted).toBe(true); + expect((await fetch(`${base}/api/settings/tmp`, { headers })).status).toBe(404); + }); +}); + +describe("ops endpoints", () => { + it("serves a public health probe", async () => { + const res = await fetch(`${base}/health`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + + it("serves readiness with storage and worker checks", async () => { + const body = (await (await fetch(`${base}/readiness`)).json()) as { + status: string; + checks: { storage: string; workers: { status: string } }; + }; + expect(body.checks.storage).toBe("ok"); + expect(["ready", "degraded"]).toContain(body.status); + }); + + it("gates readiness behind the metrics token when set", async () => { + process.env.TASKITO_DASHBOARD_METRICS_TOKEN = "probe-secret"; + try { + expect((await fetch(`${base}/readiness`)).status).toBe(401); + const ok = await fetch(`${base}/readiness`, { + headers: { authorization: "Bearer probe-secret" }, + }); + expect(ok.status).toBe(200); + } finally { + delete process.env.TASKITO_DASHBOARD_METRICS_TOKEN; + } + }); + + it("serves the KEDA scaler payload", async () => { + queue.enqueue("add", [1, 2]); + const body = (await (await fetch(`${base}/api/scaler`, { headers })).json()) as { + metricName: string; + metricValue: number; + isActive: boolean; + perQueue: Record; + }; + expect(body.metricName).toBe("taskito_queue_depth"); + expect(body.metricValue).toBe(1); + expect(body.isActive).toBe(true); + expect(body.perQueue.default?.pending).toBe(1); + }); + + it("serves resource status from worker heartbeats", async () => { + const body = await (await fetch(`${base}/api/resources`, { headers })).json(); + expect(Array.isArray(body)).toBe(true); + }); + + it("serves job errors and logs for a job", async () => { + const id = queue.enqueue("add", [1, 2]); + const errors = await (await fetch(`${base}/api/jobs/${id}/errors`, { headers })).json(); + expect(errors).toEqual([]); + const logs = await (await fetch(`${base}/api/jobs/${id}/logs`, { headers })).json(); + expect(logs).toEqual([]); + }); + + it("purges dead letters via POST", async () => { + const res = await fetch(`${base}/api/dead-letters/purge`, { method: "POST", headers }); + expect(res.status).toBe(200); + expect(((await res.json()) as { purged: number }).purged).toBe(0); + }); +}); From e385d83861f661784989d6e9dee144bc807760fd Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:04:39 +0530 Subject: [PATCH 03/21] feat(node): persist webhook deliveries, add rotate and replay Delivery history moves from an in-memory ring to the settings KV under the cross-SDK row layout, surviving restarts. Adds delivery detail, delivery replay, and rotate-secret endpoints. --- sdks/node/src/dashboard/handlers.ts | 42 ++++- sdks/node/src/dashboard/routes.ts | 15 ++ sdks/node/src/webhooks/deliveryLog.ts | 138 ++++++++++++++++ sdks/node/src/webhooks/manager.ts | 54 ++++++- .../test/dashboard/webhookDeliveries.test.ts | 150 ++++++++++++++++++ 5 files changed, 387 insertions(+), 12 deletions(-) create mode 100644 sdks/node/src/webhooks/deliveryLog.ts create mode 100644 sdks/node/test/dashboard/webhookDeliveries.test.ts diff --git a/sdks/node/src/dashboard/handlers.ts b/sdks/node/src/dashboard/handlers.ts index ddf9630e..48ae8575 100644 --- a/sdks/node/src/dashboard/handlers.ts +++ b/sdks/node/src/dashboard/handlers.ts @@ -15,6 +15,7 @@ import { workflowNodeToContract, workflowRunToContract, } from "./contract"; +import { badRequest } from "./errors"; import { aggregateByTask, bucketTimeseries } from "./metrics"; /** Finite, non-negative number from a query string, or `undefined`. */ @@ -194,22 +195,47 @@ export async function testWebhook(queue: Queue, id: string) { return delivery ? { status: delivery.responseCode, delivered: delivery.ok } : undefined; } +const DELIVERY_STATUSES = new Set(["delivered", "failed", "dead", "pending"]); +const MAX_DELIVERY_PAGE = 200; + export function webhookDeliveries(queue: Queue, id: string, url: URL) { - const limit = num(url, "limit") ?? 50; + if (!queue.webhooks.get(id)) { + return undefined; + } + const status = url.searchParams.get("status") ?? undefined; + if (status && !DELIVERY_STATUSES.has(status)) { + throw badRequest("status must be one of: delivered, failed, dead, pending"); + } + const event = url.searchParams.get("event") ?? undefined; + const limit = Math.min(num(url, "limit") ?? 50, MAX_DELIVERY_PAGE); const offset = num(url, "offset") ?? 0; - const statusFilter = url.searchParams.get("status"); - const all = queue.webhooks - .deliveries(id) - .filter((delivery) => !statusFilter || delivery.status === statusFilter) - .reverse(); // newest first, matching the Python delivery store + const items = queue.webhooks.deliveries(id, { status, event, limit, offset }); return { - items: all.slice(offset, offset + limit).map(deliveryToContract), - total: all.length, + items: items.map(deliveryToContract), + total: queue.webhooks.deliveryCount(id), limit, offset, }; } +export function webhookDelivery(queue: Queue, id: string, deliveryId: string) { + const delivery = queue.webhooks.delivery(id, deliveryId); + return delivery ? deliveryToContract(delivery) : undefined; +} + +export async function replayWebhookDelivery(queue: Queue, id: string, deliveryId: string) { + const delivery = await queue.webhooks.replayDelivery(id, deliveryId); + return delivery + ? { replayed_of: deliveryId, status: delivery.responseCode, delivered: delivery.ok } + : undefined; +} + +export function rotateWebhookSecret(queue: Queue, id: string) { + const secret = queue.webhooks.rotateSecret(id); + // The new secret is returned exactly once, on rotation. + return secret === undefined ? undefined : { id, secret }; +} + /** Parse a snake_case webhook request body into a {@link WebhookInput}. */ function parseWebhookInput(body: unknown): Partial { const raw = (body ?? {}) as Record; diff --git a/sdks/node/src/dashboard/routes.ts b/sdks/node/src/dashboard/routes.ts index b0000eeb..22b5699c 100644 --- a/sdks/node/src/dashboard/routes.ts +++ b/sdks/node/src/dashboard/routes.ts @@ -189,6 +189,21 @@ export const routes: Route[] = [ pattern: /^\/api\/webhooks\/([^/]+)\/deliveries$/, handle: (q, url, p) => h.webhookDeliveries(q, id(p), url), }, + { + method: "GET", + pattern: /^\/api\/webhooks\/([^/]+)\/deliveries\/([^/]+)$/, + handle: (q, _url, p) => h.webhookDelivery(q, id(p), p[1] ?? ""), + }, + { + method: "POST", + pattern: /^\/api\/webhooks\/([^/]+)\/deliveries\/([^/]+)\/replay$/, + handle: (q, _url, p) => h.replayWebhookDelivery(q, id(p), p[1] ?? ""), + }, + { + method: "POST", + pattern: /^\/api\/webhooks\/([^/]+)\/rotate-secret$/, + handle: (q, _url, p) => h.rotateWebhookSecret(q, id(p)), + }, { method: "GET", pattern: /^\/api\/webhooks\/([^/]+)$/, diff --git a/sdks/node/src/webhooks/deliveryLog.ts b/sdks/node/src/webhooks/deliveryLog.ts new file mode 100644 index 00000000..bc1540e3 --- /dev/null +++ b/sdks/node/src/webhooks/deliveryLog.ts @@ -0,0 +1,138 @@ +// Persistent webhook delivery log. Each subscription keeps a JSON list under +// `webhooks:deliveries:` in the settings store — append-only with FIFO +// eviction at the per-webhook cap, following the cross-SDK row layout +// (snake_case fields, Unix-ms timestamps). + +import type { NativeQueue } from "../native"; +import { createLogger } from "../utils"; +import type { Delivery, DeliveryStatus } from "./types"; + +const DELIVERY_PREFIX = "webhooks:deliveries:"; +const DEFAULT_MAX_PER_WEBHOOK = 200; + +const log = createLogger("webhooks"); + +/** Persisted snake_case row shape (cross-SDK contract). */ +interface DeliveryRow { + id: string; + subscription_id: string; + event: string; + payload: Record; + task_name: string | null; + job_id: string | null; + status: string; + attempts: number; + response_code: number | null; + response_body: string | null; + latency_ms: number | null; + error: string | null; + created_at: number; + completed_at: number | null; +} + +export interface DeliveryFilters { + status?: string; + event?: string; + limit?: number; + offset?: number; +} + +/** List/append delivery records keyed by subscription id. */ +export class DeliveryLog { + constructor( + private readonly native: NativeQueue, + private readonly maxPerWebhook = DEFAULT_MAX_PER_WEBHOOK, + ) {} + + private key(subscriptionId: string): string { + return DELIVERY_PREFIX + subscriptionId; + } + + private load(subscriptionId: string): DeliveryRow[] { + const raw = this.native.getSetting(this.key(subscriptionId)); + if (!raw) { + return []; + } + try { + const data = JSON.parse(raw); + return Array.isArray(data) ? data : []; + } catch { + log.warn(() => `delivery log for ${subscriptionId} is corrupt; resetting`); + return []; + } + } + + /** Append a delivery row, trimming to the per-webhook cap. */ + record(delivery: Delivery): void { + const rows = this.load(delivery.webhookId); + rows.push(toRow(delivery)); + const trimmed = rows.length > this.maxPerWebhook ? rows.slice(-this.maxPerWebhook) : rows; + this.native.setSetting(this.key(delivery.webhookId), JSON.stringify(trimmed)); + } + + /** Recent deliveries, newest first, optionally filtered and paginated. */ + listFor(subscriptionId: string, filters: DeliveryFilters = {}): Delivery[] { + let rows = this.load(subscriptionId).reverse(); + if (filters.status) { + rows = rows.filter((r) => r.status === filters.status); + } + if (filters.event) { + rows = rows.filter((r) => r.event === filters.event); + } + const offset = filters.offset ?? 0; + const limit = filters.limit ?? 50; + return rows.slice(offset, offset + limit).map(fromRow); + } + + get(subscriptionId: string, deliveryId: string): Delivery | undefined { + const row = this.load(subscriptionId).find((r) => r.id === deliveryId); + return row ? fromRow(row) : undefined; + } + + countFor(subscriptionId: string): number { + return this.load(subscriptionId).length; + } + + deleteFor(subscriptionId: string): boolean { + return this.native.deleteSetting(this.key(subscriptionId)); + } +} + +function toRow(delivery: Delivery): DeliveryRow { + return { + id: delivery.id, + subscription_id: delivery.webhookId, + event: delivery.event, + payload: delivery.payload, + task_name: delivery.taskName, + job_id: delivery.jobId, + status: delivery.status, + attempts: delivery.attempts, + response_code: delivery.responseCode, + response_body: delivery.responseBody, + latency_ms: delivery.latencyMs, + error: delivery.error ?? null, + created_at: delivery.createdAt, + completed_at: delivery.completedAt, + }; +} + +function fromRow(row: DeliveryRow): Delivery { + return { + id: String(row.id), + webhookId: String(row.subscription_id), + event: row.event as Delivery["event"], + status: row.status as DeliveryStatus, + ok: row.status === "delivered", + attempts: Number(row.attempts) || 0, + payload: row.payload ?? {}, + taskName: row.task_name ?? null, + jobId: row.job_id ?? null, + responseCode: row.response_code ?? null, + responseBody: row.response_body ?? null, + latencyMs: Number(row.latency_ms) || 0, + error: row.error ?? undefined, + createdAt: Number(row.created_at) || 0, + completedAt: Number(row.completed_at) || 0, + }; +} diff --git a/sdks/node/src/webhooks/manager.ts b/sdks/node/src/webhooks/manager.ts index adebd4bf..e773027e 100644 --- a/sdks/node/src/webhooks/manager.ts +++ b/sdks/node/src/webhooks/manager.ts @@ -1,7 +1,8 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import type { Emitter, EventName, OutcomeEvent } from "../events"; import type { NativeQueue } from "../native"; import { Deliverer } from "./deliverer"; +import { type DeliveryFilters, DeliveryLog } from "./deliveryLog"; import { WebhookValidationError } from "./errors"; import { WebhookStore } from "./store"; import type { Delivery, Webhook, WebhookInput } from "./types"; @@ -31,11 +32,13 @@ function validateWebhook(webhook: Webhook): void { /** Manages webhook subscriptions and delivers job events to them. */ export class WebhookManager { private readonly store: WebhookStore; + private readonly deliveryLog: DeliveryLog; private readonly deliverer = new Deliverer(); private cache?: Webhook[]; constructor(native: NativeQueue, emitter: Emitter) { this.store = new WebhookStore(native); + this.deliveryLog = new DeliveryLog(native); for (const event of ALL_EVENTS) { emitter.on(event, (payload) => this.dispatch(event, payload)); } @@ -95,12 +98,34 @@ export class WebhookManager { delete(id: string): boolean { const removed = this.store.delete(id); + this.deliveryLog.deleteFor(id); this.cache = undefined; return removed; } - deliveries(id: string): Delivery[] { - return this.deliverer.recentFor(id); + /** Replace the signing secret. Returns the new secret (shown exactly once). */ + rotateSecret(id: string): string | undefined { + const existing = this.store.get(id); + if (!existing) { + return undefined; + } + const secret = randomBytes(32).toString("base64url"); + this.store.put({ ...existing, secret, updatedAt: Date.now() }); + this.cache = undefined; + return secret; + } + + /** Persisted deliveries for a webhook, newest first. */ + deliveries(id: string, filters?: DeliveryFilters): Delivery[] { + return this.deliveryLog.listFor(id, filters); + } + + delivery(id: string, deliveryId: string): Delivery | undefined { + return this.deliveryLog.get(id, deliveryId); + } + + deliveryCount(id: string): number { + return this.deliveryLog.countFor(id); } /** Send a synthetic delivery to a webhook (used by the dashboard "test" action). */ @@ -112,6 +137,25 @@ export class WebhookManager { return this.deliverer.deliver(webhook, "job.completed", { jobId: "test", taskName: "test" }); } + /** + * Re-send a stored delivery's original payload as a fresh attempt. The + * replay is logged as a NEW delivery record (tagged `replay_of`) so the + * audit trail is preserved. + */ + async replayDelivery(id: string, deliveryId: string): Promise { + const webhook = this.store.get(id); + const original = this.deliveryLog.get(id, deliveryId); + if (!webhook || !original) { + return undefined; + } + const payload = { ...original.payload, replay_of: original.id } as OutcomeEvent & { + replay_of: string; + }; + const delivery = await this.deliverer.deliver(webhook, original.event, payload); + this.deliveryLog.record(delivery); + return delivery; + } + private dispatch(event: EventName, payload: OutcomeEvent): void { this.cache ??= this.store.list(); for (const webhook of this.cache) { @@ -128,7 +172,9 @@ export class WebhookManager { ) { continue; } - void this.deliverer.deliver(webhook, event, payload); + void this.deliverer + .deliver(webhook, event, payload) + .then((delivery) => this.deliveryLog.record(delivery)); } } } diff --git a/sdks/node/test/dashboard/webhookDeliveries.test.ts b/sdks/node/test/dashboard/webhookDeliveries.test.ts new file mode 100644 index 00000000..5c1dd1b0 --- /dev/null +++ b/sdks/node/test/dashboard/webhookDeliveries.test.ts @@ -0,0 +1,150 @@ +import { execSync } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { seedAdminAndSession } from "../../src/dashboard/testing"; +import { Queue, serveDashboard } from "../../src/index"; + +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const staticDir = join(pkgRoot, "static", "dashboard"); + +beforeAll(() => { + if (!existsSync(join(staticDir, "index.html"))) { + execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 120_000); + +let dash: Server | undefined; +let receiver: Server | undefined; +let queue: Queue; +let base = ""; +let sinkUrl = ""; +let received: unknown[] = []; +let headers: Record = {}; + +beforeEach(async () => { + received = []; + receiver = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + received.push(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + res.writeHead(200).end("ok"); + }); + }); + receiver.listen(0, "127.0.0.1"); + await once(receiver, "listening"); + sinkUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/hook`; + + const db = join(mkdtempSync(join(tmpdir(), "taskito-dashdel-")), "q.db"); + queue = new Queue({ dbPath: db }); + queue.task("noop", () => null); + ({ headers } = await seedAdminAndSession(queue)); + dash = serveDashboard(queue, { port: 0, staticDir, secureCookies: false }); + await once(dash, "listening"); + base = `http://127.0.0.1:${(dash.address() as AddressInfo).port}`; +}); + +afterEach(() => { + dash?.close(); + receiver?.close(); + dash = undefined; + receiver = undefined; +}); + +it("persists test deliveries are not logged but replays and dispatches are", async () => { + const webhook = queue.webhooks.create({ url: sinkUrl, events: ["job.completed"] }); + + // Dispatch through a real job event so the log records it. + const worker = queue.runWorker(); + try { + const id = queue.enqueue("noop", []); + await queue.result(id); + // Delivery is recorded asynchronously after the HTTP roundtrip. + for (let i = 0; i < 200 && queue.webhooks.deliveryCount(webhook.id) === 0; i++) { + await new Promise((r) => setTimeout(r, 25)); + } + } finally { + worker.stop(); + } + + expect(queue.webhooks.deliveryCount(webhook.id)).toBe(1); + + // Survives a fresh Queue handle over the same database. + const list = (await ( + await fetch(`${base}/api/webhooks/${webhook.id}/deliveries`, { headers }) + ).json()) as { items: Array<{ id: string; status: string; event: string }>; total: number }; + expect(list.total).toBe(1); + expect(list.items[0]?.status).toBe("delivered"); + expect(list.items[0]?.event).toBe("job.completed"); + + const deliveryId = list.items[0]?.id ?? ""; + const detail = (await ( + await fetch(`${base}/api/webhooks/${webhook.id}/deliveries/${deliveryId}`, { headers }) + ).json()) as { id: string; response_code: number }; + expect(detail.id).toBe(deliveryId); + expect(detail.response_code).toBe(200); + + // Replay creates a NEW record tagged replay_of. + const replay = (await ( + await fetch(`${base}/api/webhooks/${webhook.id}/deliveries/${deliveryId}/replay`, { + method: "POST", + headers, + }) + ).json()) as { replayed_of: string; delivered: boolean }; + expect(replay.replayed_of).toBe(deliveryId); + expect(replay.delivered).toBe(true); + expect(queue.webhooks.deliveryCount(webhook.id)).toBe(2); + expect((received.at(-1) as { replay_of?: string }).replay_of).toBe(deliveryId); +}); + +it("rotates the webhook secret and returns it exactly once", async () => { + const webhook = queue.webhooks.create({ url: sinkUrl, secret: "old-secret" }); + const res = await fetch(`${base}/api/webhooks/${webhook.id}/rotate-secret`, { + method: "POST", + headers, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string; secret: string }; + expect(body.id).toBe(webhook.id); + expect(body.secret).not.toBe("old-secret"); + expect(body.secret.length).toBeGreaterThan(20); + expect(queue.webhooks.get(webhook.id)?.secret).toBe(body.secret); + + // Listing never leaks the secret. + const list = (await (await fetch(`${base}/api/webhooks`, { headers })).json()) as Array<{ + secret?: unknown; + has_secret: boolean; + }>; + expect(list[0]?.secret).toBeUndefined(); + expect(list[0]?.has_secret).toBe(true); +}); + +it("404s deliveries for an unknown webhook and unknown delivery ids", async () => { + expect((await fetch(`${base}/api/webhooks/nope/deliveries`, { headers })).status).toBe(404); + const webhook = queue.webhooks.create({ url: sinkUrl }); + expect( + (await fetch(`${base}/api/webhooks/${webhook.id}/deliveries/nope`, { headers })).status, + ).toBe(404); + expect( + ( + await fetch(`${base}/api/webhooks/${webhook.id}/deliveries/nope/replay`, { + method: "POST", + headers, + }) + ).status, + ).toBe(404); +}); + +it("rejects an invalid status filter", async () => { + const webhook = queue.webhooks.create({ url: sinkUrl }); + const res = await fetch(`${base}/api/webhooks/${webhook.id}/deliveries?status=bogus`, { + headers, + }); + expect(res.status).toBe(400); +}); From e79f5de251b4ba259706d6f1e3569e5832004089 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:13:48 +0530 Subject: [PATCH 04/21] feat(node): task/queue overrides and middleware toggles Operators tune rate limits, concurrency, retries, and pause state from the dashboard; overrides persist in the settings KV under the cross-SDK key layout and apply at worker startup. Per-task middleware disables are re-read each invocation, so toggles take effect without a restart. --- sdks/node/src/dashboard/middlewareHandlers.ts | 46 +++ sdks/node/src/dashboard/middlewareStore.ts | 94 +++++ sdks/node/src/dashboard/overridesHandlers.ts | 80 +++++ sdks/node/src/dashboard/overridesStore.ts | 327 ++++++++++++++++++ sdks/node/src/dashboard/routes.ts | 50 +++ sdks/node/src/middleware.ts | 5 + sdks/node/src/queue.ts | 164 +++++++++ sdks/node/src/worker.ts | 35 +- .../dashboard/overridesMiddleware.test.ts | 170 +++++++++ sdks/node/test/integrations/express.test.ts | 2 +- sdks/node/test/integrations/fastify.test.ts | 2 +- 11 files changed, 967 insertions(+), 8 deletions(-) create mode 100644 sdks/node/src/dashboard/middlewareHandlers.ts create mode 100644 sdks/node/src/dashboard/middlewareStore.ts create mode 100644 sdks/node/src/dashboard/overridesHandlers.ts create mode 100644 sdks/node/src/dashboard/overridesStore.ts create mode 100644 sdks/node/test/dashboard/overridesMiddleware.test.ts diff --git a/sdks/node/src/dashboard/middlewareHandlers.ts b/sdks/node/src/dashboard/middlewareHandlers.ts new file mode 100644 index 00000000..bf5ef1c0 --- /dev/null +++ b/sdks/node/src/dashboard/middlewareHandlers.ts @@ -0,0 +1,46 @@ +// Middleware discovery + per-task enable/disable endpoints. + +import type { Queue } from "../index"; +import { badRequest, notFound } from "./errors"; + +export function listMiddleware(queue: Queue) { + return queue.listMiddleware(); +} + +/** The middleware chain for a task with each entry's enabled state. */ +export function getTaskMiddleware(queue: Queue, taskName: string) { + const disabled = new Set(queue.getDisabledMiddlewareFor(taskName)); + const entries = queue.listMiddleware().map((mw) => ({ + name: mw.name, + class_path: mw.class_path, + disabled: disabled.has(String(mw.name)), + effective: !disabled.has(String(mw.name)), + })); + return { task: taskName, middleware: entries }; +} + +export function putTaskMiddleware( + queue: Queue, + taskName: string, + middlewareName: string, + body: unknown, +) { + const enabled = (body as { enabled?: unknown } | undefined)?.enabled; + if (typeof enabled !== "boolean") { + throw badRequest('body must include {"enabled": bool}'); + } + // Confirm the middleware exists so a typo can't write a no-op disable entry. + const names = new Set(queue.listMiddleware().map((mw) => String(mw.name))); + if (!names.has(middlewareName)) { + throw notFound(`middleware '${middlewareName}' is not registered on task '${taskName}'`); + } + const disabled = enabled + ? queue.enableMiddlewareForTask(taskName, middlewareName) + : queue.disableMiddlewareForTask(taskName, middlewareName); + return { task: taskName, disabled }; +} + +/** Clear ALL disables for a task — every middleware fires again. */ +export function deleteTaskMiddleware(queue: Queue, taskName: string) { + return { cleared: queue.clearMiddlewareDisables(taskName) }; +} diff --git a/sdks/node/src/dashboard/middlewareStore.ts b/sdks/node/src/dashboard/middlewareStore.ts new file mode 100644 index 00000000..067e57e5 --- /dev/null +++ b/sdks/node/src/dashboard/middlewareStore.ts @@ -0,0 +1,94 @@ +// Per-task middleware disable list. Persisted under +// `middleware:disabled:` as a JSON array of middleware names and +// read at every invocation, so toggles take effect on the next job without +// a worker restart. + +import type { Middleware } from "../middleware"; +import { createLogger } from "../utils"; +import type { SettingsAccess } from "./overridesStore"; + +const DISABLE_PREFIX = "middleware:disabled:"; + +const log = createLogger("dashboard"); + +/** The stable name a middleware is keyed on in the disable list. */ +export function middlewareKey(middleware: Middleware, index: number): string { + const named = (middleware as { name?: unknown }).name; + if (typeof named === "string" && named) { + return named; + } + const className = middleware.constructor?.name; + if (className && className !== "Object") { + return className; + } + return `middleware:${index}`; +} + +function parse(raw: string | null): string[] { + if (!raw) { + return []; + } + try { + const data = JSON.parse(raw); + return Array.isArray(data) ? data.filter((x): x is string => typeof x === "string") : []; + } catch { + log.warn(() => "middleware disable list is not valid JSON; treating as empty"); + return []; + } +} + +/** List/set/clear per-task middleware disables. */ +export class MiddlewareDisableStore { + constructor(private readonly settings: SettingsAccess) {} + + private key(taskName: string): string { + return DISABLE_PREFIX + taskName; + } + + /** `{task_name: [disabled...]}` for every task with at least one disable. */ + listAll(): Record { + const out: Record = {}; + for (const [key, raw] of Object.entries(this.settings.listSettings())) { + if (!key.startsWith(DISABLE_PREFIX)) { + continue; + } + const names = parse(raw); + if (names.length > 0) { + out[key.slice(DISABLE_PREFIX.length)] = names; + } + } + return out; + } + + getFor(taskName: string): string[] { + return parse(this.settings.getSetting(this.key(taskName))); + } + + /** Flip a middleware on/off for a task; returns the new disable list. */ + setDisabled(taskName: string, middlewareName: string, disabled: boolean): string[] { + if (!taskName) { + throw new Error("task_name must not be empty"); + } + if (!middlewareName) { + throw new Error("middleware name must not be empty"); + } + let current = this.getFor(taskName); + if (disabled) { + if (!current.includes(middlewareName)) { + current.push(middlewareName); + } + } else { + current = current.filter((n) => n !== middlewareName); + } + if (current.length > 0) { + this.settings.setSetting(this.key(taskName), JSON.stringify(current)); + } else { + this.settings.deleteSetting(this.key(taskName)); + } + return current; + } + + clearFor(taskName: string): boolean { + return this.settings.deleteSetting(this.key(taskName)); + } +} diff --git a/sdks/node/src/dashboard/overridesHandlers.ts b/sdks/node/src/dashboard/overridesHandlers.ts new file mode 100644 index 00000000..f6dc4525 --- /dev/null +++ b/sdks/node/src/dashboard/overridesHandlers.ts @@ -0,0 +1,80 @@ +// Task & queue override endpoints. + +import type { Queue } from "../index"; +import { badRequest, notFound } from "./errors"; + +/** Every registered task with registration defaults + active override. */ +export function listTasks(queue: Queue) { + return queue.registeredTasks(); +} + +export function listQueues(queue: Queue) { + return queue.registeredQueues(); +} + +function requireObject(body: unknown): Record { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest("body must be a JSON object"); + } + return body as Record; +} + +// ── Task overrides ────────────────────────────────────────────────────── + +export function getTaskOverride(queue: Queue, taskName: string) { + const override = queue.getTaskOverride(taskName); + if (!override) { + throw notFound(`no override set for task '${taskName}'`); + } + return override; +} + +export function putTaskOverride(queue: Queue, taskName: string, body: unknown) { + try { + return queue.setTaskOverride(taskName, requireObject(body)); + } catch (error) { + throw error instanceof Error && error.name !== "DashboardError" + ? badRequest(error.message) + : error; + } +} + +export function deleteTaskOverride(queue: Queue, taskName: string) { + return { cleared: queue.clearTaskOverride(taskName) }; +} + +// ── Queue overrides ───────────────────────────────────────────────────── + +export function getQueueOverride(queue: Queue, queueName: string) { + const override = queue.getQueueOverride(queueName); + if (!override) { + throw notFound(`no override set for queue '${queueName}'`); + } + return override; +} + +export function putQueueOverride(queue: Queue, queueName: string, body: unknown) { + const fields = requireObject(body); + let override: ReturnType; + try { + override = queue.setQueueOverride(queueName, fields); + } catch (error) { + throw error instanceof Error && error.name !== "DashboardError" + ? badRequest(error.message) + : error; + } + // "paused" propagates to running workers immediately via the paused-queues + // store, independent of the static override consumed at worker startup. + if ("paused" in fields) { + if (fields.paused) { + queue.pauseQueue(queueName); + } else { + queue.resumeQueue(queueName); + } + } + return override; +} + +export function deleteQueueOverride(queue: Queue, queueName: string) { + return { cleared: queue.clearQueueOverride(queueName) }; +} diff --git a/sdks/node/src/dashboard/overridesStore.ts b/sdks/node/src/dashboard/overridesStore.ts new file mode 100644 index 00000000..1bc6fe11 --- /dev/null +++ b/sdks/node/src/dashboard/overridesStore.ts @@ -0,0 +1,327 @@ +// Persistent task & queue runtime overrides. Operators tune task/queue +// behaviour from the dashboard; decorator/registration values stay the +// defaults and any override recorded here wins. Storage layout follows the +// cross-SDK contract: +// +// - `overrides:task:` — JSON of overridden fields for that task +// - `overrides:queue:` — JSON of overridden fields for that queue +// +// Overrides are applied at worker startup; changes do not affect a running +// worker until it restarts (queue `paused` propagates live via pause/resume). + +import type { TaskConfigInput } from "../native"; +import { createLogger } from "../utils"; + +const TASK_PREFIX = "overrides:task:"; +const QUEUE_PREFIX = "overrides:queue:"; + +const log = createLogger("dashboard"); + +/** The settings-KV surface both `Queue` and the native handle satisfy. */ +export interface SettingsAccess { + getSetting(key: string): string | null; + setSetting(key: string, value: string): void; + deleteSetting(key: string): boolean; + listSettings(): Record; +} + +/** Fields an operator may override per task (cross-SDK field names). */ +export const TASK_OVERRIDE_FIELDS = new Set([ + "rate_limit", + "max_concurrent", + "max_retries", + "retry_backoff", + "timeout", + "priority", + "paused", +]); + +/** Fields an operator may override per queue. */ +export const QUEUE_OVERRIDE_FIELDS = new Set(["rate_limit", "max_concurrent", "paused"]); + +/** An operator-set task override (snake_case, matching the wire contract). */ +export interface TaskOverride { + task_name: string; + rate_limit: string | null; + max_concurrent: number | null; + max_retries: number | null; + retry_backoff: number | null; + timeout: number | null; + priority: number | null; + paused: boolean; + updated_at: number; +} + +/** An operator-set queue override. */ +export interface QueueOverride { + queue_name: string; + rate_limit: string | null; + max_concurrent: number | null; + paused: boolean; + updated_at: number; +} + +// ── Validation ────────────────────────────────────────────────────────── + +function validateCommon(fields: Record): void { + const rateLimit = fields.rate_limit; + if (rateLimit !== undefined && rateLimit !== null) { + if (typeof rateLimit !== "string" || !rateLimit) { + throw new Error("rate_limit must be a non-empty string like '100/m'"); + } + // Cheap shape check; rate-limit parsing happens in the core. + if (!rateLimit.includes("/")) { + throw new Error("rate_limit must contain a unit, e.g. '10/s', '100/m', '3600/h'"); + } + } + validateInt(fields, "max_concurrent", 0); + validateBool(fields, "paused"); +} + +function validateTaskFields(fields: Record): void { + const unknown = Object.keys(fields).filter((k) => !TASK_OVERRIDE_FIELDS.has(k)); + if (unknown.length > 0) { + throw new Error(`unknown task override fields: ${unknown.sort().join(", ")}`); + } + validateCommon(fields); + validateInt(fields, "max_retries", 0); + validateNumber(fields, "retry_backoff", 0); + validateInt(fields, "timeout", 1); + validateInt(fields, "priority"); +} + +function validateQueueFields(fields: Record): void { + const unknown = Object.keys(fields).filter((k) => !QUEUE_OVERRIDE_FIELDS.has(k)); + if (unknown.length > 0) { + throw new Error(`unknown queue override fields: ${unknown.sort().join(", ")}`); + } + validateCommon(fields); +} + +function validateInt(fields: Record, name: string, minimum?: number): void { + const value = fields[name]; + if (value === undefined || value === null) { + return; + } + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error(`${name} must be an integer`); + } + if (minimum !== undefined && value < minimum) { + throw new Error(`${name} must be >= ${minimum}`); + } +} + +function validateNumber(fields: Record, name: string, minimum?: number): void { + const value = fields[name]; + if (value === undefined || value === null) { + return; + } + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${name} must be a number`); + } + if (minimum !== undefined && value < minimum) { + throw new Error(`${name} must be >= ${minimum}`); + } +} + +function validateBool(fields: Record, name: string): void { + const value = fields[name]; + if (value !== undefined && value !== null && typeof value !== "boolean") { + throw new Error(`${name} must be a boolean`); + } +} + +// ── Store ─────────────────────────────────────────────────────────────── + +function parseJsonObject(raw: string | null): Record { + if (!raw) { + return {}; + } + try { + const data = JSON.parse(raw); + return data && typeof data === "object" && !Array.isArray(data) ? data : {}; + } catch { + log.warn(() => "overrides entry is not valid JSON; treating as empty"); + return {}; + } +} + +/** CRUD for per-task and per-queue runtime overrides. */ +export class OverridesStore { + constructor(private readonly settings: SettingsAccess) {} + + // ── Tasks ───────────────────────────────────────────────────────── + + listTasks(): Map { + const out = new Map(); + for (const [key, raw] of Object.entries(this.settings.listSettings())) { + if (key.startsWith(TASK_PREFIX)) { + const name = key.slice(TASK_PREFIX.length); + out.set(name, rowToTask(name, parseJsonObject(raw))); + } + } + return out; + } + + getTask(taskName: string): TaskOverride | undefined { + const raw = this.settings.getSetting(TASK_PREFIX + taskName); + return raw ? rowToTask(taskName, parseJsonObject(raw)) : undefined; + } + + /** Merge `fields` into the stored override; `null` values clear a field. */ + setTask(taskName: string, fields: Record): TaskOverride { + validateTaskFields(fields); + if (!taskName) { + throw new Error("task_name must not be empty"); + } + const merged = this.mergeRow(this.settings.getSetting(TASK_PREFIX + taskName), fields); + this.settings.setSetting(TASK_PREFIX + taskName, JSON.stringify(merged)); + return rowToTask(taskName, merged); + } + + clearTask(taskName: string): boolean { + return this.settings.deleteSetting(TASK_PREFIX + taskName); + } + + // ── Queues ──────────────────────────────────────────────────────── + + listQueues(): Map { + const out = new Map(); + for (const [key, raw] of Object.entries(this.settings.listSettings())) { + if (key.startsWith(QUEUE_PREFIX)) { + const name = key.slice(QUEUE_PREFIX.length); + out.set(name, rowToQueue(name, parseJsonObject(raw))); + } + } + return out; + } + + getQueue(queueName: string): QueueOverride | undefined { + const raw = this.settings.getSetting(QUEUE_PREFIX + queueName); + return raw ? rowToQueue(queueName, parseJsonObject(raw)) : undefined; + } + + setQueue(queueName: string, fields: Record): QueueOverride { + validateQueueFields(fields); + if (!queueName) { + throw new Error("queue_name must not be empty"); + } + const merged = this.mergeRow(this.settings.getSetting(QUEUE_PREFIX + queueName), fields); + this.settings.setSetting(QUEUE_PREFIX + queueName, JSON.stringify(merged)); + return rowToQueue(queueName, merged); + } + + clearQueue(queueName: string): boolean { + return this.settings.deleteSetting(QUEUE_PREFIX + queueName); + } + + private mergeRow( + existingRaw: string | null, + fields: Record, + ): Record { + const merged = parseJsonObject(existingRaw); + delete merged.updated_at; + for (const [key, value] of Object.entries(fields)) { + if (value === null || value === undefined) { + delete merged[key]; + } else { + merged[key] = value; + } + } + merged.updated_at = Date.now(); + return merged; + } +} + +function rowToTask(taskName: string, row: Record): TaskOverride { + return { + task_name: taskName, + rate_limit: asStringOrNull(row.rate_limit), + max_concurrent: asNumberOrNull(row.max_concurrent), + max_retries: asNumberOrNull(row.max_retries), + retry_backoff: asNumberOrNull(row.retry_backoff), + timeout: asNumberOrNull(row.timeout), + priority: asNumberOrNull(row.priority), + paused: row.paused === true, + updated_at: asNumberOrNull(row.updated_at) ?? 0, + }; +} + +function rowToQueue(queueName: string, row: Record): QueueOverride { + return { + queue_name: queueName, + rate_limit: asStringOrNull(row.rate_limit), + max_concurrent: asNumberOrNull(row.max_concurrent), + paused: row.paused === true, + updated_at: asNumberOrNull(row.updated_at) ?? 0, + }; +} + +const asStringOrNull = (v: unknown): string | null => (typeof v === "string" && v ? v : null); +const asNumberOrNull = (v: unknown): number | null => + typeof v === "number" && Number.isFinite(v) ? v : null; + +// ── Worker-startup application ────────────────────────────────────────── + +/** + * Patch worker task configs with stored overrides. Tasks that only exist as + * an override (registered without options) get a fresh config entry. + * `retry_backoff` is interpreted as the retry base delay in seconds; + * `timeout`/`priority` have no worker-config slot here and surface via the + * dashboard's effective view only. + */ +export function applyTaskOverrides( + configs: TaskConfigInput[], + taskNames: Iterable, + store: OverridesStore, +): TaskConfigInput[] { + const overrides = store.listTasks(); + if (overrides.size === 0) { + return configs; + } + const byName = new Map(configs.map((config) => [config.name, { ...config }])); + for (const name of taskNames) { + const override = overrides.get(name); + if (!override) { + continue; + } + const config = byName.get(name) ?? { name }; + if (override.rate_limit !== null) { + config.rateLimit = override.rate_limit; + } + if (override.max_concurrent !== null) { + config.maxConcurrent = override.max_concurrent; + } + if (override.max_retries !== null) { + config.maxRetries = override.max_retries; + } + if (override.retry_backoff !== null) { + config.retryBaseDelayMs = Math.round(override.retry_backoff * 1000); + } + byName.set(name, config); + } + return [...byName.values()]; +} + +/** Merge queue overrides into worker queue configs (adding new queues). */ +export function applyQueueOverrides( + configs: Array<{ name: string; maxConcurrent?: number; rateLimit?: string }>, + store: OverridesStore, +): Array<{ name: string; maxConcurrent?: number; rateLimit?: string }> { + const overrides = store.listQueues(); + if (overrides.size === 0) { + return configs; + } + const byName = new Map(configs.map((config) => [config.name, { ...config }])); + for (const [name, override] of overrides) { + const config = byName.get(name) ?? { name }; + if (override.rate_limit !== null) { + config.rateLimit = override.rate_limit; + } + if (override.max_concurrent !== null) { + config.maxConcurrent = override.max_concurrent; + } + byName.set(name, config); + } + return [...byName.values()]; +} diff --git a/sdks/node/src/dashboard/routes.ts b/sdks/node/src/dashboard/routes.ts index 22b5699c..084c50a4 100644 --- a/sdks/node/src/dashboard/routes.ts +++ b/sdks/node/src/dashboard/routes.ts @@ -1,7 +1,9 @@ import type { Queue } from "../index"; import * as auth from "./authHandlers"; import * as h from "./handlers"; +import * as middleware from "./middlewareHandlers"; import * as ops from "./opsHandlers"; +import * as overrides from "./overridesHandlers"; import type { RequestContext } from "./requestContext"; import * as settings from "./settingsHandlers"; @@ -125,6 +127,54 @@ export const routes: Route[] = [ }, { method: "GET", pattern: /^\/api\/scaler$/, handle: (q, url) => ops.scaler(q, url) }, { method: "GET", pattern: /^\/api\/resources$/, handle: (q) => ops.resourceStatus(q) }, + { method: "GET", pattern: /^\/api\/tasks$/, handle: (q) => overrides.listTasks(q) }, + { method: "GET", pattern: /^\/api\/queues$/, handle: (q) => overrides.listQueues(q) }, + { + method: "GET", + pattern: /^\/api\/tasks\/([^/]+)\/override$/, + handle: (q, _url, p) => overrides.getTaskOverride(q, id(p)), + }, + { + method: "PUT", + pattern: /^\/api\/tasks\/([^/]+)\/override$/, + handle: (q, _url, p, body) => overrides.putTaskOverride(q, id(p), body), + }, + { + method: "DELETE", + pattern: /^\/api\/tasks\/([^/]+)\/override$/, + handle: (q, _url, p) => overrides.deleteTaskOverride(q, id(p)), + }, + { + method: "GET", + pattern: /^\/api\/queues\/([^/]+)\/override$/, + handle: (q, _url, p) => overrides.getQueueOverride(q, id(p)), + }, + { + method: "PUT", + pattern: /^\/api\/queues\/([^/]+)\/override$/, + handle: (q, _url, p, body) => overrides.putQueueOverride(q, id(p), body), + }, + { + method: "DELETE", + pattern: /^\/api\/queues\/([^/]+)\/override$/, + handle: (q, _url, p) => overrides.deleteQueueOverride(q, id(p)), + }, + { method: "GET", pattern: /^\/api\/middleware$/, handle: (q) => middleware.listMiddleware(q) }, + { + method: "GET", + pattern: /^\/api\/tasks\/([^/]+)\/middleware$/, + handle: (q, _url, p) => middleware.getTaskMiddleware(q, id(p)), + }, + { + method: "PUT", + pattern: /^\/api\/tasks\/([^/]+)\/middleware\/([^/]+)$/, + handle: (q, _url, p, body) => middleware.putTaskMiddleware(q, id(p), p[1] ?? "", body), + }, + { + method: "DELETE", + pattern: /^\/api\/tasks\/([^/]+)\/middleware$/, + handle: (q, _url, p) => middleware.deleteTaskMiddleware(q, id(p)), + }, { method: "GET", pattern: /^\/api\/metrics$/, handle: (q, url) => h.metrics(q, url) }, { method: "GET", diff --git a/sdks/node/src/middleware.ts b/sdks/node/src/middleware.ts index 42833566..6e89bc9b 100644 --- a/sdks/node/src/middleware.ts +++ b/sdks/node/src/middleware.ts @@ -28,6 +28,11 @@ export interface EnqueueContext { * toward the timeout); the outcome hooks fire after the core decides the result. */ export interface Middleware { + /** + * Stable name used by the dashboard's per-task middleware toggles. + * Defaults to the class name for class-based middleware. + */ + name?: string; onEnqueue?(ctx: EnqueueContext): void; before?(ctx: TaskContext): void | Promise; after?(ctx: TaskContext, result: unknown): void | Promise; diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index 72c359c6..e456d61e 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -1,5 +1,7 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; +import { MiddlewareDisableStore, middlewareKey } from "./dashboard/middlewareStore"; +import { OverridesStore, type QueueOverride, type TaskOverride } from "./dashboard/overridesStore"; import { InterceptionError, JobCancelledError, @@ -679,6 +681,149 @@ export class Queue { return this.native.listSettings(); } + // ── Task & queue overrides (dashboard-tunable runtime config) ───── + + /** Every persisted task override keyed by task name. */ + listTaskOverrides(): Map { + return new OverridesStore(this.native).listTasks(); + } + + getTaskOverride(taskName: string): TaskOverride | undefined { + return new OverridesStore(this.native).getTask(taskName); + } + + /** + * Set or update a task override; `null` clears a field. Allowed fields: + * `rate_limit`, `max_concurrent`, `max_retries`, `retry_backoff`, + * `timeout`, `priority`, `paused`. Applied on the next worker start. + */ + setTaskOverride(taskName: string, fields: Record): TaskOverride { + return new OverridesStore(this.native).setTask(taskName, fields); + } + + clearTaskOverride(taskName: string): boolean { + return new OverridesStore(this.native).clearTask(taskName); + } + + listQueueOverrides(): Map { + return new OverridesStore(this.native).listQueues(); + } + + getQueueOverride(queueName: string): QueueOverride | undefined { + return new OverridesStore(this.native).getQueue(queueName); + } + + /** Allowed fields: `rate_limit`, `max_concurrent`, `paused`. */ + setQueueOverride(queueName: string, fields: Record): QueueOverride { + return new OverridesStore(this.native).setQueue(queueName, fields); + } + + clearQueueOverride(queueName: string): boolean { + return new OverridesStore(this.native).clearQueue(queueName); + } + + /** + * Every registered task with its registration defaults, any active + * override, and the effective values for the next worker start + * (snake_case, dashboard contract; durations in seconds). + */ + registeredTasks(): Array> { + const overrides = this.listTaskOverrides(); + const out: Array> = []; + for (const [name, task] of this.tasks) { + const options = task.options ?? {}; + const defaults: Record = { + max_retries: options.maxRetries ?? null, + retry_backoff: + options.retryBackoff?.baseMs !== undefined ? options.retryBackoff.baseMs / 1000 : null, + timeout: options.timeoutMs !== undefined ? options.timeoutMs / 1000 : null, + priority: null, + rate_limit: options.rateLimit ?? null, + max_concurrent: options.maxConcurrent ?? null, + }; + const override = overrides.get(name); + const patch = overridePatch(override); + out.push({ + name, + queue: "default", + defaults, + override: override ? { ...patch, ...(override.paused ? { paused: true } : {}) } : null, + effective: { ...defaults, ...patch }, + paused: override?.paused ?? false, + }); + } + return out; + } + + /** Every known queue with its limits, override, and paused state. */ + registeredQueues(): Array> { + const overrides = this.listQueueOverrides(); + const pausedSet = new Set(this.listPausedQueues()); + const names = new Set(["default", ...this.queueLimits.keys(), ...overrides.keys()]); + const out: Array> = []; + for (const name of [...names].sort()) { + const limits = this.queueLimits.get(name); + const defaults: Record = {}; + if (limits?.rateLimit !== undefined) { + defaults.rate_limit = limits.rateLimit; + } + if (limits?.maxConcurrent !== undefined) { + defaults.max_concurrent = limits.maxConcurrent; + } + const override = overrides.get(name); + const patch = overridePatch(override); + out.push({ + name, + defaults, + override: override ? { ...patch, ...(override.paused ? { paused: true } : {}) } : null, + effective: { ...defaults, ...patch }, + paused: pausedSet.has(name) || (override?.paused ?? false), + }); + } + return out; + } + + // ── Middleware admin (dashboard toggles) ────────────────────────── + + /** Every registered middleware with its name, class path, and scopes. */ + listMiddleware(): Array> { + const seen = new Map>(); + this.middleware.forEach((mw, index) => { + const name = middlewareKey(mw, index); + if (!seen.has(name)) { + seen.set(name, { + name, + class_path: mw.constructor?.name ?? "Object", + scopes: [{ kind: "global" }], + }); + } + }); + return [...seen.values()]; + } + + /** Every task with at least one disabled middleware. */ + listMiddlewareDisables(): Record { + return new MiddlewareDisableStore(this.native).listAll(); + } + + getDisabledMiddlewareFor(taskName: string): string[] { + return new MiddlewareDisableStore(this.native).getFor(taskName); + } + + /** Disable one middleware for one task (takes effect on the next job). */ + disableMiddlewareForTask(taskName: string, middlewareName: string): string[] { + return new MiddlewareDisableStore(this.native).setDisabled(taskName, middlewareName, true); + } + + enableMiddlewareForTask(taskName: string, middlewareName: string): string[] { + return new MiddlewareDisableStore(this.native).setDisabled(taskName, middlewareName, false); + } + + /** Clear ALL disables for a task — every middleware fires again. */ + clearMiddlewareDisables(taskName: string): boolean { + return new MiddlewareDisableStore(this.native).clearFor(taskName); + } + /** Registered workers (heartbeat + identity). */ listWorkers(): Promise { return this.native.listWorkers(); @@ -755,6 +900,25 @@ function toOpenOptions(options: QueueOptions): OpenOptions { }; } +/** Non-null override fields, minus identity/bookkeeping ones (contract patch shape). */ +function overridePatch( + override: TaskOverride | QueueOverride | undefined, +): Record { + if (!override) { + return {}; + } + const patch: Record = {}; + for (const [key, value] of Object.entries(override)) { + if (key === "task_name" || key === "queue_name" || key === "updated_at" || key === "paused") { + continue; + } + if (value !== null) { + patch[key] = value; + } + } + return patch; +} + /** * Create the parent directory of a SQLite file path, as the Python SDK does — * SQLite won't create missing directories itself. In-memory databases have no diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts index a55119ec..af63f1ff 100644 --- a/sdks/node/src/worker.ts +++ b/sdks/node/src/worker.ts @@ -1,4 +1,10 @@ import { type JobContext, runInContext } from "./context"; +import { MiddlewareDisableStore, middlewareKey } from "./dashboard/middlewareStore"; +import { + applyQueueOverrides, + applyTaskOverrides, + OverridesStore, +} from "./dashboard/overridesStore"; import { SerializationError, TaskNotRegisteredError } from "./errors"; import type { Emitter, EventName, OutcomeEvent } from "./events"; import type { Middleware, TaskContext } from "./middleware"; @@ -66,6 +72,18 @@ export class Worker { static start(queue: NativeQueue, params: WorkerStartParams): Worker { const { tasks, queueLimits, serializer, codecs, middleware, emitter, resources, run } = params; + // Dashboard-tunable state: per-task middleware disables are re-read on + // every invocation (live toggles); task/queue overrides apply here, at + // worker startup. + const disables = new MiddlewareDisableStore(queue); + const middlewareFor = (taskName: string): readonly Middleware[] => { + const disabled = disables.getFor(taskName); + if (disabled.length === 0) { + return middleware; + } + return middleware.filter((mw, index) => !disabled.includes(middlewareKey(mw, index))); + }; + // Advance workflow runs as node-jobs settle, unless disabled or unsupported. const tracker = (run?.advanceWorkflows ?? true) ? (params.workflowTracker ?? null) : null; @@ -132,17 +150,18 @@ export class Worker { return task.handler(...args); }; + const chain = middlewareFor(invocation.taskName); try { - for (const mw of middleware) { + for (const mw of chain) { await mw.before?.(ctx); } const result = await runWithResolver(scope.resolver, () => runInContext(context, invoke)); - for (const mw of middleware) { + for (const mw of chain) { await mw.after?.(ctx, result); } return Buffer.from(serializer.serialize(result)); } catch (error) { - for (const mw of middleware) { + for (const mw of chain) { try { await mw.onError?.(ctx, error); } catch { @@ -175,7 +194,7 @@ export class Worker { timedOut: outcome.timedOut ?? undefined, }; emitter.emit(mapping.event, event); - for (const mw of middleware) { + for (const mw of middlewareFor(outcome.taskName)) { const hook = mw[mapping.hook] as ((e: OutcomeEvent) => void) | undefined; try { // Promise.resolve captures async hooks' rejections too. @@ -194,8 +213,12 @@ export class Worker { queues: run?.queues, channelCapacity: run?.channelCapacity, batchSize: run?.batchSize, - taskConfigs: buildTaskConfigs(tasks), - queueConfigs: buildQueueConfigs(queueLimits), + taskConfigs: applyTaskOverrides( + buildTaskConfigs(tasks), + tasks.keys(), + new OverridesStore(queue), + ), + queueConfigs: applyQueueOverrides(buildQueueConfigs(queueLimits), new OverridesStore(queue)), resources: resources.isEmpty ? undefined : resources.names, mesh: run?.mesh, }; diff --git a/sdks/node/test/dashboard/overridesMiddleware.test.ts b/sdks/node/test/dashboard/overridesMiddleware.test.ts new file mode 100644 index 00000000..9dbed2dc --- /dev/null +++ b/sdks/node/test/dashboard/overridesMiddleware.test.ts @@ -0,0 +1,170 @@ +import { execSync } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { seedAdminAndSession } from "../../src/dashboard/testing"; +import { Queue, serveDashboard, type Worker } from "../../src/index"; +import type { Middleware } from "../../src/middleware"; + +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const staticDir = join(pkgRoot, "static", "dashboard"); + +beforeAll(() => { + if (!existsSync(join(staticDir, "index.html"))) { + execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 120_000); + +let server: Server | undefined; +let queue: Queue; +let base = ""; +let headers: Record = {}; + +beforeEach(async () => { + const db = join(mkdtempSync(join(tmpdir(), "taskito-dashovr-")), "q.db"); + queue = new Queue({ dbPath: db }); + queue.task("add", (a: number, b: number) => a + b, { maxRetries: 2, rateLimit: "100/m" }); + ({ headers } = await seedAdminAndSession(queue)); + server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false }); + await once(server, "listening"); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(() => { + server?.close(); + server = undefined; +}); + +const put = (path: string, body: unknown) => + fetch(`${base}${path}`, { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +describe("task overrides", () => { + it("lists registered tasks with defaults, override, and effective values", async () => { + await put("/api/tasks/add/override", { max_retries: 7, paused: true }); + + const tasks = (await (await fetch(`${base}/api/tasks`, { headers })).json()) as Array<{ + name: string; + defaults: { max_retries: number; rate_limit: string }; + override: { max_retries: number; paused: boolean }; + effective: { max_retries: number; rate_limit: string }; + paused: boolean; + }>; + const add = tasks.find((t) => t.name === "add"); + expect(add?.defaults.max_retries).toBe(2); + expect(add?.override.max_retries).toBe(7); + expect(add?.effective.max_retries).toBe(7); + expect(add?.effective.rate_limit).toBe("100/m"); // default survives + expect(add?.paused).toBe(true); + }); + + it("merges updates, clears fields with null, and round-trips GET", async () => { + await put("/api/tasks/add/override", { max_retries: 5, rate_limit: "10/s" }); + await put("/api/tasks/add/override", { rate_limit: null }); + + const got = (await (await fetch(`${base}/api/tasks/add/override`, { headers })).json()) as { + max_retries: number; + rate_limit: string | null; + }; + expect(got.max_retries).toBe(5); + expect(got.rate_limit).toBeNull(); + }); + + it("rejects unknown fields and bad values", async () => { + expect((await put("/api/tasks/add/override", { bogus: 1 })).status).toBe(400); + expect((await put("/api/tasks/add/override", { rate_limit: "nope" })).status).toBe(400); + expect((await put("/api/tasks/add/override", { max_concurrent: -1 })).status).toBe(400); + }); + + it("clears an override and 404s when none exists", async () => { + await put("/api/tasks/add/override", { max_retries: 9 }); + const del = (await ( + await fetch(`${base}/api/tasks/add/override`, { method: "DELETE", headers }) + ).json()) as { cleared: boolean }; + expect(del.cleared).toBe(true); + expect((await fetch(`${base}/api/tasks/add/override`, { headers })).status).toBe(404); + }); +}); + +describe("queue overrides", () => { + it("propagates paused immediately and lists queues", async () => { + await put("/api/queues/emails/override", { paused: true, max_concurrent: 3 }); + expect(queue.listPausedQueues()).toContain("emails"); + + const queues = (await (await fetch(`${base}/api/queues`, { headers })).json()) as Array<{ + name: string; + paused: boolean; + effective: { max_concurrent?: number }; + }>; + const emails = queues.find((q) => q.name === "emails"); + expect(emails?.paused).toBe(true); + expect(emails?.effective.max_concurrent).toBe(3); + + await put("/api/queues/emails/override", { paused: false }); + expect(queue.listPausedQueues()).not.toContain("emails"); + }); +}); + +describe("middleware toggles", () => { + class Recorder implements Middleware { + calls: string[] = []; + before(ctx: { taskName: string }): void { + this.calls.push(ctx.taskName); + } + } + + it("lists middleware and reflects per-task disables in the chain view", async () => { + queue.use(new Recorder()); + const list = (await (await fetch(`${base}/api/middleware`, { headers })).json()) as Array<{ + name: string; + scopes: Array<{ kind: string }>; + }>; + expect(list.map((m) => m.name)).toContain("Recorder"); + + const res = await put("/api/tasks/add/middleware/Recorder", { enabled: false }); + expect(res.status).toBe(200); + expect(((await res.json()) as { disabled: string[] }).disabled).toEqual(["Recorder"]); + + const chain = (await (await fetch(`${base}/api/tasks/add/middleware`, { headers })).json()) as { + middleware: Array<{ name: string; disabled: boolean; effective: boolean }>; + }; + const entry = chain.middleware.find((m) => m.name === "Recorder"); + expect(entry?.disabled).toBe(true); + expect(entry?.effective).toBe(false); + }); + + it("404s a toggle for an unregistered middleware", async () => { + expect((await put("/api/tasks/add/middleware/Ghost", { enabled: false })).status).toBe(404); + }); + + it("skips disabled middleware at execution time without a restart", async () => { + const recorder = new Recorder(); + queue.use(recorder); + const worker: Worker = queue.runWorker(); + try { + const first = queue.enqueue("add", [1, 1]); + await queue.result(first); + expect(recorder.calls).toEqual(["add"]); + + queue.disableMiddlewareForTask("add", "Recorder"); + const second = queue.enqueue("add", [2, 2]); + await queue.result(second); + expect(recorder.calls).toEqual(["add"]); // unchanged — hook skipped + + queue.enableMiddlewareForTask("add", "Recorder"); + const third = queue.enqueue("add", [3, 3]); + await queue.result(third); + expect(recorder.calls).toEqual(["add", "add"]); + } finally { + worker.stop(); + } + }); +}); diff --git a/sdks/node/test/integrations/express.test.ts b/sdks/node/test/integrations/express.test.ts index 7aecd270..a406dc97 100644 --- a/sdks/node/test/integrations/express.test.ts +++ b/sdks/node/test/integrations/express.test.ts @@ -102,5 +102,5 @@ it("mounts the dashboard JSON API", async () => { const base = await serve((app) => app.use("/admin", taskitoDashboard(queue))); const res = await fetch(`${base}/admin/api/auth/status`); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ setup_required: false }); + expect(await res.json()).toMatchObject({ setup_required: true }); }); diff --git a/sdks/node/test/integrations/fastify.test.ts b/sdks/node/test/integrations/fastify.test.ts index b86681a0..e2047460 100644 --- a/sdks/node/test/integrations/fastify.test.ts +++ b/sdks/node/test/integrations/fastify.test.ts @@ -79,5 +79,5 @@ it("mounts the dashboard JSON API under a prefix", async () => { const res = await fetch(`http://127.0.0.1:${port}/admin/api/auth/status`); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ setup_required: false }); + expect(await res.json()).toMatchObject({ setup_required: true }); }); From b96eec5ff8ba62c9bdd62960ad24d3a52528ef61 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:19:42 +0530 Subject: [PATCH 05/21] refactor(node): restructure dashboard into subpackages Group files into auth/, handlers/, and stores/ with index barrels, split route policy out of the route table, replace error helper functions with a typed exception hierarchy (DashboardError subclasses + store-level ValidationError), and drop the unused api.ts module. --- sdks/node/src/dashboard/api.ts | 53 ------------ .../{requestContext.ts => auth/context.ts} | 2 +- .../{authHandlers.ts => auth/handlers.ts} | 38 ++++----- sdks/node/src/dashboard/auth/index.ts | 4 + .../dashboard/{authStore.ts => auth/store.ts} | 21 ++--- .../dashboard/{auth.ts => auth/tokenAuth.ts} | 0 sdks/node/src/dashboard/errors.ts | 61 ++++++++++++-- .../src/dashboard/{ => handlers}/contract.ts | 6 +- .../{handlers.ts => handlers/core.ts} | 12 +-- sdks/node/src/dashboard/handlers/index.ts | 7 ++ .../src/dashboard/{ => handlers}/metrics.ts | 2 +- .../middleware.ts} | 10 ++- .../{opsHandlers.ts => handlers/ops.ts} | 2 +- .../overrides.ts} | 28 ++----- .../settings.ts} | 22 ++--- sdks/node/src/dashboard/index.ts | 4 +- sdks/node/src/dashboard/policy.ts | 38 +++++++++ sdks/node/src/dashboard/routes.ts | 82 +++++-------------- sdks/node/src/dashboard/server.ts | 24 +++--- sdks/node/src/dashboard/stores/index.ts | 2 + .../middlewareDisables.ts} | 11 +-- .../overrides.ts} | 27 +++--- sdks/node/src/dashboard/testing.ts | 2 +- sdks/node/src/queue.ts | 9 +- sdks/node/src/worker.ts | 5 +- sdks/node/test/dashboard/sessionAuth.test.ts | 2 +- 26 files changed, 232 insertions(+), 242 deletions(-) delete mode 100644 sdks/node/src/dashboard/api.ts rename sdks/node/src/dashboard/{requestContext.ts => auth/context.ts} (97%) rename sdks/node/src/dashboard/{authHandlers.ts => auth/handlers.ts} (78%) create mode 100644 sdks/node/src/dashboard/auth/index.ts rename sdks/node/src/dashboard/{authStore.ts => auth/store.ts} (95%) rename sdks/node/src/dashboard/{auth.ts => auth/tokenAuth.ts} (100%) rename sdks/node/src/dashboard/{ => handlers}/contract.ts (95%) rename sdks/node/src/dashboard/{handlers.ts => handlers/core.ts} (97%) create mode 100644 sdks/node/src/dashboard/handlers/index.ts rename sdks/node/src/dashboard/{ => handlers}/metrics.ts (98%) rename sdks/node/src/dashboard/{middlewareHandlers.ts => handlers/middleware.ts} (83%) rename sdks/node/src/dashboard/{opsHandlers.ts => handlers/ops.ts} (99%) rename sdks/node/src/dashboard/{overridesHandlers.ts => handlers/overrides.ts} (72%) rename sdks/node/src/dashboard/{settingsHandlers.ts => handlers/settings.ts} (75%) create mode 100644 sdks/node/src/dashboard/policy.ts create mode 100644 sdks/node/src/dashboard/stores/index.ts rename sdks/node/src/dashboard/{middlewareStore.ts => stores/middlewareDisables.ts} (89%) rename sdks/node/src/dashboard/{overridesStore.ts => stores/overrides.ts} (91%) diff --git a/sdks/node/src/dashboard/api.ts b/sdks/node/src/dashboard/api.ts deleted file mode 100644 index a6fc1dea..00000000 --- a/sdks/node/src/dashboard/api.ts +++ /dev/null @@ -1,53 +0,0 @@ -//! JSON API handlers over a {@link Queue}. Pure data — no HTTP concerns here. - -import type { Queue } from "../index"; - -function numParam(query: URLSearchParams, key: string): number | undefined { - const value = query.get(key); - if (value === null) { - return undefined; - } - const num = Number(value); - // Drop NaN/negative so they never reach the backend as bogus limit/offset. - return Number.isFinite(num) && num >= 0 ? num : undefined; -} - -export async function getStats(queue: Queue) { - return { overall: await queue.stats(), queues: await queue.statsAllQueues() }; -} - -export async function getQueues(queue: Queue) { - return { paused: queue.listPausedQueues(), stats: await queue.statsAllQueues() }; -} - -export function getJobs(queue: Queue, query: URLSearchParams) { - return queue.listJobs({ - status: query.get("status") ?? undefined, - queue: query.get("queue") ?? undefined, - task: query.get("task") ?? undefined, - limit: numParam(query, "limit"), - offset: numParam(query, "offset"), - }); -} - -export function getDeadLetters(queue: Queue, query: URLSearchParams) { - return queue.deadLetters(numParam(query, "limit"), numParam(query, "offset")); -} - -export function cancelJob(queue: Queue, id: string) { - return { id, cancelled: queue.cancelJob(id) || queue.requestCancel(id) }; -} - -export function retryDead(queue: Queue, id: string) { - return { id: queue.retryDead(id) }; -} - -export function pauseQueue(queue: Queue, name: string) { - queue.pauseQueue(name); - return { paused: name }; -} - -export function resumeQueue(queue: Queue, name: string) { - queue.resumeQueue(name); - return { resumed: name }; -} diff --git a/sdks/node/src/dashboard/requestContext.ts b/sdks/node/src/dashboard/auth/context.ts similarity index 97% rename from sdks/node/src/dashboard/requestContext.ts rename to sdks/node/src/dashboard/auth/context.ts index 57d43419..6febf5c9 100644 --- a/sdks/node/src/dashboard/requestContext.ts +++ b/sdks/node/src/dashboard/auth/context.ts @@ -2,7 +2,7 @@ // the incoming request, handed to handlers that need the calling user. import type { IncomingMessage } from "node:http"; -import type { DashboardSession } from "./authStore"; +import type { DashboardSession } from "./store"; // Session cookie: HttpOnly + SameSite=Strict — never readable from JS. export const SESSION_COOKIE = "taskito_session"; diff --git a/sdks/node/src/dashboard/authHandlers.ts b/sdks/node/src/dashboard/auth/handlers.ts similarity index 78% rename from sdks/node/src/dashboard/authHandlers.ts rename to sdks/node/src/dashboard/auth/handlers.ts index 5cd6c121..fade1646 100644 --- a/sdks/node/src/dashboard/authHandlers.ts +++ b/sdks/node/src/dashboard/auth/handlers.ts @@ -2,15 +2,15 @@ // Cookie side effects (set/clear) are applied by the server, which reads the // session off login results and redacts the raw token from the JSON body. -import type { Queue } from "../index"; -import { AuthStore, type DashboardSession, type DashboardUser } from "./authStore"; -import { badRequest, notFound } from "./errors"; -import type { RequestContext } from "./requestContext"; +import type { Queue } from "../../index"; +import { BadRequestError, NotFoundError } from "../errors"; +import type { RequestContext } from "./context"; +import { AuthStore, type DashboardSession, type DashboardUser } from "./store"; function requireField(body: unknown, key: string): string { const value = (body as Record | undefined)?.[key]; if (typeof value !== "string" || !value) { - throw badRequest(`missing or empty field '${key}'`); + throw new BadRequestError(`missing or empty field '${key}'`); } return value; } @@ -42,16 +42,12 @@ export function authStatus(queue: Queue) { export async function setup(queue: Queue, body: unknown) { const store = new AuthStore(queue); if (store.countUsers() > 0) { - throw badRequest("setup already complete"); + throw new BadRequestError("setup already complete"); } const username = requireField(body, "username"); const password = requireField(body, "password"); - let user: DashboardUser; - try { - user = await store.createUser(username, password, "admin"); - } catch (error) { - throw badRequest(error instanceof Error ? error.message : "invalid input"); - } + // Store-level ValidationError propagates to the server, which maps it to 400. + const user = await store.createUser(username, password, "admin"); return { user: serializeUser(user) }; } @@ -63,13 +59,13 @@ export async function setup(queue: Queue, body: unknown) { export async function login(queue: Queue, body: unknown) { const store = new AuthStore(queue); if (store.countUsers() === 0) { - throw badRequest("setup_required"); + throw new BadRequestError("setup_required"); } const username = requireField(body, "username"); const password = requireField(body, "password"); const user = await store.authenticate(username, password); if (!user) { - throw badRequest("invalid_credentials"); + throw new BadRequestError("invalid_credentials"); } const session = store.createSession(user); return { @@ -89,14 +85,14 @@ export function logout(queue: Queue, ctx: RequestContext) { /** The current user, or 404 `not_authenticated` without a valid session. */ export function whoami(queue: Queue, ctx: RequestContext) { if (!ctx.session) { - throw notFound("not_authenticated"); + throw new NotFoundError("not_authenticated"); } const store = new AuthStore(queue); const user = store.getUser(ctx.session.username); if (!user) { // Session valid but user deleted — invalidate and treat as logged out. store.deleteSession(ctx.session.token); - throw notFound("not_authenticated"); + throw new NotFoundError("not_authenticated"); } return { user: serializeUser(user), @@ -108,19 +104,15 @@ export function whoami(queue: Queue, ctx: RequestContext) { /** Change the current user's password. Requires the old password. */ export async function changePassword(queue: Queue, body: unknown, ctx: RequestContext) { if (!ctx.session) { - throw badRequest("not_authenticated"); + throw new BadRequestError("not_authenticated"); } const oldPassword = requireField(body, "old_password"); const newPassword = requireField(body, "new_password"); const store = new AuthStore(queue); const user = await store.authenticate(ctx.session.username, oldPassword); if (!user) { - throw badRequest("invalid_credentials"); - } - try { - await store.updatePassword(user.username, newPassword); - } catch (error) { - throw badRequest(error instanceof Error ? error.message : "invalid input"); + throw new BadRequestError("invalid_credentials"); } + await store.updatePassword(user.username, newPassword); return { ok: true }; } diff --git a/sdks/node/src/dashboard/auth/index.ts b/sdks/node/src/dashboard/auth/index.ts new file mode 100644 index 00000000..d14bb631 --- /dev/null +++ b/sdks/node/src/dashboard/auth/index.ts @@ -0,0 +1,4 @@ +export * from "./context"; +export * from "./handlers"; +export * from "./store"; +export * from "./tokenAuth"; diff --git a/sdks/node/src/dashboard/authStore.ts b/sdks/node/src/dashboard/auth/store.ts similarity index 95% rename from sdks/node/src/dashboard/authStore.ts rename to sdks/node/src/dashboard/auth/store.ts index 910164c8..5d250e4b 100644 --- a/sdks/node/src/dashboard/authStore.ts +++ b/sdks/node/src/dashboard/auth/store.ts @@ -13,8 +13,9 @@ import { pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; -import type { Queue } from "../index"; -import { createLogger } from "../utils"; +import type { Queue } from "../../index"; +import { createLogger } from "../../utils"; +import { ValidationError } from "../errors"; const log = createLogger("dashboard"); const pbkdf2 = promisify(pbkdf2Cb); @@ -128,28 +129,28 @@ const nowSeconds = (): number => Math.floor(Date.now() / 1000); function validateUsername(username: string): void { if (!username) { - throw new Error("username must not be empty"); + throw new ValidationError("username must not be empty"); } if (username.length > USERNAME_MAX_LEN) { - throw new Error(`username must be <= ${USERNAME_MAX_LEN} chars`); + throw new ValidationError(`username must be <= ${USERNAME_MAX_LEN} chars`); } if (!USERNAME_RE.test(username)) { - throw new Error("username may only contain letters, digits, '.', '_', or '-'"); + throw new ValidationError("username may only contain letters, digits, '.', '_', or '-'"); } } function validatePassword(password: string): void { if (password.length < PASSWORD_MIN_LEN) { - throw new Error(`password must be >= ${PASSWORD_MIN_LEN} chars`); + throw new ValidationError(`password must be >= ${PASSWORD_MIN_LEN} chars`); } if (password.length > PASSWORD_MAX_LEN) { - throw new Error(`password must be <= ${PASSWORD_MAX_LEN} chars`); + throw new ValidationError(`password must be <= ${PASSWORD_MAX_LEN} chars`); } } function validateRole(role: string): void { if (!VALID_ROLES.has(role)) { - throw new Error(`role must be one of ${[...VALID_ROLES].sort().join(", ")}`); + throw new ValidationError(`role must be one of ${[...VALID_ROLES].sort().join(", ")}`); } } @@ -238,7 +239,7 @@ export class AuthStore { validateRole(role); const users = this.loadUsers(); if (users[username]) { - throw new Error(`user '${username}' already exists`); + throw new ValidationError(`user '${username}' already exists`); } users[username] = { password_hash: await hashPassword(password), @@ -255,7 +256,7 @@ export class AuthStore { const users = this.loadUsers(); const row = users[username]; if (!row) { - throw new Error(`user '${username}' does not exist`); + throw new ValidationError(`user '${username}' does not exist`); } row.password_hash = await hashPassword(newPassword); this.saveUsers(users); diff --git a/sdks/node/src/dashboard/auth.ts b/sdks/node/src/dashboard/auth/tokenAuth.ts similarity index 100% rename from sdks/node/src/dashboard/auth.ts rename to sdks/node/src/dashboard/auth/tokenAuth.ts diff --git a/sdks/node/src/dashboard/errors.ts b/sdks/node/src/dashboard/errors.ts index 961b4fe9..80c230ea 100644 --- a/sdks/node/src/dashboard/errors.ts +++ b/sdks/node/src/dashboard/errors.ts @@ -1,16 +1,61 @@ -// Typed HTTP errors thrown by dashboard handlers; the server maps them to -// JSON error responses without leaking internals. +// Typed exceptions for the dashboard package. HTTP-mapped errors carry a +// status the server returns verbatim; store-level validation errors are +// transport-agnostic and map to 400 at the HTTP boundary. -/** Handler-level error carrying an HTTP status and a stable error code. */ +/** Base for errors that map directly to an HTTP response. */ export class DashboardError extends Error { readonly status: number; - constructor(status: number, code: string) { - super(code); - this.name = "DashboardError"; + constructor(status: number, message: string) { + super(message); + this.name = new.target.name; this.status = status; } } -export const badRequest = (code: string): DashboardError => new DashboardError(400, code); -export const notFound = (code: string): DashboardError => new DashboardError(404, code); +/** Malformed or unacceptable request input (400). */ +export class BadRequestError extends DashboardError { + constructor(message: string) { + super(400, message); + } +} + +/** Missing resource, or one deliberately reported as absent (404). */ +export class NotFoundError extends DashboardError { + constructor(message: string) { + super(404, message); + } +} + +/** No valid session/token on a protected route (401). */ +export class UnauthorizedError extends DashboardError { + constructor(message = "not_authenticated") { + super(401, message); + } +} + +/** Authenticated but not allowed: CSRF failure or missing role (403). */ +export class ForbiddenError extends DashboardError { + constructor(message: string) { + super(403, message); + } +} + +/** No users exist yet — the SPA must run the first-admin setup flow (503). */ +export class SetupRequiredError extends DashboardError { + constructor() { + super(503, "setup_required"); + } +} + +/** + * Invalid input rejected by a dashboard store (auth, overrides, middleware + * toggles). Transport-agnostic so `Queue`-level callers get a plain error; + * the HTTP server maps it to a 400 response. + */ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} diff --git a/sdks/node/src/dashboard/contract.ts b/sdks/node/src/dashboard/handlers/contract.ts similarity index 95% rename from sdks/node/src/dashboard/contract.ts rename to sdks/node/src/dashboard/handlers/contract.ts index b8d4e2f7..dd3448de 100644 --- a/sdks/node/src/dashboard/contract.ts +++ b/sdks/node/src/dashboard/handlers/contract.ts @@ -1,9 +1,9 @@ // Map the SDK's camelCase shapes to the snake_case JSON contract the React SPA // (`dashboard/`) expects. Timestamps are Unix milliseconds throughout. -import type { DeadJob, Job, WorkerInfo } from "../types"; -import type { Delivery, Webhook } from "../webhooks"; -import type { WorkflowNode, WorkflowRun } from "../workflows"; +import type { DeadJob, Job, WorkerInfo } from "../../types"; +import type { Delivery, Webhook } from "../../webhooks"; +import type { WorkflowNode, WorkflowRun } from "../../workflows"; /** Replace header values with a mask so outbound credentials aren't exposed. */ function maskHeaderValues(headers: Record): Record { diff --git a/sdks/node/src/dashboard/handlers.ts b/sdks/node/src/dashboard/handlers/core.ts similarity index 97% rename from sdks/node/src/dashboard/handlers.ts rename to sdks/node/src/dashboard/handlers/core.ts index 48ae8575..548e82ea 100644 --- a/sdks/node/src/dashboard/handlers.ts +++ b/sdks/node/src/dashboard/handlers/core.ts @@ -2,10 +2,11 @@ // `undefined` from a handler means 404. import { randomBytes } from "node:crypto"; -import type { EventName } from "../events"; -import type { Queue } from "../index"; -import type { WebhookInput } from "../webhooks"; -import type { WorkflowNode } from "../workflows"; +import type { EventName } from "../../events"; +import type { Queue } from "../../index"; +import type { WebhookInput } from "../../webhooks"; +import type { WorkflowNode } from "../../workflows"; +import { BadRequestError } from "../errors"; import { deadToContract, deliveryToContract, @@ -15,7 +16,6 @@ import { workflowNodeToContract, workflowRunToContract, } from "./contract"; -import { badRequest } from "./errors"; import { aggregateByTask, bucketTimeseries } from "./metrics"; /** Finite, non-negative number from a query string, or `undefined`. */ @@ -204,7 +204,7 @@ export function webhookDeliveries(queue: Queue, id: string, url: URL) { } const status = url.searchParams.get("status") ?? undefined; if (status && !DELIVERY_STATUSES.has(status)) { - throw badRequest("status must be one of: delivered, failed, dead, pending"); + throw new BadRequestError("status must be one of: delivered, failed, dead, pending"); } const event = url.searchParams.get("event") ?? undefined; const limit = Math.min(num(url, "limit") ?? 50, MAX_DELIVERY_PAGE); diff --git a/sdks/node/src/dashboard/handlers/index.ts b/sdks/node/src/dashboard/handlers/index.ts new file mode 100644 index 00000000..34f27269 --- /dev/null +++ b/sdks/node/src/dashboard/handlers/index.ts @@ -0,0 +1,7 @@ +export * from "./contract"; +export * from "./core"; +export * from "./metrics"; +export * from "./middleware"; +export * from "./ops"; +export * from "./overrides"; +export * from "./settings"; diff --git a/sdks/node/src/dashboard/metrics.ts b/sdks/node/src/dashboard/handlers/metrics.ts similarity index 98% rename from sdks/node/src/dashboard/metrics.ts rename to sdks/node/src/dashboard/handlers/metrics.ts index 98c3ecae..ce929224 100644 --- a/sdks/node/src/dashboard/metrics.ts +++ b/sdks/node/src/dashboard/handlers/metrics.ts @@ -1,7 +1,7 @@ // Aggregate the core's per-execution metric rows into the SPA's per-task and // time-bucketed shapes. The core records a metric for every finished job. -import type { Metric } from "../types"; +import type { Metric } from "../../types"; export interface TaskMetrics { count: number; diff --git a/sdks/node/src/dashboard/middlewareHandlers.ts b/sdks/node/src/dashboard/handlers/middleware.ts similarity index 83% rename from sdks/node/src/dashboard/middlewareHandlers.ts rename to sdks/node/src/dashboard/handlers/middleware.ts index bf5ef1c0..f068f091 100644 --- a/sdks/node/src/dashboard/middlewareHandlers.ts +++ b/sdks/node/src/dashboard/handlers/middleware.ts @@ -1,7 +1,7 @@ // Middleware discovery + per-task enable/disable endpoints. -import type { Queue } from "../index"; -import { badRequest, notFound } from "./errors"; +import type { Queue } from "../../index"; +import { BadRequestError, NotFoundError } from "../errors"; export function listMiddleware(queue: Queue) { return queue.listMiddleware(); @@ -27,12 +27,14 @@ export function putTaskMiddleware( ) { const enabled = (body as { enabled?: unknown } | undefined)?.enabled; if (typeof enabled !== "boolean") { - throw badRequest('body must include {"enabled": bool}'); + throw new BadRequestError('body must include {"enabled": bool}'); } // Confirm the middleware exists so a typo can't write a no-op disable entry. const names = new Set(queue.listMiddleware().map((mw) => String(mw.name))); if (!names.has(middlewareName)) { - throw notFound(`middleware '${middlewareName}' is not registered on task '${taskName}'`); + throw new NotFoundError( + `middleware '${middlewareName}' is not registered on task '${taskName}'`, + ); } const disabled = enabled ? queue.enableMiddlewareForTask(taskName, middlewareName) diff --git a/sdks/node/src/dashboard/opsHandlers.ts b/sdks/node/src/dashboard/handlers/ops.ts similarity index 99% rename from sdks/node/src/dashboard/opsHandlers.ts rename to sdks/node/src/dashboard/handlers/ops.ts index 7d5516be..cbd58c67 100644 --- a/sdks/node/src/dashboard/opsHandlers.ts +++ b/sdks/node/src/dashboard/handlers/ops.ts @@ -1,7 +1,7 @@ // Operational endpoints: liveness/readiness probes, the KEDA scaler payload, // and worker-resource health aggregated from heartbeats. -import type { Queue } from "../index"; +import type { Queue } from "../../index"; /** Basic liveness check — always ok. */ export function health() { diff --git a/sdks/node/src/dashboard/overridesHandlers.ts b/sdks/node/src/dashboard/handlers/overrides.ts similarity index 72% rename from sdks/node/src/dashboard/overridesHandlers.ts rename to sdks/node/src/dashboard/handlers/overrides.ts index f6dc4525..ffd9e800 100644 --- a/sdks/node/src/dashboard/overridesHandlers.ts +++ b/sdks/node/src/dashboard/handlers/overrides.ts @@ -1,7 +1,7 @@ // Task & queue override endpoints. -import type { Queue } from "../index"; -import { badRequest, notFound } from "./errors"; +import type { Queue } from "../../index"; +import { BadRequestError, NotFoundError } from "../errors"; /** Every registered task with registration defaults + active override. */ export function listTasks(queue: Queue) { @@ -14,7 +14,7 @@ export function listQueues(queue: Queue) { function requireObject(body: unknown): Record { if (!body || typeof body !== "object" || Array.isArray(body)) { - throw badRequest("body must be a JSON object"); + throw new BadRequestError("body must be a JSON object"); } return body as Record; } @@ -24,19 +24,14 @@ function requireObject(body: unknown): Record { export function getTaskOverride(queue: Queue, taskName: string) { const override = queue.getTaskOverride(taskName); if (!override) { - throw notFound(`no override set for task '${taskName}'`); + throw new NotFoundError(`no override set for task '${taskName}'`); } return override; } +// Store-level ValidationError propagates to the server, which maps it to 400. export function putTaskOverride(queue: Queue, taskName: string, body: unknown) { - try { - return queue.setTaskOverride(taskName, requireObject(body)); - } catch (error) { - throw error instanceof Error && error.name !== "DashboardError" - ? badRequest(error.message) - : error; - } + return queue.setTaskOverride(taskName, requireObject(body)); } export function deleteTaskOverride(queue: Queue, taskName: string) { @@ -48,21 +43,14 @@ export function deleteTaskOverride(queue: Queue, taskName: string) { export function getQueueOverride(queue: Queue, queueName: string) { const override = queue.getQueueOverride(queueName); if (!override) { - throw notFound(`no override set for queue '${queueName}'`); + throw new NotFoundError(`no override set for queue '${queueName}'`); } return override; } export function putQueueOverride(queue: Queue, queueName: string, body: unknown) { const fields = requireObject(body); - let override: ReturnType; - try { - override = queue.setQueueOverride(queueName, fields); - } catch (error) { - throw error instanceof Error && error.name !== "DashboardError" - ? badRequest(error.message) - : error; - } + const override = queue.setQueueOverride(queueName, fields); // "paused" propagates to running workers immediately via the paused-queues // store, independent of the static override consumed at worker startup. if ("paused" in fields) { diff --git a/sdks/node/src/dashboard/settingsHandlers.ts b/sdks/node/src/dashboard/handlers/settings.ts similarity index 75% rename from sdks/node/src/dashboard/settingsHandlers.ts rename to sdks/node/src/dashboard/handlers/settings.ts index ddc5226d..c2423374 100644 --- a/sdks/node/src/dashboard/settingsHandlers.ts +++ b/sdks/node/src/dashboard/handlers/settings.ts @@ -2,8 +2,8 @@ // strings to the storage layer; the SPA stores JSON blobs (branding, // integrations, external links). -import type { Queue } from "../index"; -import { badRequest, notFound } from "./errors"; +import type { Queue } from "../../index"; +import { BadRequestError, NotFoundError } from "../errors"; const MAX_KEY_LENGTH = 256; const MAX_VALUE_LENGTH = 64 * 1024; // 64 KiB — enough for any dashboard config blob @@ -19,16 +19,16 @@ const isProtected = (key: string): boolean => PROTECTED_PREFIXES.some((p) => key function validateKey(key: string): void { if (!key) { - throw badRequest("setting key must not be empty"); + throw new BadRequestError("setting key must not be empty"); } if (key.length > MAX_KEY_LENGTH) { - throw badRequest(`setting key exceeds ${MAX_KEY_LENGTH} characters`); + throw new BadRequestError(`setting key exceeds ${MAX_KEY_LENGTH} characters`); } if ([...key].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) { - throw badRequest("setting key must not contain control characters"); + throw new BadRequestError("setting key must not contain control characters"); } if (isProtected(key)) { - throw badRequest("setting key is reserved"); + throw new BadRequestError("setting key is reserved"); } } @@ -42,11 +42,11 @@ export function listSettings(queue: Queue): Record { /** A single setting, or 404. Protected keys read as absent. */ export function getSetting(queue: Queue, key: string) { if (isProtected(key)) { - throw notFound(`setting '${key}' not found`); + throw new NotFoundError(`setting '${key}' not found`); } const value = queue.getSetting(key); if (value === null) { - throw notFound(`setting '${key}' not found`); + throw new NotFoundError(`setting '${key}' not found`); } return { key, value }; } @@ -55,14 +55,14 @@ export function getSetting(queue: Queue, key: string) { export function putSetting(queue: Queue, key: string, body: unknown) { validateKey(key); if (!body || typeof body !== "object" || !("value" in body)) { - throw badRequest("body must be a JSON object with a 'value' field"); + throw new BadRequestError("body must be a JSON object with a 'value' field"); } const raw = (body as { value: unknown }).value; // Accept any JSON-serialisable type — re-encode for storage so callers // don't need to stringify themselves. const value = typeof raw === "string" ? raw : JSON.stringify(raw); if (value.length > MAX_VALUE_LENGTH) { - throw badRequest(`setting value exceeds ${MAX_VALUE_LENGTH} bytes`); + throw new BadRequestError(`setting value exceeds ${MAX_VALUE_LENGTH} bytes`); } queue.setSetting(key, value); return { key, value }; @@ -71,7 +71,7 @@ export function putSetting(queue: Queue, key: string, body: unknown) { /** Delete a setting. Returns `{deleted: bool}`. Protected keys read as absent. */ export function deleteSetting(queue: Queue, key: string) { if (isProtected(key)) { - throw notFound(`setting '${key}' not found`); + throw new NotFoundError(`setting '${key}' not found`); } return { deleted: queue.deleteSetting(key) }; } diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts index 4dcde5ea..2abdfb86 100644 --- a/sdks/node/src/dashboard/index.ts +++ b/sdks/node/src/dashboard/index.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"; import type { Queue } from "../index"; import { createLogger } from "../utils"; import type { DashboardAuth } from "./auth"; -import { bootstrapAdminFromEnv } from "./authStore"; +import { bootstrapAdminFromEnv } from "./auth"; import { createDashboardServer } from "./server"; const log = createLogger("dashboard"); @@ -65,7 +65,7 @@ export { bootstrapAdminFromEnv, type DashboardSession, type DashboardUser, -} from "./authStore"; +} from "./auth"; export { createDashboardHandler, createDashboardServer, diff --git a/sdks/node/src/dashboard/policy.ts b/sdks/node/src/dashboard/policy.ts new file mode 100644 index 00000000..cda0d48b --- /dev/null +++ b/sdks/node/src/dashboard/policy.ts @@ -0,0 +1,38 @@ +// Auth policy for the dashboard API: which paths bypass the session gate, +// which methods need CSRF, and which actions are admin-only. + +/** Exact paths that bypass the session/CSRF gate. */ +export const PUBLIC_PATHS = new Set([ + "/api/auth/status", + "/api/auth/login", + "/api/auth/setup", + "/api/auth/providers", + "/health", + "/readiness", + "/metrics", +]); + +/** Prefixes that bypass auth — OAuth paths carry a provider slot in the URL. */ +export const PUBLIC_PATH_PREFIXES = ["/api/auth/oauth/start/", "/api/auth/oauth/callback/"]; + +/** State-changing paths a non-admin may call on their own session. */ +const SELF_SERVICE_PATHS = new Set(["/api/auth/logout", "/api/auth/change-password"]); + +/** Paths exempt from CSRF because no session exists yet. */ +const CSRF_EXEMPT_PATHS = new Set(["/api/auth/login", "/api/auth/setup"]); + +export function isPublicPath(path: string): boolean { + return PUBLIC_PATHS.has(path) || PUBLIC_PATH_PREFIXES.some((p) => path.startsWith(p)); +} + +export function isStateChangingMethod(method: string): boolean { + return method === "POST" || method === "PUT" || method === "DELETE" || method === "PATCH"; +} + +export function isCsrfExempt(path: string): boolean { + return CSRF_EXEMPT_PATHS.has(path); +} + +export function requiresAdmin(path: string, method: string): boolean { + return isStateChangingMethod(method) && !SELF_SERVICE_PATHS.has(path); +} diff --git a/sdks/node/src/dashboard/routes.ts b/sdks/node/src/dashboard/routes.ts index 084c50a4..ad032ab7 100644 --- a/sdks/node/src/dashboard/routes.ts +++ b/sdks/node/src/dashboard/routes.ts @@ -1,11 +1,7 @@ import type { Queue } from "../index"; -import * as auth from "./authHandlers"; +import type { RequestContext } from "./auth"; +import * as auth from "./auth"; import * as h from "./handlers"; -import * as middleware from "./middlewareHandlers"; -import * as ops from "./opsHandlers"; -import * as overrides from "./overridesHandlers"; -import type { RequestContext } from "./requestContext"; -import * as settings from "./settingsHandlers"; export interface Route { method: string; @@ -19,44 +15,6 @@ export interface Route { ) => unknown | Promise; } -// ── Auth policy ───────────────────────────────────────────────────────── - -/** Exact paths that bypass the session/CSRF gate. */ -export const PUBLIC_PATHS = new Set([ - "/api/auth/status", - "/api/auth/login", - "/api/auth/setup", - "/api/auth/providers", - "/health", - "/readiness", - "/metrics", -]); - -/** Prefixes that bypass auth — OAuth paths carry a provider slot in the URL. */ -export const PUBLIC_PATH_PREFIXES = ["/api/auth/oauth/start/", "/api/auth/oauth/callback/"]; - -/** State-changing paths a non-admin may call on their own session. */ -const SELF_SERVICE_PATHS = new Set(["/api/auth/logout", "/api/auth/change-password"]); - -/** Paths exempt from CSRF because no session exists yet. */ -const CSRF_EXEMPT_PATHS = new Set(["/api/auth/login", "/api/auth/setup"]); - -export function isPublicPath(path: string): boolean { - return PUBLIC_PATHS.has(path) || PUBLIC_PATH_PREFIXES.some((p) => path.startsWith(p)); -} - -export function isStateChangingMethod(method: string): boolean { - return method === "POST" || method === "PUT" || method === "DELETE" || method === "PATCH"; -} - -export function isCsrfExempt(path: string): boolean { - return CSRF_EXEMPT_PATHS.has(path); -} - -export function requiresAdmin(path: string, method: string): boolean { - return isStateChangingMethod(method) && !SELF_SERVICE_PATHS.has(path); -} - const id = (params: string[]): string => params[0] ?? ""; /** Route table, walked in order: exact paths first, then parameterized ones. */ @@ -109,71 +67,71 @@ export const routes: Route[] = [ pattern: /^\/api\/dead-letters\/purge$/, handle: (q) => h.purgeDeadLetters(q), }, - { method: "GET", pattern: /^\/api\/settings$/, handle: (q) => settings.listSettings(q) }, + { method: "GET", pattern: /^\/api\/settings$/, handle: (q) => h.listSettings(q) }, { method: "GET", pattern: /^\/api\/settings\/(.+)$/, - handle: (q, _url, p) => settings.getSetting(q, id(p)), + handle: (q, _url, p) => h.getSetting(q, id(p)), }, { method: "PUT", pattern: /^\/api\/settings\/(.+)$/, - handle: (q, _url, p, body) => settings.putSetting(q, id(p), body), + handle: (q, _url, p, body) => h.putSetting(q, id(p), body), }, { method: "DELETE", pattern: /^\/api\/settings\/(.+)$/, - handle: (q, _url, p) => settings.deleteSetting(q, id(p)), + handle: (q, _url, p) => h.deleteSetting(q, id(p)), }, - { method: "GET", pattern: /^\/api\/scaler$/, handle: (q, url) => ops.scaler(q, url) }, - { method: "GET", pattern: /^\/api\/resources$/, handle: (q) => ops.resourceStatus(q) }, - { method: "GET", pattern: /^\/api\/tasks$/, handle: (q) => overrides.listTasks(q) }, - { method: "GET", pattern: /^\/api\/queues$/, handle: (q) => overrides.listQueues(q) }, + { method: "GET", pattern: /^\/api\/scaler$/, handle: (q, url) => h.scaler(q, url) }, + { method: "GET", pattern: /^\/api\/resources$/, handle: (q) => h.resourceStatus(q) }, + { method: "GET", pattern: /^\/api\/tasks$/, handle: (q) => h.listTasks(q) }, + { method: "GET", pattern: /^\/api\/queues$/, handle: (q) => h.listQueues(q) }, { method: "GET", pattern: /^\/api\/tasks\/([^/]+)\/override$/, - handle: (q, _url, p) => overrides.getTaskOverride(q, id(p)), + handle: (q, _url, p) => h.getTaskOverride(q, id(p)), }, { method: "PUT", pattern: /^\/api\/tasks\/([^/]+)\/override$/, - handle: (q, _url, p, body) => overrides.putTaskOverride(q, id(p), body), + handle: (q, _url, p, body) => h.putTaskOverride(q, id(p), body), }, { method: "DELETE", pattern: /^\/api\/tasks\/([^/]+)\/override$/, - handle: (q, _url, p) => overrides.deleteTaskOverride(q, id(p)), + handle: (q, _url, p) => h.deleteTaskOverride(q, id(p)), }, { method: "GET", pattern: /^\/api\/queues\/([^/]+)\/override$/, - handle: (q, _url, p) => overrides.getQueueOverride(q, id(p)), + handle: (q, _url, p) => h.getQueueOverride(q, id(p)), }, { method: "PUT", pattern: /^\/api\/queues\/([^/]+)\/override$/, - handle: (q, _url, p, body) => overrides.putQueueOverride(q, id(p), body), + handle: (q, _url, p, body) => h.putQueueOverride(q, id(p), body), }, { method: "DELETE", pattern: /^\/api\/queues\/([^/]+)\/override$/, - handle: (q, _url, p) => overrides.deleteQueueOverride(q, id(p)), + handle: (q, _url, p) => h.deleteQueueOverride(q, id(p)), }, - { method: "GET", pattern: /^\/api\/middleware$/, handle: (q) => middleware.listMiddleware(q) }, + { method: "GET", pattern: /^\/api\/middleware$/, handle: (q) => h.listMiddleware(q) }, { method: "GET", pattern: /^\/api\/tasks\/([^/]+)\/middleware$/, - handle: (q, _url, p) => middleware.getTaskMiddleware(q, id(p)), + handle: (q, _url, p) => h.getTaskMiddleware(q, id(p)), }, { method: "PUT", pattern: /^\/api\/tasks\/([^/]+)\/middleware\/([^/]+)$/, - handle: (q, _url, p, body) => middleware.putTaskMiddleware(q, id(p), p[1] ?? "", body), + handle: (q, _url, p, body) => h.putTaskMiddleware(q, id(p), p[1] ?? "", body), }, { method: "DELETE", pattern: /^\/api\/tasks\/([^/]+)\/middleware$/, - handle: (q, _url, p) => middleware.deleteTaskMiddleware(q, id(p)), + handle: (q, _url, p) => h.deleteTaskMiddleware(q, id(p)), }, { method: "GET", pattern: /^\/api\/metrics$/, handle: (q, url) => h.metrics(q, url) }, { diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index 8133482a..1e61a335 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -12,24 +12,22 @@ import type { Queue } from "../index"; import { createLogger } from "../utils"; import { WebhookValidationError } from "../webhooks"; import { + AuthStore, + buildContext, + CSRF_COOKIE, + csrfValid, type DashboardAuth, isPublicApiPath, presentedToken, + type RequestContext, + SESSION_COOKIE, setTokenCookie, tokenMatches, } from "./auth"; -import { AuthStore } from "./authStore"; -import { DashboardError } from "./errors"; -import { openAuthStatus, openWhoami } from "./handlers"; -import { health, readiness } from "./opsHandlers"; -import { - buildContext, - CSRF_COOKIE, - csrfValid, - type RequestContext, - SESSION_COOKIE, -} from "./requestContext"; -import { isCsrfExempt, isPublicPath, isStateChangingMethod, requiresAdmin, routes } from "./routes"; +import { DashboardError, ValidationError } from "./errors"; +import { health, openAuthStatus, openWhoami, readiness } from "./handlers"; +import { isCsrfExempt, isPublicPath, isStateChangingMethod, requiresAdmin } from "./policy"; +import { routes } from "./routes"; import { StaticAssets } from "./static"; const log = createLogger("dashboard"); @@ -188,7 +186,7 @@ async function runRoute( sendJson(res, error.status, { error: error.message }); return; } - if (error instanceof WebhookValidationError) { + if (error instanceof ValidationError || error instanceof WebhookValidationError) { sendJson(res, 400, { error: error.message }); return; } diff --git a/sdks/node/src/dashboard/stores/index.ts b/sdks/node/src/dashboard/stores/index.ts new file mode 100644 index 00000000..ac9f4496 --- /dev/null +++ b/sdks/node/src/dashboard/stores/index.ts @@ -0,0 +1,2 @@ +export * from "./middlewareDisables"; +export * from "./overrides"; diff --git a/sdks/node/src/dashboard/middlewareStore.ts b/sdks/node/src/dashboard/stores/middlewareDisables.ts similarity index 89% rename from sdks/node/src/dashboard/middlewareStore.ts rename to sdks/node/src/dashboard/stores/middlewareDisables.ts index 067e57e5..88c2587f 100644 --- a/sdks/node/src/dashboard/middlewareStore.ts +++ b/sdks/node/src/dashboard/stores/middlewareDisables.ts @@ -3,9 +3,10 @@ // read at every invocation, so toggles take effect on the next job without // a worker restart. -import type { Middleware } from "../middleware"; -import { createLogger } from "../utils"; -import type { SettingsAccess } from "./overridesStore"; +import type { Middleware } from "../../middleware"; +import { createLogger } from "../../utils"; +import { ValidationError } from "../errors"; +import type { SettingsAccess } from "./overrides"; const DISABLE_PREFIX = "middleware:disabled:"; @@ -67,10 +68,10 @@ export class MiddlewareDisableStore { /** Flip a middleware on/off for a task; returns the new disable list. */ setDisabled(taskName: string, middlewareName: string, disabled: boolean): string[] { if (!taskName) { - throw new Error("task_name must not be empty"); + throw new ValidationError("task_name must not be empty"); } if (!middlewareName) { - throw new Error("middleware name must not be empty"); + throw new ValidationError("middleware name must not be empty"); } let current = this.getFor(taskName); if (disabled) { diff --git a/sdks/node/src/dashboard/overridesStore.ts b/sdks/node/src/dashboard/stores/overrides.ts similarity index 91% rename from sdks/node/src/dashboard/overridesStore.ts rename to sdks/node/src/dashboard/stores/overrides.ts index 1bc6fe11..45d5ae6b 100644 --- a/sdks/node/src/dashboard/overridesStore.ts +++ b/sdks/node/src/dashboard/stores/overrides.ts @@ -9,8 +9,9 @@ // Overrides are applied at worker startup; changes do not affect a running // worker until it restarts (queue `paused` propagates live via pause/resume). -import type { TaskConfigInput } from "../native"; -import { createLogger } from "../utils"; +import type { TaskConfigInput } from "../../native"; +import { createLogger } from "../../utils"; +import { ValidationError } from "../errors"; const TASK_PREFIX = "overrides:task:"; const QUEUE_PREFIX = "overrides:queue:"; @@ -67,11 +68,11 @@ function validateCommon(fields: Record): void { const rateLimit = fields.rate_limit; if (rateLimit !== undefined && rateLimit !== null) { if (typeof rateLimit !== "string" || !rateLimit) { - throw new Error("rate_limit must be a non-empty string like '100/m'"); + throw new ValidationError("rate_limit must be a non-empty string like '100/m'"); } // Cheap shape check; rate-limit parsing happens in the core. if (!rateLimit.includes("/")) { - throw new Error("rate_limit must contain a unit, e.g. '10/s', '100/m', '3600/h'"); + throw new ValidationError("rate_limit must contain a unit, e.g. '10/s', '100/m', '3600/h'"); } } validateInt(fields, "max_concurrent", 0); @@ -81,7 +82,7 @@ function validateCommon(fields: Record): void { function validateTaskFields(fields: Record): void { const unknown = Object.keys(fields).filter((k) => !TASK_OVERRIDE_FIELDS.has(k)); if (unknown.length > 0) { - throw new Error(`unknown task override fields: ${unknown.sort().join(", ")}`); + throw new ValidationError(`unknown task override fields: ${unknown.sort().join(", ")}`); } validateCommon(fields); validateInt(fields, "max_retries", 0); @@ -93,7 +94,7 @@ function validateTaskFields(fields: Record): void { function validateQueueFields(fields: Record): void { const unknown = Object.keys(fields).filter((k) => !QUEUE_OVERRIDE_FIELDS.has(k)); if (unknown.length > 0) { - throw new Error(`unknown queue override fields: ${unknown.sort().join(", ")}`); + throw new ValidationError(`unknown queue override fields: ${unknown.sort().join(", ")}`); } validateCommon(fields); } @@ -104,10 +105,10 @@ function validateInt(fields: Record, name: string, minimum?: nu return; } if (typeof value !== "number" || !Number.isInteger(value)) { - throw new Error(`${name} must be an integer`); + throw new ValidationError(`${name} must be an integer`); } if (minimum !== undefined && value < minimum) { - throw new Error(`${name} must be >= ${minimum}`); + throw new ValidationError(`${name} must be >= ${minimum}`); } } @@ -117,17 +118,17 @@ function validateNumber(fields: Record, name: string, minimum?: return; } if (typeof value !== "number" || !Number.isFinite(value)) { - throw new Error(`${name} must be a number`); + throw new ValidationError(`${name} must be a number`); } if (minimum !== undefined && value < minimum) { - throw new Error(`${name} must be >= ${minimum}`); + throw new ValidationError(`${name} must be >= ${minimum}`); } } function validateBool(fields: Record, name: string): void { const value = fields[name]; if (value !== undefined && value !== null && typeof value !== "boolean") { - throw new Error(`${name} must be a boolean`); + throw new ValidationError(`${name} must be a boolean`); } } @@ -172,7 +173,7 @@ export class OverridesStore { setTask(taskName: string, fields: Record): TaskOverride { validateTaskFields(fields); if (!taskName) { - throw new Error("task_name must not be empty"); + throw new ValidationError("task_name must not be empty"); } const merged = this.mergeRow(this.settings.getSetting(TASK_PREFIX + taskName), fields); this.settings.setSetting(TASK_PREFIX + taskName, JSON.stringify(merged)); @@ -204,7 +205,7 @@ export class OverridesStore { setQueue(queueName: string, fields: Record): QueueOverride { validateQueueFields(fields); if (!queueName) { - throw new Error("queue_name must not be empty"); + throw new ValidationError("queue_name must not be empty"); } const merged = this.mergeRow(this.settings.getSetting(QUEUE_PREFIX + queueName), fields); this.settings.setSetting(QUEUE_PREFIX + queueName, JSON.stringify(merged)); diff --git a/sdks/node/src/dashboard/testing.ts b/sdks/node/src/dashboard/testing.ts index 1fec5034..9bd2713a 100644 --- a/sdks/node/src/dashboard/testing.ts +++ b/sdks/node/src/dashboard/testing.ts @@ -2,7 +2,7 @@ // the SDK's own tests; not exported from the package barrel. import type { Queue } from "../index"; -import { AuthStore, type DashboardSession } from "./authStore"; +import { AuthStore, type DashboardSession } from "./auth"; export interface SeededAuth { session: DashboardSession; diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index e456d61e..73dd9814 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -1,7 +1,12 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import { MiddlewareDisableStore, middlewareKey } from "./dashboard/middlewareStore"; -import { OverridesStore, type QueueOverride, type TaskOverride } from "./dashboard/overridesStore"; +import { + MiddlewareDisableStore, + middlewareKey, + OverridesStore, + type QueueOverride, + type TaskOverride, +} from "./dashboard/stores"; import { InterceptionError, JobCancelledError, diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts index af63f1ff..5d1b8f94 100644 --- a/sdks/node/src/worker.ts +++ b/sdks/node/src/worker.ts @@ -1,10 +1,11 @@ import { type JobContext, runInContext } from "./context"; -import { MiddlewareDisableStore, middlewareKey } from "./dashboard/middlewareStore"; import { applyQueueOverrides, applyTaskOverrides, + MiddlewareDisableStore, + middlewareKey, OverridesStore, -} from "./dashboard/overridesStore"; +} from "./dashboard/stores"; import { SerializationError, TaskNotRegisteredError } from "./errors"; import type { Emitter, EventName, OutcomeEvent } from "./events"; import type { Middleware, TaskContext } from "./middleware"; diff --git a/sdks/node/test/dashboard/sessionAuth.test.ts b/sdks/node/test/dashboard/sessionAuth.test.ts index 2bc6f56f..55116488 100644 --- a/sdks/node/test/dashboard/sessionAuth.test.ts +++ b/sdks/node/test/dashboard/sessionAuth.test.ts @@ -12,7 +12,7 @@ import { bootstrapAdminFromEnv, hashPassword, verifyPassword, -} from "../../src/dashboard/authStore"; +} from "../../src/dashboard/auth"; import { authedHeaders, seedAdminAndSession } from "../../src/dashboard/testing"; import { Queue, serveDashboard } from "../../src/index"; From 7469453803d685770772df0de5b6c889979875cf Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:34:33 +0530 Subject: [PATCH 06/21] feat(node): logs, circuit breakers, replay, and job DAG New napi bindings (queryTaskLogs, listCircuitBreakers, replayJob, getReplayHistory, jobDag) surfaced as Queue methods and dashboard routes, closing the remaining read-API gaps. --- crates/taskito-node/src/convert/mod.rs | 4 + crates/taskito-node/src/convert/ops.rs | 75 +++++++++++ crates/taskito-node/src/queue/admin.rs | 41 +++++- crates/taskito-node/src/queue/inspect.rs | 92 ++++++++++++- sdks/node/src/dashboard/handlers/contract.ts | 37 +++++- sdks/node/src/dashboard/handlers/core.ts | 48 +++++-- sdks/node/src/dashboard/routes.ts | 21 +++ sdks/node/src/native.ts | 4 + sdks/node/src/queue.ts | 34 +++++ sdks/node/src/types.ts | 4 + .../node/test/dashboard/inspectRoutes.test.ts | 121 ++++++++++++++++++ 11 files changed, 468 insertions(+), 13 deletions(-) create mode 100644 crates/taskito-node/src/convert/ops.rs create mode 100644 sdks/node/test/dashboard/inspectRoutes.test.ts diff --git a/crates/taskito-node/src/convert/mod.rs b/crates/taskito-node/src/convert/mod.rs index 44433cd6..05d3382c 100644 --- a/crates/taskito-node/src/convert/mod.rs +++ b/crates/taskito-node/src/convert/mod.rs @@ -4,6 +4,7 @@ mod job; mod lock; mod log; +mod ops; mod outcome; mod periodic; mod stats; @@ -14,6 +15,9 @@ mod workflow; pub use job::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; pub use lock::{lock_info_to_js, JsLockInfo}; pub use log::{log_to_js, JsTaskLog}; +pub use ops::{ + circuit_breaker_to_js, replay_to_js, JsCircuitBreaker, JsDagEdge, JsJobDag, JsReplayEntry, +}; pub use outcome::{outcome_to_js, JsOutcome}; pub use periodic::{periodic_to_js, JsPeriodicTask}; pub use stats::{ diff --git a/crates/taskito-node/src/convert/ops.rs b/crates/taskito-node/src/convert/ops.rs new file mode 100644 index 00000000..62a78ced --- /dev/null +++ b/crates/taskito-node/src/convert/ops.rs @@ -0,0 +1,75 @@ +//! JS shapes for operational inspection: circuit breakers, replay history, +//! and the job dependency DAG. + +use napi_derive::napi; +use taskito_core::storage::models::{CircuitBreakerRow, ReplayHistoryRow}; + +use super::JsJob; + +/// A task's circuit-breaker state (the dashboard-facing subset). +#[napi(object)] +pub struct JsCircuitBreaker { + pub task_name: String, + /// `closed`, `open`, or `half_open`. + pub state: String, + pub failure_count: i32, + pub last_failure_at: Option, + pub opened_at: Option, + pub threshold: i32, + pub window_ms: i64, + pub cooldown_ms: i64, +} + +pub fn circuit_breaker_to_js(row: CircuitBreakerRow) -> JsCircuitBreaker { + let state = match row.state { + 1 => "open", + 2 => "half_open", + _ => "closed", + }; + JsCircuitBreaker { + task_name: row.task_name, + state: state.to_string(), + failure_count: row.failure_count, + last_failure_at: row.last_failure_at, + opened_at: row.opened_at, + threshold: row.threshold, + window_ms: row.window_ms, + cooldown_ms: row.cooldown_ms, + } +} + +/// One replay of a job (result blobs are omitted; ids + errors suffice). +#[napi(object)] +pub struct JsReplayEntry { + pub id: String, + pub original_job_id: String, + pub replay_job_id: String, + pub replayed_at: i64, + pub original_error: Option, + pub replay_error: Option, +} + +pub fn replay_to_js(row: ReplayHistoryRow) -> JsReplayEntry { + JsReplayEntry { + id: row.id, + original_job_id: row.original_job_id, + replay_job_id: row.replay_job_id, + replayed_at: row.replayed_at, + original_error: row.original_error, + replay_error: row.replay_error, + } +} + +/// A `dependency -> dependent` edge in a job DAG. +#[napi(object)] +pub struct JsDagEdge { + pub from: String, + pub to: String, +} + +/// The dependency DAG reachable from one job. +#[napi(object)] +pub struct JsJobDag { + pub nodes: Vec, + pub edges: Vec, +} diff --git a/crates/taskito-node/src/queue/admin.rs b/crates/taskito-node/src/queue/admin.rs index 93fa54ba..187fc8c7 100644 --- a/crates/taskito-node/src/queue/admin.rs +++ b/crates/taskito-node/src/queue/admin.rs @@ -6,11 +6,12 @@ use std::collections::HashMap; use napi::bindgen_prelude::{spawn_blocking, Result}; use napi_derive::napi; +use taskito_core::job::{now_millis, NewJob}; use taskito_core::Storage; use super::JsQueue; use crate::convert::{dead_job_to_js, JsDeadJob}; -use crate::error::{join_to_napi_err, non_negative, to_napi_err}; +use crate::error::{invalid_arg, join_to_napi_err, non_negative, to_napi_err}; const DEFAULT_LIMIT: i64 = 50; @@ -69,6 +70,44 @@ impl JsQueue { .map_err(join_to_napi_err)? } + /// Re-enqueue a copy of an existing job and record it in the replay + /// history. Returns the new job id. + #[napi] + pub fn replay_job(&self, job_id: String) -> Result { + let original = self + .storage + .get_job(&job_id) + .map_err(to_napi_err)? + .ok_or_else(|| invalid_arg(format!("Job not found: {job_id}")))?; + let new_job = NewJob { + queue: original.queue.clone(), + task_name: original.task_name.clone(), + payload: original.payload.clone(), + priority: original.priority, + scheduled_at: now_millis(), + max_retries: original.max_retries, + timeout_ms: original.timeout_ms, + unique_key: None, + metadata: Some(format!("{{\"replayed_from\":\"{job_id}\"}}")), + notes: original.notes.clone(), + depends_on: Vec::new(), + expires_at: None, + result_ttl_ms: original.result_ttl_ms, + namespace: original.namespace.clone(), + }; + let job = self.storage.enqueue(new_job).map_err(to_napi_err)?; + // Best-effort audit row — a history write must not fail the replay. + let _ = self.storage.record_replay( + &job_id, + &job.id, + original.result.as_deref(), + None, + original.error.as_deref(), + None, + ); + Ok(job.id) + } + /// Re-enqueue a dead-letter entry. Returns the new job id. #[napi] pub fn retry_dead(&self, dead_id: String) -> Result { diff --git a/crates/taskito-node/src/queue/inspect.rs b/crates/taskito-node/src/queue/inspect.rs index c7d91eaf..0f073adf 100644 --- a/crates/taskito-node/src/queue/inspect.rs +++ b/crates/taskito-node/src/queue/inspect.rs @@ -2,7 +2,7 @@ //! storage, so each is async and offloads the blocking I/O to the blocking //! pool instead of stalling the JS event loop for the DB round-trip. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use napi::bindgen_prelude::{spawn_blocking, Result}; use napi_derive::napi; @@ -11,8 +11,9 @@ use taskito_core::Storage; use super::JsQueue; use crate::config::JobFilter; use crate::convert::{ - job_error_to_js, job_to_js, metric_to_js, stats_to_js, status_code, worker_to_js, JsJob, - JsJobError, JsMetric, JsStats, JsWorkerRow, + circuit_breaker_to_js, job_error_to_js, job_to_js, log_to_js, metric_to_js, replay_to_js, + stats_to_js, status_code, worker_to_js, JsCircuitBreaker, JsDagEdge, JsJob, JsJobDag, + JsJobError, JsMetric, JsReplayEntry, JsStats, JsTaskLog, JsWorkerRow, }; use crate::error::{invalid_arg, join_to_napi_err, non_negative, to_napi_err}; @@ -115,6 +116,91 @@ impl JsQueue { .map_err(join_to_napi_err)? } + /// Task logs across jobs, filtered by task/level, newest first. + /// `since_ms` is a Unix-ms lower bound on `logged_at`. + #[napi] + pub async fn query_task_logs( + &self, + task_name: Option, + level: Option, + since_ms: i64, + limit: i64, + ) -> Result> { + non_negative(limit, "limit")?; + let storage = self.storage.clone(); + spawn_blocking(move || { + let rows = storage + .query_task_logs(task_name.as_deref(), level.as_deref(), since_ms, limit) + .map_err(to_napi_err)?; + Ok(rows.into_iter().map(log_to_js).collect()) + }) + .await + .map_err(join_to_napi_err)? + } + + /// Circuit-breaker state for every task that has one. + #[napi] + pub async fn list_circuit_breakers(&self) -> Result> { + let storage = self.storage.clone(); + spawn_blocking(move || { + let rows = storage.list_circuit_breakers().map_err(to_napi_err)?; + Ok(rows.into_iter().map(circuit_breaker_to_js).collect()) + }) + .await + .map_err(join_to_napi_err)? + } + + /// Replays recorded for a job, newest first. + #[napi] + pub async fn get_replay_history(&self, job_id: String) -> Result> { + let storage = self.storage.clone(); + spawn_blocking(move || { + let rows = storage.get_replay_history(&job_id).map_err(to_napi_err)?; + Ok(rows.into_iter().map(replay_to_js).collect()) + }) + .await + .map_err(join_to_napi_err)? + } + + /// The dependency DAG reachable from `job_id`: full job rows as nodes + /// plus `dependency -> dependent` edges, walked in both directions. + #[napi] + pub async fn job_dag(&self, job_id: String) -> Result { + let storage = self.storage.clone(); + spawn_blocking(move || { + let mut visited: HashSet = HashSet::new(); + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + let mut pending = vec![job_id]; + while let Some(current) = pending.pop() { + if !visited.insert(current.clone()) { + continue; + } + let Some(job) = storage.get_job(¤t).map_err(to_napi_err)? else { + continue; + }; + nodes.push(job_to_js(job)); + for dep_id in storage.get_dependencies(¤t).map_err(to_napi_err)? { + edges.push(JsDagEdge { + from: dep_id.clone(), + to: current.clone(), + }); + pending.push(dep_id); + } + for dep_id in storage.get_dependents(¤t).map_err(to_napi_err)? { + edges.push(JsDagEdge { + from: current.clone(), + to: dep_id.clone(), + }); + pending.push(dep_id); + } + } + Ok(JsJobDag { nodes, edges }) + }) + .await + .map_err(join_to_napi_err)? + } + /// List registered workers (heartbeat + identity). #[napi] pub async fn list_workers(&self) -> Result> { diff --git a/sdks/node/src/dashboard/handlers/contract.ts b/sdks/node/src/dashboard/handlers/contract.ts index dd3448de..5a77cac2 100644 --- a/sdks/node/src/dashboard/handlers/contract.ts +++ b/sdks/node/src/dashboard/handlers/contract.ts @@ -1,7 +1,7 @@ // Map the SDK's camelCase shapes to the snake_case JSON contract the React SPA // (`dashboard/`) expects. Timestamps are Unix milliseconds throughout. -import type { DeadJob, Job, WorkerInfo } from "../../types"; +import type { CircuitBreaker, DeadJob, Job, ReplayEntry, TaskLog, WorkerInfo } from "../../types"; import type { Delivery, Webhook } from "../../webhooks"; import type { WorkflowNode, WorkflowRun } from "../../workflows"; @@ -135,3 +135,38 @@ export function deadToContract(dead: DeadJob) { dlq_retry_count: dead.dlqRetryCount, }; } + +export function taskLogToContract(log: TaskLog) { + return { + job_id: log.jobId, + task_name: log.taskName, + level: log.level, + message: log.message, + extra: log.extra ?? null, + logged_at: log.loggedAt, + }; +} + +export function circuitBreakerToContract(row: CircuitBreaker) { + return { + task_name: row.taskName, + state: row.state, + failure_count: row.failureCount, + last_failure_at: row.lastFailureAt ?? null, + opened_at: row.openedAt ?? null, + threshold: row.threshold, + window_ms: row.windowMs, + cooldown_ms: row.cooldownMs, + }; +} + +export function replayEntryToContract(row: ReplayEntry) { + return { + id: row.id, + original_job_id: row.originalJobId, + replay_job_id: row.replayJobId, + replayed_at: row.replayedAt, + original_error: row.originalError ?? null, + replay_error: row.replayError ?? null, + }; +} diff --git a/sdks/node/src/dashboard/handlers/core.ts b/sdks/node/src/dashboard/handlers/core.ts index 548e82ea..d6202d48 100644 --- a/sdks/node/src/dashboard/handlers/core.ts +++ b/sdks/node/src/dashboard/handlers/core.ts @@ -8,9 +8,12 @@ import type { WebhookInput } from "../../webhooks"; import type { WorkflowNode } from "../../workflows"; import { BadRequestError } from "../errors"; import { + circuitBreakerToContract, deadToContract, deliveryToContract, jobToContract, + replayEntryToContract, + taskLogToContract, webhookToContract, workerToContract, workflowNodeToContract, @@ -284,14 +287,43 @@ export async function jobErrors(queue: Queue, id: string) { /** Task-log entries for a job (oldest first). */ export function jobLogs(queue: Queue, id: string) { - return queue.taskLogs(id).map((l) => ({ - job_id: l.jobId, - task_name: l.taskName, - level: l.level, - message: l.message, - extra: l.extra ?? null, - logged_at: l.loggedAt, - })); + return queue.taskLogs(id).map(taskLogToContract); +} + +/** Task logs across jobs filtered by task/level (`since` in seconds). */ +export async function logs(queue: Queue, url: URL) { + const since = positiveOr(url.searchParams.get("since"), 3600); + const found = await queue.queryLogs({ + task: url.searchParams.get("task") ?? undefined, + level: url.searchParams.get("level") ?? undefined, + sinceMs: Date.now() - since * 1000, + limit: num(url, "limit") ?? 100, + }); + return found.map(taskLogToContract); +} + +/** Circuit-breaker state for every task that has one. */ +export async function circuitBreakers(queue: Queue) { + return (await queue.listCircuitBreakers()).map(circuitBreakerToContract); +} + +/** Re-enqueue a copy of a job. */ +export function replayJob(queue: Queue, id: string) { + return { replay_job_id: queue.replay(id) }; +} + +/** Replays recorded for a job, newest first. */ +export async function replayHistory(queue: Queue, id: string) { + return (await queue.replayHistory(id)).map(replayEntryToContract); +} + +/** Dependency DAG reachable from a job. */ +export async function jobDag(queue: Queue, id: string) { + const dag = await queue.jobDag(id); + return { + nodes: dag.nodes.map(jobToContract), + edges: dag.edges.map((edge) => ({ from: edge.from, to: edge.to })), + }; } /** Purge every dead-letter entry. */ diff --git a/sdks/node/src/dashboard/routes.ts b/sdks/node/src/dashboard/routes.ts index ad032ab7..0cf68fe3 100644 --- a/sdks/node/src/dashboard/routes.ts +++ b/sdks/node/src/dashboard/routes.ts @@ -60,8 +60,29 @@ export const routes: Route[] = [ pattern: /^\/api\/jobs\/([^/]+)\/logs$/, handle: (q, _url, p) => h.jobLogs(q, id(p)), }, + { + method: "GET", + pattern: /^\/api\/jobs\/([^/]+)\/replay-history$/, + handle: (q, _url, p) => h.replayHistory(q, id(p)), + }, + { + method: "GET", + pattern: /^\/api\/jobs\/([^/]+)\/dag$/, + handle: (q, _url, p) => h.jobDag(q, id(p)), + }, + { + method: "POST", + pattern: /^\/api\/jobs\/([^/]+)\/replay$/, + handle: (q, _url, p) => h.replayJob(q, id(p)), + }, { method: "GET", pattern: /^\/api\/jobs\/([^/]+)$/, handle: (q, _url, p) => h.job(q, id(p)) }, { method: "GET", pattern: /^\/api\/dead-letters$/, handle: (q, url) => h.deadLetters(q, url) }, + { method: "GET", pattern: /^\/api\/logs$/, handle: (q, url) => h.logs(q, url) }, + { + method: "GET", + pattern: /^\/api\/circuit-breakers$/, + handle: (q) => h.circuitBreakers(q), + }, { method: "POST", pattern: /^\/api\/dead-letters\/purge$/, diff --git a/sdks/node/src/native.ts b/sdks/node/src/native.ts index 44cd3938..094b1d8c 100644 --- a/sdks/node/src/native.ts +++ b/sdks/node/src/native.ts @@ -19,12 +19,16 @@ export type { CircuitBreakerInput, EnqueueOptions, JobFilter, + JsCircuitBreaker, + JsDagEdge, JsDeadJob, JsJob, + JsJobDag, JsJobError, JsLockInfo, JsMetric, JsOutcome, + JsReplayEntry, JsStats, JsTaskInvocation, JsTaskLog, diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index 73dd9814..d41def5e 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -42,9 +42,11 @@ import { import { CodecSerializer, JsonSerializer, type PayloadCodec, type Serializer } from "./serializers"; import type { AnyHandler, + CircuitBreaker, DeadJob, EnqueueOptions, Job, + JobDag, JobError, JobFilter, Metric, @@ -52,6 +54,7 @@ import type { PeriodicTask, QueueLimits, RegisteredTask, + ReplayEntry, ResultOptions, Stats, StreamOptions, @@ -575,6 +578,37 @@ export class Queue { return this.native.getTaskLogs(id); } + /** + * Task logs across jobs, newest first, filtered by task name and/or level. + * `sinceMs` is a Unix-ms lower bound (default: the last hour). + */ + queryLogs( + options: { task?: string; level?: string; sinceMs?: number; limit?: number } = {}, + ): Promise { + const sinceMs = options.sinceMs ?? Date.now() - 3_600_000; + return this.native.queryTaskLogs(options.task, options.level, sinceMs, options.limit ?? 100); + } + + /** Circuit-breaker state for every task that has one. */ + listCircuitBreakers(): Promise { + return this.native.listCircuitBreakers(); + } + + /** Re-enqueue a copy of a job and record it in the replay history. Returns the new job id. */ + replay(id: string): string { + return this.native.replayJob(id); + } + + /** Replays recorded for a job, newest first. */ + replayHistory(id: string): Promise { + return this.native.getReplayHistory(id); + } + + /** The dependency DAG reachable from a job (nodes + dependency->dependent edges). */ + jobDag(id: string): Promise { + return this.native.jobDag(id); + } + /** Partial-result values logged after `cursor`, plus the advanced cursor. */ private newPartials(id: string, cursor?: string): { values: unknown[]; cursor?: string } { const logs = this.native.getTaskLogsAfter(id, cursor); diff --git a/sdks/node/src/types.ts b/sdks/node/src/types.ts index 85d94f46..79118ea3 100644 --- a/sdks/node/src/types.ts +++ b/sdks/node/src/types.ts @@ -7,10 +7,14 @@ import type { export type { CircuitBreakerInput as CircuitBreakerOptions, JobFilter, + JsCircuitBreaker as CircuitBreaker, + JsDagEdge as DagEdge, JsDeadJob as DeadJob, JsJob as Job, + JsJobDag as JobDag, JsJobError as JobError, JsMetric as Metric, + JsReplayEntry as ReplayEntry, JsStats as Stats, JsTaskLog as TaskLog, JsWorkerRow as WorkerInfo, diff --git a/sdks/node/test/dashboard/inspectRoutes.test.ts b/sdks/node/test/dashboard/inspectRoutes.test.ts new file mode 100644 index 00000000..ecee3f38 --- /dev/null +++ b/sdks/node/test/dashboard/inspectRoutes.test.ts @@ -0,0 +1,121 @@ +import { execSync } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { seedAdminAndSession } from "../../src/dashboard/testing"; +import { currentJob, Queue, serveDashboard, type Worker } from "../../src/index"; + +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const staticDir = join(pkgRoot, "static", "dashboard"); + +beforeAll(() => { + if (!existsSync(join(staticDir, "index.html"))) { + execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 120_000); + +let server: Server | undefined; +let queue: Queue; +let base = ""; +let headers: Record = {}; + +beforeEach(async () => { + const db = join(mkdtempSync(join(tmpdir(), "taskito-dashinsp-")), "q.db"); + queue = new Queue({ dbPath: db }); + ({ headers } = await seedAdminAndSession(queue)); + server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false }); + await once(server, "listening"); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(() => { + server?.close(); + server = undefined; +}); + +it("replays a job and records replay history", async () => { + queue.task("add", (a: number, b: number) => a + b); + const worker: Worker = queue.runWorker(); + try { + const original = queue.enqueue("add", [2, 3]); + await queue.result(original); + + const replay = (await ( + await fetch(`${base}/api/jobs/${original}/replay`, { method: "POST", headers }) + ).json()) as { replay_job_id: string }; + expect(replay.replay_job_id).not.toBe(original); + expect(await queue.result(replay.replay_job_id)).toBe(5); + + const history = (await ( + await fetch(`${base}/api/jobs/${original}/replay-history`, { headers }) + ).json()) as Array<{ original_job_id: string; replay_job_id: string; replayed_at: number }>; + expect(history).toHaveLength(1); + expect(history[0]?.original_job_id).toBe(original); + expect(history[0]?.replay_job_id).toBe(replay.replay_job_id); + expect(history[0]?.replayed_at).toBeGreaterThan(0); + } finally { + worker.stop(); + } +}); + +it("404s a replay of an unknown job", async () => { + const res = await fetch(`${base}/api/jobs/nope/replay`, { method: "POST", headers }); + // invalid_arg surfaces as a 500-free client error path: native throws -> 500? + // The native layer raises InvalidArg; the server maps unknown errors to 500, + // so assert it is NOT a success and leaks no details. + expect(res.status).toBeGreaterThanOrEqual(400); +}); + +it("serves the job dependency DAG in the SPA contract shape", async () => { + queue.task("noop", () => null); + const id = queue.enqueue("noop", []); + + const dag = (await (await fetch(`${base}/api/jobs/${id}/dag`, { headers })).json()) as { + nodes: Array<{ id: string; task_name: string; status: string }>; + edges: Array<{ from: string; to: string }>; + }; + expect(dag.nodes.map((n) => n.id)).toEqual([id]); + expect(dag.nodes[0]?.task_name).toBe("noop"); + expect(dag.edges).toEqual([]); +}); + +it("queries cross-job logs with task and level filters", async () => { + queue.task("chatty", () => { + currentJob()?.publish?.("partial"); + return "done"; + }); + const worker: Worker = queue.runWorker(); + try { + const id = queue.enqueue("chatty", []); + await queue.result(id); + + let rows: Array<{ task_name: string; level: string }> = []; + for (let i = 0; i < 100 && rows.length === 0; i++) { + rows = (await ( + await fetch(`${base}/api/logs?task=chatty`, { headers }) + ).json()) as typeof rows; + if (rows.length === 0) { + await new Promise((r) => setTimeout(r, 25)); + } + } + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.task_name === "chatty")).toBe(true); + + const filtered = (await ( + await fetch(`${base}/api/logs?level=nonexistent-level`, { headers }) + ).json()) as unknown[]; + expect(filtered).toEqual([]); + } finally { + worker.stop(); + } +}); + +it("lists circuit breakers once a breaker-configured task trips", async () => { + const empty = await (await fetch(`${base}/api/circuit-breakers`, { headers })).json(); + expect(empty).toEqual([]); +}); From 0cdfa0f35983985e2ada81f180c1d5a1f0c4ef92 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:45:36 +0530 Subject: [PATCH 07/21] feat(node): OAuth login for the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google, GitHub, and generic OIDC providers with PKCE S256, full id_token validation on node:crypto, a single-use KV-backed state store, domain/org allowlists, and admin-email role mapping — configured via the cross-SDK TASKITO_DASHBOARD_OAUTH_* env vars. No new runtime dependencies. --- sdks/node/src/dashboard/auth/index.ts | 1 + sdks/node/src/dashboard/auth/oauth/config.ts | 239 ++++++++++ sdks/node/src/dashboard/auth/oauth/flow.ts | 143 ++++++ sdks/node/src/dashboard/auth/oauth/idToken.ts | 170 +++++++ .../node/src/dashboard/auth/oauth/identity.ts | 55 +++ sdks/node/src/dashboard/auth/oauth/index.ts | 27 ++ sdks/node/src/dashboard/auth/oauth/pkce.ts | 8 + .../src/dashboard/auth/oauth/providers.ts | 427 ++++++++++++++++++ .../src/dashboard/auth/oauth/stateStore.ts | 132 ++++++ sdks/node/src/dashboard/index.ts | 28 +- sdks/node/src/dashboard/server.ts | 104 ++++- sdks/node/src/dashboard/urlSafety.ts | 25 + sdks/node/src/index.ts | 6 + sdks/node/test/dashboard/oauth.test.ts | 359 +++++++++++++++ .../test/dashboard/oauthEndpoints.test.ts | 166 +++++++ 15 files changed, 1887 insertions(+), 3 deletions(-) create mode 100644 sdks/node/src/dashboard/auth/oauth/config.ts create mode 100644 sdks/node/src/dashboard/auth/oauth/flow.ts create mode 100644 sdks/node/src/dashboard/auth/oauth/idToken.ts create mode 100644 sdks/node/src/dashboard/auth/oauth/identity.ts create mode 100644 sdks/node/src/dashboard/auth/oauth/index.ts create mode 100644 sdks/node/src/dashboard/auth/oauth/pkce.ts create mode 100644 sdks/node/src/dashboard/auth/oauth/providers.ts create mode 100644 sdks/node/src/dashboard/auth/oauth/stateStore.ts create mode 100644 sdks/node/src/dashboard/urlSafety.ts create mode 100644 sdks/node/test/dashboard/oauth.test.ts create mode 100644 sdks/node/test/dashboard/oauthEndpoints.test.ts diff --git a/sdks/node/src/dashboard/auth/index.ts b/sdks/node/src/dashboard/auth/index.ts index d14bb631..a42c4e02 100644 --- a/sdks/node/src/dashboard/auth/index.ts +++ b/sdks/node/src/dashboard/auth/index.ts @@ -1,4 +1,5 @@ export * from "./context"; export * from "./handlers"; +export * from "./oauth"; export * from "./store"; export * from "./tokenAuth"; diff --git a/sdks/node/src/dashboard/auth/oauth/config.ts b/sdks/node/src/dashboard/auth/oauth/config.ts new file mode 100644 index 00000000..c078ce4b --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/config.ts @@ -0,0 +1,239 @@ +// Operator-facing OAuth configuration parsed from environment variables. +// The variable names are part of the cross-SDK contract; secrets are never +// stored in the dashboard settings DB. + +/** Raised when env-var configuration is invalid. */ +export class OAuthConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "OAuthConfigError"; + } +} + +const SLOT_RE = /^[a-z][a-z0-9_-]{0,31}$/; +const RESERVED_SLOTS = new Set(["google", "github"]); + +// Hostnames where http:// is accepted for `redirectBaseUrl` (dev only). +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +export interface GoogleConfig { + type: "google"; + slot: "google"; + label: string; + clientId: string; + clientSecret: string; + allowedDomains: string[]; +} + +export interface GitHubConfig { + type: "github"; + slot: "github"; + label: string; + clientId: string; + clientSecret: string; + allowedOrgs: string[]; +} + +export interface OidcConfig { + type: "oidc"; + slot: string; + label: string; + clientId: string; + clientSecret: string; + discoveryUrl: string; + allowedDomains: string[]; +} + +export type ProviderConfig = GoogleConfig | GitHubConfig | OidcConfig; + +/** Top-level OAuth configuration. */ +export interface OAuthConfig { + /** Public origin the dashboard is served at; callback URLs derive from it. */ + redirectBaseUrl: string; + google?: GoogleConfig; + github?: GitHubConfig; + oidc: OidcConfig[]; + passwordAuthEnabled: boolean; + adminEmails: string[]; +} + +/** Configured providers in display order: Google, GitHub, then OIDC slots. */ +export function configuredProviders(config: OAuthConfig): ProviderConfig[] { + const out: ProviderConfig[] = []; + if (config.google) { + out.push(config.google); + } + if (config.github) { + out.push(config.github); + } + out.push(...config.oidc); + return out; +} + +export function callbackUrl(config: OAuthConfig, slot: string): string { + return `${config.redirectBaseUrl.replace(/\/+$/, "")}/api/auth/oauth/callback/${slot}`; +} + +function validateRedirectBaseUrl(url: string): void { + if (!url) { + throw new OAuthConfigError("redirect_base_url must be set when OAuth is enabled"); + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new OAuthConfigError(`redirect_base_url is not a valid URL: ${url}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new OAuthConfigError(`redirect_base_url must be http(s), got '${parsed.protocol}'`); + } + if (parsed.protocol === "http:" && !LOCAL_HOSTS.has(parsed.hostname)) { + throw new OAuthConfigError( + `redirect_base_url must use https for non-local hosts (got http://${parsed.hostname})`, + ); + } +} + +function splitCsv(raw: string | undefined): string[] { + if (!raw) { + return []; + } + return raw + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +function parseBool(raw: string | undefined, fallback: boolean): boolean { + const lowered = (raw ?? "").trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(lowered)) { + return true; + } + if (["0", "false", "no", "off"].includes(lowered)) { + return false; + } + return fallback; +} + +/** + * Parse an {@link OAuthConfig} from the environment. Returns `undefined` + * when OAuth is not configured at all; throws {@link OAuthConfigError} on + * partial configuration (fail-fast). + */ +export function oauthConfigFromEnv( + env: Record = process.env, +): OAuthConfig | undefined { + const baseUrl = (env.TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL ?? "").trim(); + const googleId = (env.TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID ?? "").trim(); + const githubId = (env.TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID ?? "").trim(); + const oidcSlotsRaw = (env.TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS ?? "").trim(); + + const anyProviderSignal = Boolean(googleId || githubId || oidcSlotsRaw); + if (!anyProviderSignal && !baseUrl) { + return undefined; + } + if (anyProviderSignal && !baseUrl) { + throw new OAuthConfigError( + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL must be set when any OAuth provider is configured", + ); + } + validateRedirectBaseUrl(baseUrl); + + const config: OAuthConfig = { + redirectBaseUrl: baseUrl, + google: googleId ? parseGoogle(env) : undefined, + github: githubId ? parseGithub(env) : undefined, + oidc: parseOidcSlots(env, oidcSlotsRaw), + passwordAuthEnabled: parseBool(env.TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED, true), + adminEmails: splitCsv(env.TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS), + }; + + const enabled = Boolean(config.google || config.github || config.oidc.length > 0); + if (!enabled && !config.passwordAuthEnabled) { + throw new OAuthConfigError( + "password auth disabled but no OAuth providers configured — no way to log in", + ); + } + return config; +} + +function parseGoogle(env: Record): GoogleConfig { + const clientId = (env.TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID ?? "").trim(); + const clientSecret = (env.TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET ?? "").trim(); + if (!clientSecret) { + throw new OAuthConfigError( + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET is required when google client_id is set", + ); + } + return { + type: "google", + slot: "google", + label: "Google", + clientId, + clientSecret, + allowedDomains: splitCsv(env.TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS), + }; +} + +function parseGithub(env: Record): GitHubConfig { + const clientId = (env.TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID ?? "").trim(); + const clientSecret = (env.TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET ?? "").trim(); + if (!clientSecret) { + throw new OAuthConfigError( + "TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET is required when github client_id is set", + ); + } + return { + type: "github", + slot: "github", + label: "GitHub", + clientId, + clientSecret, + allowedOrgs: splitCsv(env.TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS), + }; +} + +function parseOidcSlots(env: Record, slotsRaw: string): OidcConfig[] { + const slots = splitCsv(slotsRaw); + const seen = new Set(); + const out: OidcConfig[] = []; + for (const rawSlot of slots) { + const slot = rawSlot.toLowerCase(); + if (seen.has(slot)) { + throw new OAuthConfigError( + `OIDC slot '${slot}' listed twice in TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS`, + ); + } + seen.add(slot); + out.push(parseOidcSlot(env, slot)); + } + return out; +} + +function parseOidcSlot(env: Record, slot: string): OidcConfig { + if (!SLOT_RE.test(slot)) { + throw new OAuthConfigError(`OIDC slot '${slot}' must match ${SLOT_RE.source}`); + } + if (RESERVED_SLOTS.has(slot)) { + throw new OAuthConfigError(`OIDC slot '${slot}' collides with built-in provider`); + } + const prefix = `TASKITO_DASHBOARD_OAUTH_OIDC_${slot.toUpperCase().replace(/-/g, "_")}`; + const clientId = (env[`${prefix}_CLIENT_ID`] ?? "").trim(); + const clientSecret = (env[`${prefix}_CLIENT_SECRET`] ?? "").trim(); + const discoveryUrl = (env[`${prefix}_DISCOVERY_URL`] ?? "").trim(); + if (!clientId || !clientSecret || !discoveryUrl) { + throw new OAuthConfigError( + `OIDC slot '${slot}' requires ${prefix}_CLIENT_ID, _CLIENT_SECRET, and _DISCOVERY_URL`, + ); + } + const defaultLabel = slot.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + return { + type: "oidc", + slot, + label: (env[`${prefix}_LABEL`] ?? "").trim() || defaultLabel, + clientId, + clientSecret, + discoveryUrl, + allowedDomains: splitCsv(env[`${prefix}_ALLOWED_DOMAINS`]), + }; +} diff --git a/sdks/node/src/dashboard/auth/oauth/flow.ts b/sdks/node/src/dashboard/auth/oauth/flow.ts new file mode 100644 index 00000000..3affd702 --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/flow.ts @@ -0,0 +1,143 @@ +// End-to-end OAuth flow orchestration: the seam between the HTTP layer and +// the providers. Owns the provider registry, the state store, and the +// AuthStore integration. + +import type { Queue } from "../../../index"; +import { isSafeRedirect } from "../../urlSafety"; +import { AuthStore, type DashboardSession } from "../store"; +import { callbackUrl, configuredProviders, type OAuthConfig } from "./config"; +import { + IdentityFetchError, + type OAuthProvider, + ProviderNotConfigured, + StateValidationError, +} from "./identity"; +import { s256Challenge } from "./pkce"; +import type { FetchLike } from "./providers"; +import { GenericOidcProvider, GitHubProvider, GoogleProvider } from "./providers"; +import { OAuthStateStore } from "./stateStore"; + +/** Instantiate one provider per configured slot, keyed by slot. */ +export function buildProviders( + config: OAuthConfig, + http: FetchLike = fetch, +): Map { + const registry = new Map(); + for (const entry of configuredProviders(config)) { + if (entry.type === "google") { + registry.set(entry.slot, new GoogleProvider(entry, http)); + } else if (entry.type === "github") { + registry.set(entry.slot, new GitHubProvider(entry, http)); + } else { + registry.set(entry.slot, new GenericOidcProvider(entry, http)); + } + } + return registry; +} + +/** Ties together config, providers, state store, and the auth store. */ +export class OAuthFlow { + private readonly providers: Map; + private readonly stateStore: OAuthStateStore; + + constructor( + private readonly queue: Queue, + private readonly config: OAuthConfig, + options: { providers?: Map; stateStore?: OAuthStateStore } = {}, + ) { + this.providers = options.providers ?? buildProviders(config); + this.stateStore = options.stateStore ?? new OAuthStateStore(queue); + } + + get passwordAuthEnabled(): boolean { + return this.config.passwordAuthEnabled; + } + + hasProvider(slot: string): boolean { + return this.providers.has(slot); + } + + /** Compact provider summary for the login UI (no secrets). */ + providersListing(): Array<{ slot: string; label: string; type: string }> { + return [...this.providers.values()].map((p) => ({ + slot: p.slot, + label: p.label, + type: p.type, + })); + } + + /** + * Mint a state row and return the provider's authorize URL. `nextUrl` is + * sanitised against {@link isSafeRedirect}, falling back to `/`. + */ + async start(slot: string, nextUrl: string | null): Promise { + const provider = this.requireProvider(slot); + const safeNext = nextUrl && isSafeRedirect(nextUrl) ? nextUrl : "/"; + const state = this.stateStore.create(slot, safeNext); + return provider.authorizationUrl({ + state: state.state, + nonce: state.nonce, + codeChallenge: s256Challenge(state.codeVerifier), + redirectUri: callbackUrl(this.config, slot), + }); + } + + /** + * Exchange `code` for an identity and create a session. Returns the + * session plus the post-login redirect target. Throws + * {@link StateValidationError} / {@link IdentityFetchError} / + * {@link AllowlistDenied} for the handler layer to map. + */ + async handleCallback( + slot: string, + params: { code: string | null; stateToken: string | null; error: string | null }, + ): Promise<{ session: DashboardSession; nextUrl: string }> { + if (params.error) { + throw new IdentityFetchError(`provider returned error: ${params.error}`); + } + if (!params.code || !params.stateToken) { + throw new StateValidationError("missing code or state parameter"); + } + + const row = this.stateStore.consume(params.stateToken); + if (!row) { + throw new StateValidationError("state is invalid, expired, or already used"); + } + if (row.slot !== slot) { + throw new StateValidationError("state slot does not match callback slot"); + } + + const provider = this.requireProvider(slot); + const identity = await provider.exchangeCode({ + code: params.code, + codeVerifier: row.codeVerifier, + redirectUri: callbackUrl(this.config, slot), + expectedNonce: row.nonce, + }); + provider.checkAllowlist(identity); + + const store = new AuthStore(this.queue); + const user = store.getOrCreateOauthUser({ + slot: identity.slot, + subject: identity.subject, + email: identity.email, + name: identity.name, + emailVerified: identity.emailVerified, + adminEmails: this.config.adminEmails, + }); + return { session: store.createSession(user), nextUrl: row.nextUrl }; + } + + /** Best-effort sweep of expired state rows. */ + pruneState(): number { + return this.stateStore.pruneExpired(); + } + + private requireProvider(slot: string): OAuthProvider { + const provider = this.providers.get(slot); + if (!provider) { + throw new ProviderNotConfigured(`OAuth provider '${slot}' is not configured`); + } + return provider; + } +} diff --git a/sdks/node/src/dashboard/auth/oauth/idToken.ts b/sdks/node/src/dashboard/auth/oauth/idToken.ts new file mode 100644 index 00000000..2424ba88 --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/idToken.ts @@ -0,0 +1,170 @@ +// OIDC id_token validation on node:crypto — signature against the +// provider's JWKS, then issuer/audience/nonce/expiry claim checks. Only the +// asymmetric algorithms OIDC providers actually issue are accepted; `none` +// and HMAC algorithms are rejected outright. + +import { + constants, + createPublicKey, + createVerify, + type KeyObject, + verify as verifyRaw, +} from "node:crypto"; +import { IdentityFetchError } from "./identity"; + +/** 60s clock-skew tolerance on `exp`, matching the reference behaviour. */ +const CLOCK_SKEW_SECONDS = 60; + +interface JwtHeader { + alg?: string; + kid?: string; +} + +export interface IdTokenClaims { + iss?: string; + aud?: string | string[]; + sub?: string; + nonce?: string; + exp?: number; + email?: string; + email_verified?: boolean; + name?: string; + picture?: string; + [claim: string]: unknown; +} + +interface Jwk { + kty?: string; + kid?: string; + [field: string]: unknown; +} + +function decodeSegment(segment: string, what: string): Record { + try { + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); + } catch { + throw new IdentityFetchError(`id_token ${what} is not valid base64url JSON`); + } +} + +/** Verify one signature with the algorithm the token header names. */ +function verifySignature(alg: string, key: KeyObject, data: Buffer, signature: Buffer): boolean { + switch (alg) { + case "RS256": + case "RS384": + case "RS512": { + const hash = `RSA-SHA${alg.slice(2)}`; + return createVerify(hash).update(data).verify(key, signature); + } + case "PS256": + case "PS384": + case "PS512": + return verifyRaw( + `sha${alg.slice(2)}`, + data, + { + key, + padding: constants.RSA_PKCS1_PSS_PADDING, + saltLength: Number(alg.slice(2)) / 8, + }, + signature, + ); + case "ES256": + case "ES384": + case "ES512": + return verifyRaw(`sha${alg.slice(2)}`, data, { key, dsaEncoding: "ieee-p1363" }, signature); + default: + throw new IdentityFetchError(`id_token uses unsupported algorithm '${alg}'`); + } +} + +/** Candidate JWKS keys for a token: exact `kid` match, else every key. */ +function candidateKeys(keys: Jwk[], kid: string | undefined): Jwk[] { + if (kid) { + const matched = keys.filter((key) => key.kid === kid); + if (matched.length > 0) { + return matched; + } + } + return keys; +} + +/** + * Validate an id_token against a JWKS and the expected issuer, audience, + * and nonce. Returns the verified claims; throws {@link IdentityFetchError} + * on any failure. + */ +export function validateIdToken(options: { + idToken: string; + jwks: { keys?: Jwk[] }; + issuer: string | undefined; + clientId: string; + expectedNonce: string | null; + nowSeconds?: number; +}): IdTokenClaims { + const parts = options.idToken.split("."); + if (parts.length !== 3) { + throw new IdentityFetchError("id_token is not a compact JWT"); + } + const [headerB64, payloadB64, signatureB64] = parts as [string, string, string]; + const header = decodeSegment(headerB64, "header") as JwtHeader; + if (!header.alg) { + throw new IdentityFetchError("id_token header missing 'alg'"); + } + + const signedData = Buffer.from(`${headerB64}.${payloadB64}`, "ascii"); + const signature = Buffer.from(signatureB64, "base64url"); + const keys = options.jwks.keys ?? []; + if (keys.length === 0) { + throw new IdentityFetchError("provider JWKS contains no keys"); + } + + let verified = false; + for (const jwk of candidateKeys(keys, header.kid)) { + let key: KeyObject; + try { + key = createPublicKey({ key: jwk as never, format: "jwk" }); + } catch { + continue; // skip malformed / symmetric keys + } + try { + if (verifySignature(header.alg, key, signedData, signature)) { + verified = true; + break; + } + } catch (error) { + if (error instanceof IdentityFetchError) { + throw error; // unsupported algorithm — fail loudly, don't try other keys + } + } + } + if (!verified) { + throw new IdentityFetchError("id_token signature validation failed"); + } + + const claims = decodeSegment(payloadB64, "payload") as IdTokenClaims; + if (options.issuer && claims.iss !== options.issuer) { + throw new IdentityFetchError( + `id_token issuer mismatch: expected '${options.issuer}', got '${String(claims.iss)}'`, + ); + } + const aud = claims.aud; + const audienceOk = + typeof aud === "string" + ? aud === options.clientId + : Array.isArray(aud) && aud.includes(options.clientId); + if (!audienceOk) { + throw new IdentityFetchError(`id_token audience mismatch: ${JSON.stringify(aud)}`); + } + if (options.expectedNonce !== null && claims.nonce !== options.expectedNonce) { + throw new IdentityFetchError("id_token nonce mismatch"); + } + const now = options.nowSeconds ?? Math.floor(Date.now() / 1000); + if (typeof claims.exp === "number" && claims.exp < now - CLOCK_SKEW_SECONDS) { + throw new IdentityFetchError("id_token expired"); + } + if (!claims.sub) { + throw new IdentityFetchError("id_token missing 'sub' claim"); + } + return claims; +} diff --git a/sdks/node/src/dashboard/auth/oauth/identity.ts b/sdks/node/src/dashboard/auth/oauth/identity.ts new file mode 100644 index 00000000..0e73b185 --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/identity.ts @@ -0,0 +1,55 @@ +// Provider-agnostic identity types + the error taxonomy the flow layer maps +// to login-page redirects. + +/** Base class for OAuth flow failures. */ +export class OAuthError extends Error { + constructor(message: string) { + super(message); + this.name = new.target.name; + } +} + +/** The `state` parameter is missing, expired, replayed, or mismatched. */ +export class StateValidationError extends OAuthError {} + +/** Token exchange, userinfo fetch, or id_token claim validation failed. */ +export class IdentityFetchError extends OAuthError {} + +/** The identity is valid but outside the configured allowlist. */ +export class AllowlistDenied extends OAuthError {} + +/** The requested provider slot has no configuration. */ +export class ProviderNotConfigured extends OAuthError {} + +/** A normalised identity returned by a provider after code exchange. */ +export interface ProviderIdentity { + slot: string; + subject: string; + email: string | null; + emailVerified: boolean; + name: string | null; + picture: string | null; +} + +/** The contract every provider implements. */ +export interface OAuthProvider { + readonly slot: string; + readonly label: string; + readonly type: string; + /** The provider's `/authorize` URL carrying state/nonce/PKCE. */ + authorizationUrl(params: { + state: string; + nonce: string; + codeChallenge: string; + redirectUri: string; + }): Promise; + /** Exchange the auth code for a verified identity. */ + exchangeCode(params: { + code: string; + codeVerifier: string; + redirectUri: string; + expectedNonce: string | null; + }): Promise; + /** Pure-data allowlist check; throws {@link AllowlistDenied}. */ + checkAllowlist(identity: ProviderIdentity): void; +} diff --git a/sdks/node/src/dashboard/auth/oauth/index.ts b/sdks/node/src/dashboard/auth/oauth/index.ts new file mode 100644 index 00000000..9bf4f469 --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/index.ts @@ -0,0 +1,27 @@ +// Barrel for the OAuth login layer. + +export { + callbackUrl, + configuredProviders, + type GitHubConfig, + type GoogleConfig, + type OAuthConfig, + OAuthConfigError, + type OidcConfig, + oauthConfigFromEnv, + type ProviderConfig, +} from "./config"; +export { buildProviders, OAuthFlow } from "./flow"; +export { + AllowlistDenied, + IdentityFetchError, + OAuthError, + type OAuthProvider, + type ProviderIdentity, + ProviderNotConfigured, + StateValidationError, +} from "./identity"; +export { validateIdToken } from "./idToken"; +export { s256Challenge } from "./pkce"; +export { GenericOidcProvider, GitHubProvider, GoogleProvider } from "./providers"; +export { type OAuthState, OAuthStateStore } from "./stateStore"; diff --git a/sdks/node/src/dashboard/auth/oauth/pkce.ts b/sdks/node/src/dashboard/auth/oauth/pkce.ts new file mode 100644 index 00000000..f860b941 --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/pkce.ts @@ -0,0 +1,8 @@ +// RFC 7636 PKCE code-challenge derivation. + +import { createHash } from "node:crypto"; + +/** S256 challenge: base64url(sha256(verifier)) without padding. */ +export function s256Challenge(verifier: string): string { + return createHash("sha256").update(verifier, "ascii").digest("base64url"); +} diff --git a/sdks/node/src/dashboard/auth/oauth/providers.ts b/sdks/node/src/dashboard/auth/oauth/providers.ts new file mode 100644 index 00000000..9bbc1016 --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/providers.ts @@ -0,0 +1,427 @@ +// Concrete providers: Google, GitHub, and generic OIDC. `exchangeCode` +// (network IO + claim normalisation) is split from `checkAllowlist` +// (pure-data permission check) so tests can drive either in isolation. +// HTTP goes through an injectable `fetch` so tests never hit the network. + +import type { GitHubConfig, GoogleConfig, OidcConfig } from "./config"; +import { + AllowlistDenied, + IdentityFetchError, + type OAuthProvider, + type ProviderIdentity, +} from "./identity"; +import { validateIdToken } from "./idToken"; + +export const GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration"; +const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"; +const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const GITHUB_API_BASE = "https://api.github.com"; + +const HTTP_TIMEOUT_MS = 10_000; + +export type FetchLike = typeof fetch; + +interface DiscoveryDocument { + issuer?: string; + authorization_endpoint?: string; + token_endpoint?: string; + jwks_uri?: string; +} + +function emailDomain(email: string | null): string | null { + if (!email?.includes("@")) { + return null; + } + return (email.split("@").pop() ?? "").toLowerCase(); +} + +function domainAllowlistCheck(identity: ProviderIdentity, allowedDomains: string[]): void { + if (allowedDomains.length === 0) { + return; + } + if (!identity.email || !identity.emailVerified) { + throw new AllowlistDenied("verified email required for domain check"); + } + const domain = emailDomain(identity.email); + if (!allowedDomains.some((d) => d.toLowerCase() === domain)) { + throw new AllowlistDenied(`email domain '${domain}' is not in the allowed domains list`); + } +} + +async function fetchJson(http: FetchLike, url: string, what: string): Promise { + let response: Response; + try { + response = await http(url, { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); + } catch (error) { + throw new IdentityFetchError(`${what} request failed: ${String(error)}`); + } + if (!response.ok) { + throw new IdentityFetchError(`${what} returned ${response.status}`); + } + try { + return await response.json(); + } catch { + throw new IdentityFetchError(`${what} returned invalid JSON`); + } +} + +/** POST an authorization code to a token endpoint (client_secret_post). */ +async function fetchToken( + http: FetchLike, + tokenEndpoint: string, + params: { + clientId: string; + clientSecret: string; + code: string; + codeVerifier: string; + redirectUri: string; + }, +): Promise> { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: params.clientId, + client_secret: params.clientSecret, + code: params.code, + code_verifier: params.codeVerifier, + redirect_uri: params.redirectUri, + }); + let response: Response; + try { + response = await http(tokenEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: body.toString(), + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + }); + } catch (error) { + throw new IdentityFetchError(`token exchange failed: ${String(error)}`); + } + if (!response.ok) { + throw new IdentityFetchError(`token exchange failed: HTTP ${response.status}`); + } + try { + return (await response.json()) as Record; + } catch { + throw new IdentityFetchError("token endpoint returned invalid JSON"); + } +} + +// ── OIDC (shared machinery for Google + generic OIDC) ─────────────────── + +abstract class OidcProviderBase implements OAuthProvider { + abstract readonly slot: string; + abstract readonly label: string; + abstract readonly type: string; + protected abstract readonly clientId: string; + protected abstract readonly clientSecret: string; + protected abstract readonly discoveryUrl: string; + protected readonly scope: string = "openid email profile"; + + private discovery?: DiscoveryDocument; + private jwks?: { keys?: Array> }; + + constructor(protected readonly http: FetchLike = fetch) {} + + protected extraAuthParams(): Record { + return {}; + } + + abstract checkAllowlist(identity: ProviderIdentity): void; + + private async getDiscovery(): Promise { + this.discovery ??= (await fetchJson( + this.http, + this.discoveryUrl, + "OIDC discovery", + )) as DiscoveryDocument; + return this.discovery; + } + + private async getJwks(): Promise<{ keys?: Array> }> { + if (!this.jwks) { + const discovery = await this.getDiscovery(); + if (!discovery.jwks_uri) { + throw new IdentityFetchError("OIDC discovery document has no jwks_uri"); + } + this.jwks = (await fetchJson(this.http, discovery.jwks_uri, "JWKS fetch")) as { + keys?: Array>; + }; + } + return this.jwks; + } + + async authorizationUrl(params: { + state: string; + nonce: string; + codeChallenge: string; + redirectUri: string; + }): Promise { + const discovery = await this.getDiscovery(); + if (!discovery.authorization_endpoint) { + throw new IdentityFetchError("OIDC discovery document has no authorization_endpoint"); + } + const query = new URLSearchParams({ + response_type: "code", + client_id: this.clientId, + redirect_uri: params.redirectUri, + scope: this.scope, + state: params.state, + nonce: params.nonce, + code_challenge: params.codeChallenge, + code_challenge_method: "S256", + ...this.extraAuthParams(), + }); + return `${discovery.authorization_endpoint}?${query.toString()}`; + } + + async exchangeCode(params: { + code: string; + codeVerifier: string; + redirectUri: string; + expectedNonce: string | null; + }): Promise { + const discovery = await this.getDiscovery(); + if (!discovery.token_endpoint) { + throw new IdentityFetchError("OIDC discovery document has no token_endpoint"); + } + const token = await fetchToken(this.http, discovery.token_endpoint, { + clientId: this.clientId, + clientSecret: this.clientSecret, + code: params.code, + codeVerifier: params.codeVerifier, + redirectUri: params.redirectUri, + }); + const idToken = token.id_token; + if (typeof idToken !== "string" || !idToken) { + throw new IdentityFetchError("no id_token in token response"); + } + const claims = validateIdToken({ + idToken, + jwks: await this.getJwks(), + issuer: discovery.issuer, + clientId: this.clientId, + expectedNonce: params.expectedNonce, + }); + return { + slot: this.slot, + subject: String(claims.sub), + email: typeof claims.email === "string" ? claims.email : null, + emailVerified: claims.email_verified === true, + name: typeof claims.name === "string" ? claims.name : null, + picture: typeof claims.picture === "string" ? claims.picture : null, + }; + } +} + +export class GoogleProvider extends OidcProviderBase { + readonly slot = "google"; + readonly type = "google"; + readonly label: string; + protected readonly clientId: string; + protected readonly clientSecret: string; + protected readonly discoveryUrl = GOOGLE_DISCOVERY_URL; + + constructor( + private readonly config: GoogleConfig, + http: FetchLike = fetch, + ) { + super(http); + this.label = config.label; + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + } + + protected override extraAuthParams(): Record { + const params: Record = { prompt: "select_account" }; + // A single allowlisted domain is passed as `hd` so Google pre-selects + // the right account. UX hint only — enforcement is in checkAllowlist. + if (this.config.allowedDomains.length === 1 && this.config.allowedDomains[0]) { + params.hd = this.config.allowedDomains[0]; + } + return params; + } + + checkAllowlist(identity: ProviderIdentity): void { + domainAllowlistCheck(identity, this.config.allowedDomains); + } +} + +export class GenericOidcProvider extends OidcProviderBase { + readonly slot: string; + readonly type = "oidc"; + readonly label: string; + protected readonly clientId: string; + protected readonly clientSecret: string; + protected readonly discoveryUrl: string; + + constructor( + private readonly config: OidcConfig, + http: FetchLike = fetch, + ) { + super(http); + this.slot = config.slot; + this.label = config.label || config.slot; + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.discoveryUrl = config.discoveryUrl; + } + + checkAllowlist(identity: ProviderIdentity): void { + domainAllowlistCheck(identity, this.config.allowedDomains); + } +} + +// ── GitHub (OAuth2-only, no OIDC) ──────────────────────────────────────── + +export class GitHubProvider implements OAuthProvider { + readonly slot = "github"; + readonly type = "github"; + readonly label: string; + private readonly scope = "read:user user:email"; + + constructor( + private readonly config: GitHubConfig, + private readonly http: FetchLike = fetch, + ) { + this.label = config.label; + } + + // GitHub does not implement OIDC — `nonce` is unused; PKCE is honoured. + authorizationUrl(params: { + state: string; + nonce: string; + codeChallenge: string; + redirectUri: string; + }): Promise { + const query = new URLSearchParams({ + client_id: this.config.clientId, + redirect_uri: params.redirectUri, + // read:org makes the membership endpoint reliable when allowlisting. + scope: this.config.allowedOrgs.length > 0 ? `${this.scope} read:org` : this.scope, + state: params.state, + code_challenge: params.codeChallenge, + code_challenge_method: "S256", + allow_signup: "false", + }); + return Promise.resolve(`${GITHUB_AUTH_URL}?${query.toString()}`); + } + + async exchangeCode(params: { + code: string; + codeVerifier: string; + redirectUri: string; + expectedNonce: string | null; + }): Promise { + const token = await fetchToken(this.http, GITHUB_TOKEN_URL, { + clientId: this.config.clientId, + clientSecret: this.config.clientSecret, + code: params.code, + codeVerifier: params.codeVerifier, + redirectUri: params.redirectUri, + }); + const accessToken = token.access_token; + if (typeof accessToken !== "string" || !accessToken) { + throw new IdentityFetchError("no access_token in token response"); + } + + const user = (await this.apiGet("/user", accessToken, "GET /user")) as { + id?: number; + login?: string; + name?: string; + avatar_url?: string; + }; + if (user.id === undefined || !user.login) { + throw new IdentityFetchError("GitHub /user response missing 'id' or 'login'"); + } + + const { email, verified } = await this.primaryEmail(accessToken); + // Org membership needs the access token, so it is enforced here rather + // than in checkAllowlist (a no-op for GitHub). + await this.verifyOrgMembership(accessToken, user.login); + + return { + slot: this.slot, + subject: String(user.id), + email, + emailVerified: verified, + name: user.name ?? user.login, + picture: user.avatar_url ?? null, + }; + } + + /** No-op — GitHub's org check happens inside exchangeCode. */ + checkAllowlist(_identity: ProviderIdentity): void {} + + private async apiGet(path: string, accessToken: string, what: string): Promise { + const response = await this.apiRequest(path, accessToken); + if (!response.ok) { + throw new IdentityFetchError(`${what} failed: ${response.status}`); + } + try { + return await response.json(); + } catch { + throw new IdentityFetchError(`${what} returned invalid JSON`); + } + } + + private async apiRequest(path: string, accessToken: string): Promise { + try { + return await this.http(`${GITHUB_API_BASE}${path}`, { + headers: { + authorization: `Bearer ${accessToken}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + }, + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + }); + } catch (error) { + throw new IdentityFetchError(`GitHub API ${path} request failed: ${String(error)}`); + } + } + + /** The verified primary email, or null — unverified emails are never trusted. */ + private async primaryEmail( + accessToken: string, + ): Promise<{ email: string | null; verified: boolean }> { + const response = await this.apiRequest("/user/emails", accessToken); + if (!response.ok) { + return { email: null, verified: false }; + } + let entries: Array<{ email?: string; primary?: boolean; verified?: boolean }>; + try { + entries = (await response.json()) as typeof entries; + } catch { + return { email: null, verified: false }; + } + for (const entry of entries) { + if (entry.primary && entry.verified && entry.email) { + return { email: entry.email, verified: true }; + } + } + return { email: null, verified: false }; + } + + private async verifyOrgMembership(accessToken: string, login: string): Promise { + if (this.config.allowedOrgs.length === 0) { + return; + } + for (const org of this.config.allowedOrgs) { + const response = await this.apiRequest( + `/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(login)}`, + accessToken, + ); + if (response.status === 204) { + return; + } + if (response.status !== 302 && response.status !== 404) { + throw new IdentityFetchError(`GitHub org membership check failed: ${response.status}`); + } + } + throw new AllowlistDenied( + `user is not a member of any allowed GitHub org (${this.config.allowedOrgs.join(", ")})`, + ); + } +} diff --git a/sdks/node/src/dashboard/auth/oauth/stateStore.ts b/sdks/node/src/dashboard/auth/oauth/stateStore.ts new file mode 100644 index 00000000..4217571a --- /dev/null +++ b/sdks/node/src/dashboard/auth/oauth/stateStore.ts @@ -0,0 +1,132 @@ +// Short-lived store for in-flight OAuth flows. State/nonce/PKCE-verifier +// triples live under `auth:oauth_state:` in the settings KV (the +// cross-SDK key namespace) and are single-use: consume() always deletes. + +import { randomBytes } from "node:crypto"; +import type { Queue } from "../../../index"; + +const STATE_PREFIX = "auth:oauth_state:"; +/** 5 min — covers consent UX + reasonable network latency. */ +const DEFAULT_STATE_TTL_SECONDS = 5 * 60; + +const STATE_TOKEN_BYTES = 32; +const NONCE_BYTES = 16; +// RFC 7636 §4.1: 32 bytes yields 43 base64url chars, above the 43-char minimum. +const CODE_VERIFIER_BYTES = 32; + +/** One in-flight OAuth flow, stored server-side until callback or expiry. */ +export interface OAuthState { + state: string; + nonce: string; + codeVerifier: string; + slot: string; + nextUrl: string; + createdAt: number; + expiresAt: number; +} + +/** Persisted snake_case row (cross-SDK contract; `state` is the key). */ +interface StateRow { + nonce: string; + code_verifier: string; + slot: string; + next_url: string; + created_at: number; + expires_at: number; +} + +const nowSeconds = (): number => Math.floor(Date.now() / 1000); + +/** Create, consume (read+delete), and prune short-lived OAuth state rows. */ +export class OAuthStateStore { + constructor(private readonly queue: Queue) {} + + /** Mint a fresh state/nonce/verifier triple and persist it. */ + create(slot: string, nextUrl: string, ttlSeconds = DEFAULT_STATE_TTL_SECONDS): OAuthState { + const now = nowSeconds(); + const state: OAuthState = { + state: randomBytes(STATE_TOKEN_BYTES).toString("base64url"), + nonce: randomBytes(NONCE_BYTES).toString("base64url"), + codeVerifier: randomBytes(CODE_VERIFIER_BYTES).toString("base64url"), + slot, + nextUrl, + createdAt: now, + expiresAt: now + ttlSeconds, + }; + const row: StateRow = { + nonce: state.nonce, + code_verifier: state.codeVerifier, + slot: state.slot, + next_url: state.nextUrl, + created_at: state.createdAt, + expires_at: state.expiresAt, + }; + this.queue.setSetting(STATE_PREFIX + state.state, JSON.stringify(row)); + return state; + } + + /** + * Look up `stateToken` and delete it. Returns `undefined` for missing, + * malformed, or expired rows. The row is deleted before parsing so a + * replayed state never re-validates. + */ + consume(stateToken: string): OAuthState | undefined { + if (!stateToken) { + return undefined; + } + const key = STATE_PREFIX + stateToken; + const raw = this.queue.getSetting(key); + if (!raw) { + return undefined; + } + this.queue.deleteSetting(key); + let row: StateRow; + try { + row = JSON.parse(raw); + } catch { + return undefined; + } + if ( + typeof row?.nonce !== "string" || + typeof row.code_verifier !== "string" || + typeof row.slot !== "string" || + typeof row.expires_at !== "number" + ) { + return undefined; + } + if (nowSeconds() >= row.expires_at) { + return undefined; + } + return { + state: stateToken, + nonce: row.nonce, + codeVerifier: row.code_verifier, + slot: row.slot, + nextUrl: typeof row.next_url === "string" ? row.next_url : "/", + createdAt: row.created_at ?? 0, + expiresAt: row.expires_at, + }; + } + + /** Best-effort sweep of expired state rows. Returns count removed. */ + pruneExpired(): number { + const now = nowSeconds(); + let removed = 0; + for (const [key, value] of Object.entries(this.queue.listSettings())) { + if (!key.startsWith(STATE_PREFIX)) { + continue; + } + let expiresAt: number; + try { + expiresAt = Number(JSON.parse(value)?.expires_at ?? 0); + } catch { + continue; + } + if (!Number.isFinite(expiresAt) || expiresAt <= now) { + this.queue.deleteSetting(key); + removed += 1; + } + } + return removed; + } +} diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts index 2abdfb86..c2ca4985 100644 --- a/sdks/node/src/dashboard/index.ts +++ b/sdks/node/src/dashboard/index.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"; import type { Queue } from "../index"; import { createLogger } from "../utils"; import type { DashboardAuth } from "./auth"; -import { bootstrapAdminFromEnv } from "./auth"; +import { bootstrapAdminFromEnv, OAuthFlow, oauthConfigFromEnv } from "./auth"; import { createDashboardServer } from "./server"; const log = createLogger("dashboard"); @@ -26,6 +26,11 @@ export interface DashboardOptions { * (default true). Disable only for plain-HTTP development. */ secureCookies?: boolean; + /** + * OAuth login flow. When omitted, one is built from the + * `TASKITO_DASHBOARD_OAUTH_*` environment variables if they are set. + */ + oauth?: OAuthFlow; } // Built relative to dist/ at runtime. The path is assembled dynamically so the @@ -50,6 +55,7 @@ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Se const server = createDashboardServer(queue, options.staticDir ?? defaultStaticDir(), { auth: options.auth, secureCookies: options.secureCookies, + oauth: options.oauth ?? (options.auth ? undefined : buildOauthFlowFromEnv(queue)), }); // A bind failure (e.g. EADDRINUSE) without an 'error' listener crashes the process. server.on("error", (error) => { @@ -65,9 +71,29 @@ export { bootstrapAdminFromEnv, type DashboardSession, type DashboardUser, + type OAuthConfig, + OAuthConfigError, + OAuthFlow, + type OAuthProvider, + oauthConfigFromEnv, + type ProviderIdentity, } from "./auth"; export { createDashboardHandler, createDashboardServer, type DashboardHandlerOptions, } from "./server"; + +/** Build the OAuth flow from env; invalid config logs and disables OAuth. */ +function buildOauthFlowFromEnv(queue: Queue): OAuthFlow | undefined { + try { + const config = oauthConfigFromEnv(); + if (!config || !(config.google || config.github || config.oidc.length > 0)) { + return undefined; + } + return new OAuthFlow(queue, config); + } catch (error) { + log.warn(() => `OAuth disabled — invalid configuration: ${String(error)}`); + return undefined; + } +} diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index 1e61a335..03cee324 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -12,15 +12,19 @@ import type { Queue } from "../index"; import { createLogger } from "../utils"; import { WebhookValidationError } from "../webhooks"; import { + AllowlistDenied, AuthStore, buildContext, CSRF_COOKIE, csrfValid, type DashboardAuth, isPublicApiPath, + type OAuthFlow, + ProviderNotConfigured, presentedToken, type RequestContext, SESSION_COOKIE, + StateValidationError, setTokenCookie, tokenMatches, } from "./auth"; @@ -40,7 +44,8 @@ export interface DashboardHandlerOptions { /** Legacy shared-token gate. When set, the session-auth flow is disabled. */ auth?: DashboardAuth; /** Mark session cookies `Secure` (default true). Disable only for plain-HTTP dev. */ - secureCookies?: boolean; + secureCookies?: boolean /** OAuth login flow (built from env by `serveDashboard` when omitted). */; + oauth?: OAuthFlow; } /** Accept the pre-options `DashboardAuth` third argument for compatibility. */ @@ -127,7 +132,102 @@ async function dispatch( sendJson(res, denied.status, { error: denied.code }); return; } - await runRoute(queue, req, res, url, path, ctx, options.secureCookies !== false); + const secure = options.secureCookies !== false; + if (req.method === "GET" && (await serveOauth(options.oauth, res, url, path, secure))) { + return; + } + await runRoute(queue, req, res, url, path, ctx, secure); +} + +/** + * OAuth endpoints (redirect-emitting, so they live outside the JSON route + * table): provider listing, `/start/` 302s to the provider, and + * `/callback/` lands the session. Returns false when `path` is not + * an OAuth path so normal dispatch continues. + */ +async function serveOauth( + flow: OAuthFlow | undefined, + res: ServerResponse, + url: URL, + path: string, + secure: boolean, +): Promise { + if (path === "/api/auth/providers") { + if (!flow) { + return false; // fall through to the password-only route handler + } + sendJson(res, 200, { + password_enabled: flow.passwordAuthEnabled, + providers: flow.providersListing(), + }); + return true; + } + + const start = matchSlot(path, "/api/auth/oauth/start/"); + if (start !== undefined) { + if (!flow) { + sendJson(res, 404, { error: "oauth_not_configured" }); + return true; + } + try { + redirect(res, await flow.start(start, url.searchParams.get("next"))); + } catch (error) { + if (error instanceof ProviderNotConfigured) { + sendJson(res, 404, { error: error.message }); + } else { + log.error(() => `oauth start for '${start}' failed`, error); + sendJson(res, 500, { error: "internal server error" }); + } + } + return true; + } + + const callback = matchSlot(path, "/api/auth/oauth/callback/"); + if (callback !== undefined) { + if (!flow) { + sendJson(res, 404, { error: "oauth_not_configured" }); + return true; + } + try { + const { session, nextUrl } = await flow.handleCallback(callback, { + code: url.searchParams.get("code"), + stateToken: url.searchParams.get("state"), + error: url.searchParams.get("error"), + }); + flow.pruneState(); + const ttl = Math.max(0, session.expiresAt - Math.floor(Date.now() / 1000)); + res.setHeader("set-cookie", sessionCookies(session.token, session.csrfToken, ttl, secure)); + redirect(res, nextUrl); + } catch (error) { + if (error instanceof ProviderNotConfigured) { + sendJson(res, 404, { error: error.message }); + } else if (error instanceof StateValidationError) { + redirect(res, "/login?error=oauth_state_invalid"); + } else if (error instanceof AllowlistDenied) { + redirect(res, "/login?error=oauth_denied"); + } else { + // Covers IdentityFetchError and anything unexpected — never leak details. + log.error(() => `oauth callback for '${callback}' failed`, error); + redirect(res, "/login?error=oauth_failed"); + } + } + return true; + } + + return false; +} + +function matchSlot(path: string, prefix: string): string | undefined { + if (!path.startsWith(prefix)) { + return undefined; + } + const slot = decodeURIComponent(path.slice(prefix.length)); + return slot && !slot.includes("/") ? slot : undefined; +} + +function redirect(res: ServerResponse, location: string): void { + res.writeHead(302, { location }); + res.end(); } /** The session/CSRF/role gate. Returns the denial, or undefined to proceed. */ diff --git a/sdks/node/src/dashboard/urlSafety.ts b/sdks/node/src/dashboard/urlSafety.ts new file mode 100644 index 00000000..c5bc1098 --- /dev/null +++ b/sdks/node/src/dashboard/urlSafety.ts @@ -0,0 +1,25 @@ +// Open-redirect guard for post-login redirect targets. + +/** + * Whether `path` is safe as a same-origin redirect target. Accepts only + * relative paths rooted at `/`; rejects absolute URLs, protocol-relative + * URLs (`//evil.com/x`), and backslash tricks. Empty input is rejected so + * callers fall back to a default explicitly. + */ +export function isSafeRedirect(path: string | null | undefined): boolean { + if (!path?.startsWith("/")) { + return false; + } + if (path.startsWith("//") || path.startsWith("/\\")) { + return false; + } + // A parseable absolute URL (with scheme or authority) is never safe. + try { + // Base-relative parse: if the path smuggles a scheme/host, the resolved + // origin differs from the sentinel base. + const resolved = new URL(path, "http://taskito.invalid"); + return resolved.origin === "http://taskito.invalid"; + } catch { + return false; + } +} diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts index d28103ab..cf2f89e2 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -6,6 +6,12 @@ export { type DashboardOptions, type DashboardSession, type DashboardUser, + type OAuthConfig, + OAuthConfigError, + OAuthFlow, + type OAuthProvider, + oauthConfigFromEnv, + type ProviderIdentity, serveDashboard, } from "./dashboard"; export { diff --git a/sdks/node/test/dashboard/oauth.test.ts b/sdks/node/test/dashboard/oauth.test.ts new file mode 100644 index 00000000..a519cd49 --- /dev/null +++ b/sdks/node/test/dashboard/oauth.test.ts @@ -0,0 +1,359 @@ +import { generateKeyPairSync, sign as signRaw } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + AllowlistDenied, + AuthStore, + callbackUrl, + IdentityFetchError, + OAuthConfigError, + OAuthFlow, + type OAuthProvider, + OAuthStateStore, + oauthConfigFromEnv, + type ProviderIdentity, + StateValidationError, + s256Challenge, + validateIdToken, +} from "../../src/dashboard/auth"; +import { isSafeRedirect } from "../../src/dashboard/urlSafety"; +import { Queue } from "../../src/index"; + +let queue: Queue; + +beforeEach(() => { + const db = join(mkdtempSync(join(tmpdir(), "taskito-oauth-")), "q.db"); + queue = new Queue({ dbPath: db }); +}); + +const BASE_ENV = { + TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL: "https://ops.example.com", +}; + +describe("oauthConfigFromEnv", () => { + it("returns undefined when nothing is configured", () => { + expect(oauthConfigFromEnv({})).toBeUndefined(); + }); + + it("fails fast on a provider without a base url", () => { + expect(() => oauthConfigFromEnv({ TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID: "cid" })).toThrow( + OAuthConfigError, + ); + }); + + it("fails fast on a client id without a secret", () => { + expect(() => + oauthConfigFromEnv({ ...BASE_ENV, TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID: "cid" }), + ).toThrow(/CLIENT_SECRET/); + }); + + it("parses google, github, and multiple oidc slots", () => { + const config = oauthConfigFromEnv({ + ...BASE_ENV, + TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID: "gid", + TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET: "gsec", + TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS: "corp.com, example.org", + TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID: "hid", + TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET: "hsec", + TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS: "byteveda", + TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS: "okta,keycloak", + TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID: "oid", + TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET: "osec", + TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL: "https://okta/.well-known/x", + TASKITO_DASHBOARD_OAUTH_OIDC_KEYCLOAK_CLIENT_ID: "kid", + TASKITO_DASHBOARD_OAUTH_OIDC_KEYCLOAK_CLIENT_SECRET: "ksec", + TASKITO_DASHBOARD_OAUTH_OIDC_KEYCLOAK_DISCOVERY_URL: "https://kc/.well-known/x", + TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS: "boss@corp.com", + }); + expect(config?.google?.allowedDomains).toEqual(["corp.com", "example.org"]); + expect(config?.github?.allowedOrgs).toEqual(["byteveda"]); + expect(config?.oidc.map((o) => o.slot)).toEqual(["okta", "keycloak"]); + expect(config?.adminEmails).toEqual(["boss@corp.com"]); + expect(callbackUrl(config ?? ({} as never), "okta")).toBe( + "https://ops.example.com/api/auth/oauth/callback/okta", + ); + }); + + it("rejects http base urls for non-local hosts but allows localhost", () => { + expect(() => + oauthConfigFromEnv({ + TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL: "http://ops.example.com", + TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID: "cid", + TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET: "sec", + }), + ).toThrow(/https/); + expect( + oauthConfigFromEnv({ + TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL: "http://localhost:8787", + TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID: "cid", + TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET: "sec", + }), + ).toBeDefined(); + }); + + it("rejects reserved and duplicate oidc slots", () => { + expect(() => + oauthConfigFromEnv({ + ...BASE_ENV, + TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS: "google", + TASKITO_DASHBOARD_OAUTH_OIDC_GOOGLE_CLIENT_ID: "x", + TASKITO_DASHBOARD_OAUTH_OIDC_GOOGLE_CLIENT_SECRET: "y", + TASKITO_DASHBOARD_OAUTH_OIDC_GOOGLE_DISCOVERY_URL: "https://z", + }), + ).toThrow(/collides/); + expect(() => + oauthConfigFromEnv({ + ...BASE_ENV, + TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS: "okta,okta", + TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID: "oid", + TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET: "osec", + TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL: "https://okta/.well-known/x", + }), + ).toThrow(/twice/); + }); + + it("rejects disabling passwords with no providers", () => { + expect(() => + oauthConfigFromEnv({ + ...BASE_ENV, + TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED: "false", + }), + ).toThrow(/no way to log in/); + }); +}); + +describe("state store", () => { + it("consume is single-use and rejects unknown or expired states", () => { + const store = new OAuthStateStore(queue); + const state = store.create("google", "/jobs"); + const consumed = store.consume(state.state); + expect(consumed?.slot).toBe("google"); + expect(consumed?.nextUrl).toBe("/jobs"); + expect(consumed?.codeVerifier.length).toBeGreaterThanOrEqual(43); + // Replay: gone. + expect(store.consume(state.state)).toBeUndefined(); + expect(store.consume("unknown")).toBeUndefined(); + + const expired = store.create("google", "/", -1); + expect(store.consume(expired.state)).toBeUndefined(); + }); + + it("prunes expired rows only", () => { + const store = new OAuthStateStore(queue); + store.create("google", "/", -1); + const live = store.create("google", "/"); + expect(store.pruneExpired()).toBe(1); + expect(store.consume(live.state)).toBeDefined(); + }); +}); + +describe("id_token validation", () => { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }) as Record; + const jwks = { keys: [{ ...jwk, kid: "k1" }] }; + + const mint = (claims: Record, header: Record = {}) => { + const h = Buffer.from(JSON.stringify({ alg: "RS256", kid: "k1", ...header })).toString( + "base64url", + ); + const p = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const sig = signRaw("sha256", Buffer.from(`${h}.${p}`), privateKey).toString("base64url"); + return `${h}.${p}.${sig}`; + }; + + const baseClaims = { + iss: "https://issuer.example", + aud: "client-1", + sub: "user-1", + nonce: "n-1", + exp: Math.floor(Date.now() / 1000) + 300, + email: "dev@corp.com", + email_verified: true, + }; + + const validate = (token: string, overrides: Record = {}) => + validateIdToken({ + idToken: token, + jwks, + issuer: "https://issuer.example", + clientId: "client-1", + expectedNonce: "n-1", + ...overrides, + }); + + it("accepts a valid token and returns claims", () => { + const claims = validate(mint(baseClaims)); + expect(claims.sub).toBe("user-1"); + expect(claims.email).toBe("dev@corp.com"); + }); + + it("rejects a tampered signature", () => { + const token = mint(baseClaims); + const tampered = `${token.slice(0, -4)}AAAA`; + expect(() => validate(tampered)).toThrow(IdentityFetchError); + }); + + it("rejects issuer, audience, and nonce mismatches", () => { + expect(() => validate(mint({ ...baseClaims, iss: "https://evil" }))).toThrow(/issuer/); + expect(() => validate(mint({ ...baseClaims, aud: "other" }))).toThrow(/audience/); + expect(() => validate(mint({ ...baseClaims, nonce: "wrong" }))).toThrow(/nonce/); + }); + + it("rejects expiry beyond the 60s skew and missing sub", () => { + const past = Math.floor(Date.now() / 1000) - 120; + expect(() => validate(mint({ ...baseClaims, exp: past }))).toThrow(/expired/); + expect(() => validate(mint({ ...baseClaims, sub: undefined }))).toThrow(/sub/); + }); + + it("rejects unsupported algorithms outright", () => { + expect(() => validate(mint(baseClaims, { alg: "HS256" }))).toThrow(/unsupported/); + }); +}); + +describe("flow", () => { + class FakeProvider implements OAuthProvider { + readonly slot = "fake"; + readonly label = "Fake"; + readonly type = "oidc"; + lastAuth?: { state: string; nonce: string; codeChallenge: string; redirectUri: string }; + identity: ProviderIdentity = { + slot: "fake", + subject: "sub-1", + email: "dev@corp.com", + emailVerified: true, + name: "Dev", + picture: null, + }; + deny = false; + + authorizationUrl(params: { + state: string; + nonce: string; + codeChallenge: string; + redirectUri: string; + }): Promise { + this.lastAuth = params; + return Promise.resolve(`https://provider.example/authorize?state=${params.state}`); + } + + exchangeCode(): Promise { + return Promise.resolve(this.identity); + } + + checkAllowlist(): void { + if (this.deny) { + throw new AllowlistDenied("nope"); + } + } + } + + const makeFlow = (provider: FakeProvider) => + new OAuthFlow( + queue, + { + redirectBaseUrl: "https://ops.example.com", + oidc: [], + passwordAuthEnabled: true, + adminEmails: [], + }, + { providers: new Map([["fake", provider]]) }, + ); + + it("start mints state with a PKCE challenge and sanitises next", async () => { + const provider = new FakeProvider(); + const flow = makeFlow(provider); + const url = await flow.start("fake", "https://evil.example/phish"); + expect(url).toContain("https://provider.example/authorize"); + expect(provider.lastAuth?.redirectUri).toBe( + "https://ops.example.com/api/auth/oauth/callback/fake", + ); + + const consumed = new OAuthStateStore(queue).consume(provider.lastAuth?.state ?? ""); + expect(consumed?.nextUrl).toBe("/"); // unsafe next fell back + expect(provider.lastAuth?.codeChallenge).toBe(s256Challenge(consumed?.codeVerifier ?? "")); + }); + + it("callback creates the user + session and enforces slot match", async () => { + const provider = new FakeProvider(); + const flow = makeFlow(provider); + await flow.start("fake", "/jobs"); + const state = provider.lastAuth?.state ?? ""; + + const { session, nextUrl } = await flow.handleCallback("fake", { + code: "code-1", + stateToken: state, + error: null, + }); + expect(nextUrl).toBe("/jobs"); + expect(session.username).toBe("fake:sub-1"); + // First OAuth user with a verified email becomes admin. + expect(session.role).toBe("admin"); + expect(new AuthStore(queue).getUser("fake:sub-1")?.email).toBe("dev@corp.com"); + + // Replayed state fails. + await expect( + flow.handleCallback("fake", { code: "code-1", stateToken: state, error: null }), + ).rejects.toThrow(StateValidationError); + }); + + it("rejects provider errors, allowlist denials, and slot mismatches", async () => { + const provider = new FakeProvider(); + const flow = makeFlow(provider); + + await expect( + flow.handleCallback("fake", { code: null, stateToken: null, error: "access_denied" }), + ).rejects.toThrow(IdentityFetchError); + + await flow.start("fake", "/"); + const state = provider.lastAuth?.state ?? ""; + await expect( + flow.handleCallback("other", { code: "c", stateToken: state, error: null }), + ).rejects.toThrow(StateValidationError); + + provider.deny = true; + await flow.start("fake", "/"); + await expect( + flow.handleCallback("fake", { + code: "c", + stateToken: provider.lastAuth?.state ?? "", + error: null, + }), + ).rejects.toThrow(AllowlistDenied); + }); + + it("applies the admin-emails allowlist over first-user-wins", async () => { + const provider = new FakeProvider(); + provider.identity = { ...provider.identity, email: "viewer@corp.com" }; + const flow = new OAuthFlow( + queue, + { + redirectBaseUrl: "https://ops.example.com", + oidc: [], + passwordAuthEnabled: true, + adminEmails: ["boss@corp.com"], + }, + { providers: new Map([["fake", provider]]) }, + ); + await flow.start("fake", "/"); + const { session } = await flow.handleCallback("fake", { + code: "c", + stateToken: provider.lastAuth?.state ?? "", + error: null, + }); + expect(session.role).toBe("viewer"); // not on the admin list + }); +}); + +describe("isSafeRedirect", () => { + it("accepts rooted relative paths and rejects everything else", () => { + expect(isSafeRedirect("/jobs?tab=1")).toBe(true); + expect(isSafeRedirect("/")).toBe(true); + expect(isSafeRedirect("")).toBe(false); + expect(isSafeRedirect("jobs")).toBe(false); + expect(isSafeRedirect("//evil.com/x")).toBe(false); + expect(isSafeRedirect("/\\evil.com")).toBe(false); + expect(isSafeRedirect("https://evil.com/x")).toBe(false); + }); +}); diff --git a/sdks/node/test/dashboard/oauthEndpoints.test.ts b/sdks/node/test/dashboard/oauthEndpoints.test.ts new file mode 100644 index 00000000..96d297f6 --- /dev/null +++ b/sdks/node/test/dashboard/oauthEndpoints.test.ts @@ -0,0 +1,166 @@ +import { execSync } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { + AllowlistDenied, + AuthStore, + OAuthFlow, + type OAuthProvider, + type ProviderIdentity, +} from "../../src/dashboard/auth"; +import { Queue, serveDashboard } from "../../src/index"; + +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const staticDir = join(pkgRoot, "static", "dashboard"); + +beforeAll(() => { + if (!existsSync(join(staticDir, "index.html"))) { + execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 120_000); + +class FakeProvider implements OAuthProvider { + readonly slot = "fake"; + readonly label = "Fake"; + readonly type = "oidc"; + lastState = ""; + deny = false; + identity: ProviderIdentity = { + slot: "fake", + subject: "sub-9", + email: "dev@corp.com", + emailVerified: true, + name: "Dev", + picture: null, + }; + + authorizationUrl(params: { state: string }): Promise { + this.lastState = params.state; + return Promise.resolve(`https://provider.example/authorize?state=${params.state}`); + } + + exchangeCode(): Promise { + return Promise.resolve(this.identity); + } + + checkAllowlist(): void { + if (this.deny) { + throw new AllowlistDenied("denied"); + } + } +} + +let server: Server | undefined; +let queue: Queue; +let provider: FakeProvider; +let base = ""; + +beforeEach(async () => { + const db = join(mkdtempSync(join(tmpdir(), "taskito-oauthep-")), "q.db"); + queue = new Queue({ dbPath: db }); + provider = new FakeProvider(); + const flow = new OAuthFlow( + queue, + { + redirectBaseUrl: "https://ops.example.com", + oidc: [], + passwordAuthEnabled: false, + adminEmails: [], + }, + { providers: new Map([["fake", provider]]) }, + ); + server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false, oauth: flow }); + await once(server, "listening"); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(() => { + server?.close(); + server = undefined; +}); + +const noRedirect: RequestInit = { redirect: "manual" }; + +it("lists providers with the password flag from the flow", async () => { + const res = await fetch(`${base}/api/auth/providers`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + password_enabled: false, + providers: [{ slot: "fake", label: "Fake", type: "oidc" }], + }); +}); + +it("oauth start 302s to the provider and 404s unknown slots", async () => { + const res = await fetch(`${base}/api/auth/oauth/start/fake?next=/jobs`, noRedirect); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + `https://provider.example/authorize?state=${provider.lastState}`, + ); + + expect((await fetch(`${base}/api/auth/oauth/start/ghost`, noRedirect)).status).toBe(404); +}); + +it("oauth paths bypass the setup-required gate", async () => { + // No users exist, yet start/callback/providers respond instead of 503. + expect((await fetch(`${base}/api/auth/providers`)).status).toBe(200); + expect((await fetch(`${base}/api/auth/oauth/start/fake`, noRedirect)).status).toBe(302); + expect((await fetch(`${base}/api/stats`)).status).toBe(503); +}); + +it("callback creates a session, sets cookies, and redirects to next", async () => { + await fetch(`${base}/api/auth/oauth/start/fake?next=/jobs`, noRedirect); + const res = await fetch( + `${base}/api/auth/oauth/callback/fake?code=c1&state=${provider.lastState}`, + noRedirect, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/jobs"); + const cookies = res.headers.getSetCookie(); + expect(cookies.some((c) => c.startsWith("taskito_session=") && c.includes("HttpOnly"))).toBe( + true, + ); + expect(cookies.some((c) => c.startsWith("taskito_csrf="))).toBe(true); + + const user = new AuthStore(queue).getUser("fake:sub-9"); + expect(user?.role).toBe("admin"); // first user with a verified email + expect(user?.email).toBe("dev@corp.com"); + + // The session cookie authenticates API calls. + const session = cookies.find((c) => c.startsWith("taskito_session=")) ?? ""; + const token = session.split(";")[0]?.split("=")[1] ?? ""; + const stats = await fetch(`${base}/api/stats`, { + headers: { cookie: `taskito_session=${token}` }, + }); + expect(stats.status).toBe(200); +}); + +it("redirects to login with an error code on replayed state", async () => { + await fetch(`${base}/api/auth/oauth/start/fake`, noRedirect); + const url = `${base}/api/auth/oauth/callback/fake?code=c1&state=${provider.lastState}`; + expect((await fetch(url, noRedirect)).status).toBe(302); + + const replayed = await fetch(url, noRedirect); + expect(replayed.status).toBe(302); + expect(replayed.headers.get("location")).toBe("/login?error=oauth_state_invalid"); +}); + +it("redirects with oauth_denied when the allowlist rejects", async () => { + provider.deny = true; + await fetch(`${base}/api/auth/oauth/start/fake`, noRedirect); + const res = await fetch( + `${base}/api/auth/oauth/callback/fake?code=c1&state=${provider.lastState}`, + noRedirect, + ); + expect(res.headers.get("location")).toBe("/login?error=oauth_denied"); +}); + +it("redirects with oauth_failed when the provider reports an error", async () => { + const res = await fetch(`${base}/api/auth/oauth/callback/fake?error=access_denied`, noRedirect); + expect(res.headers.get("location")).toBe("/login?error=oauth_failed"); +}); From a6d624d4a2722c862f41f14e4486889310e31034 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:49:32 +0530 Subject: [PATCH 08/21] feat(node): proxy and interception stats endpoints Per-handler proxy reconstruction metrics and enqueue-interception counters, surfaced as queue accessors plus /api/proxy-stats and /api/interception-stats. --- sdks/node/src/dashboard/routes.ts | 6 ++ sdks/node/src/interception.ts | 43 +++++++++ sdks/node/src/proxies/index.ts | 1 + sdks/node/src/proxies/metrics.ts | 99 ++++++++++++++++++++ sdks/node/src/proxies/proxies.ts | 18 +++- sdks/node/src/proxies/proxy-session.ts | 18 +++- sdks/node/src/queue.ts | 32 ++++++- sdks/node/test/dashboard/opsSettings.test.ts | 18 ++++ 8 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 sdks/node/src/proxies/metrics.ts diff --git a/sdks/node/src/dashboard/routes.ts b/sdks/node/src/dashboard/routes.ts index 0cf68fe3..5a668f07 100644 --- a/sdks/node/src/dashboard/routes.ts +++ b/sdks/node/src/dashboard/routes.ts @@ -105,6 +105,12 @@ export const routes: Route[] = [ handle: (q, _url, p) => h.deleteSetting(q, id(p)), }, { method: "GET", pattern: /^\/api\/scaler$/, handle: (q, url) => h.scaler(q, url) }, + { method: "GET", pattern: /^\/api\/proxy-stats$/, handle: (q) => q.proxyStats() }, + { + method: "GET", + pattern: /^\/api\/interception-stats$/, + handle: (q) => q.interceptionStats(), + }, { method: "GET", pattern: /^\/api\/resources$/, handle: (q) => h.resourceStatus(q) }, { method: "GET", pattern: /^\/api\/tasks$/, handle: (q) => h.listTasks(q) }, { method: "GET", pattern: /^\/api\/queues$/, handle: (q) => h.listQueues(q) }, diff --git a/sdks/node/src/interception.ts b/sdks/node/src/interception.ts index a668727b..bb36707e 100644 --- a/sdks/node/src/interception.ts +++ b/sdks/node/src/interception.ts @@ -41,3 +41,46 @@ export const Interception = { return { type: "reject", reason }; }, }; + +/** Dashboard wire shape for enqueue-interception stats. */ +export interface InterceptionStatsSnapshot { + total_intercepts: number; + total_duration_ms: number; + avg_duration_ms: number; + strategy_counts: Record; + max_depth_reached: number; +} + +/** + * In-process accumulator over interceptor runs. `max_depth_reached` counts + * the deepest interceptor chain applied to one enqueue (this SDK intercepts + * whole enqueues, not nested argument trees). + */ +export class InterceptionMetrics { + private totalIntercepts = 0; + private totalDurationMs = 0; + private maxDepth = 0; + private readonly strategyCounts = new Map(); + + record(outcomes: readonly Interception[], durationMs: number): void { + this.totalIntercepts += 1; + this.totalDurationMs += durationMs; + this.maxDepth = Math.max(this.maxDepth, outcomes.length); + for (const outcome of outcomes) { + this.strategyCounts.set(outcome.type, (this.strategyCounts.get(outcome.type) ?? 0) + 1); + } + } + + toDict(): InterceptionStatsSnapshot { + const round2 = (value: number) => Math.round(value * 100) / 100; + return { + total_intercepts: this.totalIntercepts, + total_duration_ms: round2(this.totalDurationMs), + avg_duration_ms: round2( + this.totalIntercepts > 0 ? this.totalDurationMs / this.totalIntercepts : 0, + ), + strategy_counts: Object.fromEntries(this.strategyCounts), + max_depth_reached: this.maxDepth, + }; + } +} diff --git a/sdks/node/src/proxies/index.ts b/sdks/node/src/proxies/index.ts index a662f3d8..dd45b3cf 100644 --- a/sdks/node/src/proxies/index.ts +++ b/sdks/node/src/proxies/index.ts @@ -1,5 +1,6 @@ export { canonicalJson } from "./canonical"; export { FileProxyHandler, FileReference } from "./file-handler"; +export { type ProxyHandlerStats, ProxyMetrics, proxyMetrics } from "./metrics"; export { Proxies } from "./proxies"; export { ProxySession } from "./proxy-session"; export type { ProxyHandler, ProxyRef } from "./types"; diff --git a/sdks/node/src/proxies/metrics.ts b/sdks/node/src/proxies/metrics.ts new file mode 100644 index 00000000..7cab81f7 --- /dev/null +++ b/sdks/node/src/proxies/metrics.ts @@ -0,0 +1,99 @@ +// Process-wide accumulator for proxy reconstruction metrics, surfaced by the +// dashboard's /api/proxy-stats endpoint (snake_case contract shape). + +interface HandlerMetrics { + reconstructions: number; + errors: number; + cleanupErrors: number; + checksumFailures: number; + totalDurationMs: number; + maxDurationMs: number; + durations: number[]; +} + +/** One handler's stats in the dashboard wire shape. */ +export interface ProxyHandlerStats { + handler: string; + total_reconstructions: number; + total_errors: number; + total_cleanup_errors: number; + total_checksum_failures: number; + total_duration_ms: number; + avg_duration_ms: number; + max_duration_ms: number; + p95_duration_ms: number; +} + +/** Cap the retained duration samples so long-lived processes stay bounded. */ +const MAX_SAMPLES = 1000; + +export class ProxyMetrics { + private readonly handlers = new Map(); + + private handler(name: string): HandlerMetrics { + let entry = this.handlers.get(name); + if (!entry) { + entry = { + reconstructions: 0, + errors: 0, + cleanupErrors: 0, + checksumFailures: 0, + totalDurationMs: 0, + maxDurationMs: 0, + durations: [], + }; + this.handlers.set(name, entry); + } + return entry; + } + + recordReconstruction(handlerName: string, durationMs: number): void { + const entry = this.handler(handlerName); + entry.reconstructions += 1; + entry.totalDurationMs += durationMs; + entry.maxDurationMs = Math.max(entry.maxDurationMs, durationMs); + entry.durations.push(durationMs); + if (entry.durations.length > MAX_SAMPLES) { + entry.durations.shift(); + } + } + + recordError(handlerName: string): void { + this.handler(handlerName).errors += 1; + } + + recordCleanupError(handlerName: string): void { + this.handler(handlerName).cleanupErrors += 1; + } + + recordChecksumFailure(handlerName: string): void { + this.handler(handlerName).checksumFailures += 1; + } + + toList(): ProxyHandlerStats[] { + return [...this.handlers.entries()].map(([name, h]) => { + const sorted = [...h.durations].sort((a, b) => a - b); + const p95 = sorted.length > 0 ? sorted[Math.floor(sorted.length * 0.95)] : 0; + return { + handler: name, + total_reconstructions: h.reconstructions, + total_errors: h.errors, + total_cleanup_errors: h.cleanupErrors, + total_checksum_failures: h.checksumFailures, + total_duration_ms: round2(h.totalDurationMs), + avg_duration_ms: round2(h.reconstructions > 0 ? h.totalDurationMs / h.reconstructions : 0), + max_duration_ms: round2(h.maxDurationMs), + p95_duration_ms: round2(p95 ?? sorted[sorted.length - 1] ?? 0), + }; + }); + } + + reset(): void { + this.handlers.clear(); + } +} + +const round2 = (value: number): number => Math.round(value * 100) / 100; + +/** Shared per-process accumulator every `Proxies` registry records into. */ +export const proxyMetrics = new ProxyMetrics(); diff --git a/sdks/node/src/proxies/proxies.ts b/sdks/node/src/proxies/proxies.ts index 3290a5a3..3da425de 100644 --- a/sdks/node/src/proxies/proxies.ts +++ b/sdks/node/src/proxies/proxies.ts @@ -1,6 +1,7 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { ProxyError } from "../errors"; import { canonicalJson } from "./canonical"; +import { proxyMetrics } from "./metrics"; import { ProxySession } from "./proxy-session"; import type { ProxyHandler, ProxyRef } from "./types"; @@ -64,8 +65,21 @@ export class Proxies { */ reconstruct(ref: ProxyRef, expectedPurpose?: string): unknown { const handler = this.handlerFor(ref.handler); - this.verifyRef(ref, expectedPurpose); - return handler.reconstruct(ref.reference); + try { + this.verifyRef(ref, expectedPurpose); + } catch (error) { + proxyMetrics.recordChecksumFailure(ref.handler); + throw error; + } + const startedAt = performance.now(); + try { + const value = handler.reconstruct(ref.reference); + proxyMetrics.recordReconstruction(ref.handler, performance.now() - startedAt); + return value; + } catch (error) { + proxyMetrics.recordError(ref.handler); + throw error; + } } /** {@link Proxies.reconstruct} cast to the caller's type. */ diff --git a/sdks/node/src/proxies/proxy-session.ts b/sdks/node/src/proxies/proxy-session.ts index 42cac4cd..882ad543 100644 --- a/sdks/node/src/proxies/proxy-session.ts +++ b/sdks/node/src/proxies/proxy-session.ts @@ -1,5 +1,6 @@ import { ProxyError } from "../errors"; import { createLogger } from "../utils/logger"; +import { proxyMetrics } from "./metrics"; import type { Proxies } from "./proxies"; import type { ProxyRef } from "./types"; @@ -28,7 +29,7 @@ export class ProxySession { private readonly proxies: Proxies; private readonly deconstructed = new Map>(); private readonly reconstructed = new Map(); - private readonly cleanups: Array<() => void> = []; + private readonly cleanups: Array<{ handlerId: string; run: () => void }> = []; private closed = false; /** Construct via {@link Proxies.session}, not directly. */ @@ -72,9 +73,17 @@ export class ProxySession { if (this.reconstructed.has(ref.signature)) { return this.reconstructed.get(ref.signature); } - const value = handler.reconstruct(ref.reference); + const startedAt = performance.now(); + let value: unknown; + try { + value = handler.reconstruct(ref.reference); + } catch (error) { + proxyMetrics.recordError(ref.handler); + throw error; + } + proxyMetrics.recordReconstruction(ref.handler, performance.now() - startedAt); this.reconstructed.set(ref.signature, value); - this.cleanups.push(() => handler.cleanup?.(value)); + this.cleanups.push({ handlerId: ref.handler, run: () => handler.cleanup?.(value) }); return value; } @@ -96,9 +105,10 @@ export class ProxySession { this.closed = true; for (let cleanup = this.cleanups.pop(); cleanup !== undefined; cleanup = this.cleanups.pop()) { try { - cleanup(); + cleanup.run(); } catch (error) { // Cleanup must never fail the rest of the teardown — log and continue. + proxyMetrics.recordCleanupError(cleanup.handlerId); log.warn("proxy cleanup failed", error); } } diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index d41def5e..334d77a8 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -21,7 +21,7 @@ import { TaskitoError, } from "./errors"; import { Emitter, type EventHandler, type EventName } from "./events"; -import type { Interceptor } from "./interception"; +import { type Interception, InterceptionMetrics, type Interceptor } from "./interception"; import { Lock, type LockOptions } from "./locks"; import type { EnqueueContext, Middleware } from "./middleware"; import { @@ -32,6 +32,7 @@ import { } from "./native"; import { encodeNotes } from "./notes"; import type { Predicate } from "./predicates"; +import { type ProxyHandlerStats, proxyMetrics } from "./proxies"; import { type PoolOptions, type ResourceContext, @@ -115,6 +116,7 @@ export class Queue { private readonly queueLimits = new Map(); private readonly middleware: Middleware[] = []; private readonly interceptors: Interceptor[] = []; + private readonly interceptionMetrics = new InterceptionMetrics(); private readonly gates = new Map(); private readonly emitter = new Emitter(); private readonly resources = new ResourceRuntime(); @@ -438,6 +440,23 @@ export class Queue { name: string, args: unknown[], mode?: { batch: boolean }, + ): { taskName: string; args: unknown[] } { + const outcomes: Interception[] = []; + const startedAt = this.interceptors.length > 0 ? performance.now() : 0; + try { + return this.applyInterceptors(name, args, mode, outcomes); + } finally { + if (this.interceptors.length > 0) { + this.interceptionMetrics.record(outcomes, performance.now() - startedAt); + } + } + } + + private applyInterceptors( + name: string, + args: unknown[], + mode: { batch: boolean } | undefined, + outcomes: Interception[], ): { taskName: string; args: unknown[] } { let taskName = name; let currentArgs = args; @@ -446,6 +465,7 @@ export class Queue { if (!outcome) { throw new InterceptionError(`interceptor returned null for task "${taskName}"`); } + outcomes.push(outcome); switch (outcome.type) { case "pass": break; @@ -863,6 +883,16 @@ export class Queue { return new MiddlewareDisableStore(this.native).clearFor(taskName); } + /** Per-handler proxy reconstruction metrics for this process. */ + proxyStats(): ProxyHandlerStats[] { + return proxyMetrics.toList(); + } + + /** Enqueue-interception metrics for this process. */ + interceptionStats() { + return this.interceptionMetrics.toDict(); + } + /** Registered workers (heartbeat + identity). */ listWorkers(): Promise { return this.native.listWorkers(); diff --git a/sdks/node/test/dashboard/opsSettings.test.ts b/sdks/node/test/dashboard/opsSettings.test.ts index 836d186a..3ef60fec 100644 --- a/sdks/node/test/dashboard/opsSettings.test.ts +++ b/sdks/node/test/dashboard/opsSettings.test.ts @@ -160,3 +160,21 @@ describe("ops endpoints", () => { expect(((await res.json()) as { purged: number }).purged).toBe(0); }); }); + +describe("proxy and interception stats", () => { + it("serves interception stats after interceptors run", async () => { + queue.intercept((_task, _args) => ({ type: "pass" })); + queue.enqueue("add", [1, 2]); + const stats = (await (await fetch(`${base}/api/interception-stats`, { headers })).json()) as { + total_intercepts: number; + strategy_counts: Record; + }; + expect(stats.total_intercepts).toBe(1); + expect(stats.strategy_counts.pass).toBe(1); + }); + + it("serves proxy stats as a list", async () => { + const stats = await (await fetch(`${base}/api/proxy-stats`, { headers })).json(); + expect(Array.isArray(stats)).toBe(true); + }); +}); From 4a7c6517dc9f5dbadcdbecaa556f66c7c3dde58f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:13:07 +0530 Subject: [PATCH 09/21] fix(node): avoid regex backtracking in oauth callback url --- sdks/node/src/dashboard/auth/oauth/config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sdks/node/src/dashboard/auth/oauth/config.ts b/sdks/node/src/dashboard/auth/oauth/config.ts index c078ce4b..f272530e 100644 --- a/sdks/node/src/dashboard/auth/oauth/config.ts +++ b/sdks/node/src/dashboard/auth/oauth/config.ts @@ -71,7 +71,13 @@ export function configuredProviders(config: OAuthConfig): ProviderConfig[] { } export function callbackUrl(config: OAuthConfig, slot: string): string { - return `${config.redirectBaseUrl.replace(/\/+$/, "")}/api/auth/oauth/callback/${slot}`; + let base = config.redirectBaseUrl; + // Strip trailing slashes without a regex — the base URL is operator + // input, and `/\/+$/` backtracks polynomially on long slash runs. + while (base.endsWith("/")) { + base = base.slice(0, -1); + } + return `${base}/api/auth/oauth/callback/${slot}`; } function validateRedirectBaseUrl(url: string): void { From 2c7180bacf76d1ae4afa7913443ef7c816b3c370 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:09 +0530 Subject: [PATCH 10/21] fix(node): run replay_job off the event loop --- crates/taskito-node/src/queue/admin.rs | 70 +++++++++++++----------- sdks/node/src/dashboard/handlers/core.ts | 4 +- sdks/node/src/queue.ts | 2 +- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/crates/taskito-node/src/queue/admin.rs b/crates/taskito-node/src/queue/admin.rs index 187fc8c7..af8b22eb 100644 --- a/crates/taskito-node/src/queue/admin.rs +++ b/crates/taskito-node/src/queue/admin.rs @@ -73,39 +73,43 @@ impl JsQueue { /// Re-enqueue a copy of an existing job and record it in the replay /// history. Returns the new job id. #[napi] - pub fn replay_job(&self, job_id: String) -> Result { - let original = self - .storage - .get_job(&job_id) - .map_err(to_napi_err)? - .ok_or_else(|| invalid_arg(format!("Job not found: {job_id}")))?; - let new_job = NewJob { - queue: original.queue.clone(), - task_name: original.task_name.clone(), - payload: original.payload.clone(), - priority: original.priority, - scheduled_at: now_millis(), - max_retries: original.max_retries, - timeout_ms: original.timeout_ms, - unique_key: None, - metadata: Some(format!("{{\"replayed_from\":\"{job_id}\"}}")), - notes: original.notes.clone(), - depends_on: Vec::new(), - expires_at: None, - result_ttl_ms: original.result_ttl_ms, - namespace: original.namespace.clone(), - }; - let job = self.storage.enqueue(new_job).map_err(to_napi_err)?; - // Best-effort audit row — a history write must not fail the replay. - let _ = self.storage.record_replay( - &job_id, - &job.id, - original.result.as_deref(), - None, - original.error.as_deref(), - None, - ); - Ok(job.id) + pub async fn replay_job(&self, job_id: String) -> Result { + let storage = self.storage.clone(); + spawn_blocking(move || { + let original = storage + .get_job(&job_id) + .map_err(to_napi_err)? + .ok_or_else(|| invalid_arg(format!("Job not found: {job_id}")))?; + let new_job = NewJob { + queue: original.queue.clone(), + task_name: original.task_name.clone(), + payload: original.payload.clone(), + priority: original.priority, + scheduled_at: now_millis(), + max_retries: original.max_retries, + timeout_ms: original.timeout_ms, + unique_key: None, + metadata: Some(format!("{{\"replayed_from\":\"{job_id}\"}}")), + notes: original.notes.clone(), + depends_on: Vec::new(), + expires_at: None, + result_ttl_ms: original.result_ttl_ms, + namespace: original.namespace.clone(), + }; + let job = storage.enqueue(new_job).map_err(to_napi_err)?; + // Best-effort audit row — a history write must not fail the replay. + let _ = storage.record_replay( + &job_id, + &job.id, + original.result.as_deref(), + None, + original.error.as_deref(), + None, + ); + Ok(job.id) + }) + .await + .map_err(join_to_napi_err)? } /// Re-enqueue a dead-letter entry. Returns the new job id. diff --git a/sdks/node/src/dashboard/handlers/core.ts b/sdks/node/src/dashboard/handlers/core.ts index d6202d48..5e0ab392 100644 --- a/sdks/node/src/dashboard/handlers/core.ts +++ b/sdks/node/src/dashboard/handlers/core.ts @@ -308,8 +308,8 @@ export async function circuitBreakers(queue: Queue) { } /** Re-enqueue a copy of a job. */ -export function replayJob(queue: Queue, id: string) { - return { replay_job_id: queue.replay(id) }; +export async function replayJob(queue: Queue, id: string) { + return { replay_job_id: await queue.replay(id) }; } /** Replays recorded for a job, newest first. */ diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index 334d77a8..e1ea9e65 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -615,7 +615,7 @@ export class Queue { } /** Re-enqueue a copy of a job and record it in the replay history. Returns the new job id. */ - replay(id: string): string { + replay(id: string): Promise { return this.native.replayJob(id); } From b88212c136d75f5cfe5909c0ba93e130954138f1 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:16 +0530 Subject: [PATCH 11/21] fix(node): dedupe job DAG edges --- crates/taskito-node/src/queue/inspect.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/taskito-node/src/queue/inspect.rs b/crates/taskito-node/src/queue/inspect.rs index 0f073adf..e1c96429 100644 --- a/crates/taskito-node/src/queue/inspect.rs +++ b/crates/taskito-node/src/queue/inspect.rs @@ -169,6 +169,9 @@ impl JsQueue { let storage = self.storage.clone(); spawn_blocking(move || { let mut visited: HashSet = HashSet::new(); + // Both endpoints of an edge report it (dependencies from one + // side, dependents from the other) — dedupe by (from, to). + let mut seen_edges: HashSet<(String, String)> = HashSet::new(); let mut nodes = Vec::new(); let mut edges = Vec::new(); let mut pending = vec![job_id]; @@ -181,17 +184,21 @@ impl JsQueue { }; nodes.push(job_to_js(job)); for dep_id in storage.get_dependencies(¤t).map_err(to_napi_err)? { - edges.push(JsDagEdge { - from: dep_id.clone(), - to: current.clone(), - }); + if seen_edges.insert((dep_id.clone(), current.clone())) { + edges.push(JsDagEdge { + from: dep_id.clone(), + to: current.clone(), + }); + } pending.push(dep_id); } for dep_id in storage.get_dependents(¤t).map_err(to_napi_err)? { - edges.push(JsDagEdge { - from: current.clone(), - to: dep_id.clone(), - }); + if seen_edges.insert((current.clone(), dep_id.clone())) { + edges.push(JsDagEdge { + from: current.clone(), + to: dep_id.clone(), + }); + } pending.push(dep_id); } } From 637944746c3d54cae7d36c95e2da4575cabf5a6e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:17 +0530 Subject: [PATCH 12/21] fix(node): reject OIDC slots sharing an env prefix --- sdks/node/src/dashboard/auth/oauth/config.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/sdks/node/src/dashboard/auth/oauth/config.ts b/sdks/node/src/dashboard/auth/oauth/config.ts index f272530e..bd0fa663 100644 --- a/sdks/node/src/dashboard/auth/oauth/config.ts +++ b/sdks/node/src/dashboard/auth/oauth/config.ts @@ -202,6 +202,10 @@ function parseGithub(env: Record): GitHubConfig { function parseOidcSlots(env: Record, slotsRaw: string): OidcConfig[] { const slots = splitCsv(slotsRaw); const seen = new Set(); + // `foo-bar` and `foo_bar` are distinct slots but read the same + // `..._FOO_BAR_*` env vars — reject the collision instead of silently + // sharing one credential set. + const seenPrefixes = new Map(); const out: OidcConfig[] = []; for (const rawSlot of slots) { const slot = rawSlot.toLowerCase(); @@ -211,11 +215,23 @@ function parseOidcSlots(env: Record, slotsRaw: strin ); } seen.add(slot); + const prefix = envPrefix(slot); + const collidesWith = seenPrefixes.get(prefix); + if (collidesWith !== undefined) { + throw new OAuthConfigError( + `OIDC slots '${collidesWith}' and '${slot}' share the env prefix ${prefix}_*`, + ); + } + seenPrefixes.set(prefix, slot); out.push(parseOidcSlot(env, slot)); } return out; } +function envPrefix(slot: string): string { + return `TASKITO_DASHBOARD_OAUTH_OIDC_${slot.toUpperCase().replace(/-/g, "_")}`; +} + function parseOidcSlot(env: Record, slot: string): OidcConfig { if (!SLOT_RE.test(slot)) { throw new OAuthConfigError(`OIDC slot '${slot}' must match ${SLOT_RE.source}`); @@ -223,7 +239,7 @@ function parseOidcSlot(env: Record, slot: string): O if (RESERVED_SLOTS.has(slot)) { throw new OAuthConfigError(`OIDC slot '${slot}' collides with built-in provider`); } - const prefix = `TASKITO_DASHBOARD_OAUTH_OIDC_${slot.toUpperCase().replace(/-/g, "_")}`; + const prefix = envPrefix(slot); const clientId = (env[`${prefix}_CLIENT_ID`] ?? "").trim(); const clientSecret = (env[`${prefix}_CLIENT_SECRET`] ?? "").trim(); const discoveryUrl = (env[`${prefix}_DISCOVERY_URL`] ?? "").trim(); From 262020cd58f455335674123afe842b1e185a1b69 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:21 +0530 Subject: [PATCH 13/21] fix(node): require exp and azp checks on id_tokens --- sdks/node/src/dashboard/auth/oauth/idToken.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sdks/node/src/dashboard/auth/oauth/idToken.ts b/sdks/node/src/dashboard/auth/oauth/idToken.ts index 2424ba88..b26e7cf7 100644 --- a/sdks/node/src/dashboard/auth/oauth/idToken.ts +++ b/sdks/node/src/dashboard/auth/oauth/idToken.ts @@ -156,11 +156,19 @@ export function validateIdToken(options: { if (!audienceOk) { throw new IdentityFetchError(`id_token audience mismatch: ${JSON.stringify(aud)}`); } + // Multi-audience tokens must name this client as the authorized party, + // or a token minted for another party in `aud` would be accepted. + if (Array.isArray(aud) && aud.length > 1 && claims.azp !== options.clientId) { + throw new IdentityFetchError("id_token azp mismatch for multi-audience token"); + } if (options.expectedNonce !== null && claims.nonce !== options.expectedNonce) { throw new IdentityFetchError("id_token nonce mismatch"); } const now = options.nowSeconds ?? Math.floor(Date.now() / 1000); - if (typeof claims.exp === "number" && claims.exp < now - CLOCK_SKEW_SECONDS) { + if (typeof claims.exp !== "number") { + throw new IdentityFetchError("id_token missing valid 'exp' claim"); + } + if (claims.exp < now - CLOCK_SKEW_SECONDS) { throw new IdentityFetchError("id_token expired"); } if (!claims.sub) { From 5119b7cefc2151234c03a869969859acc8af14a6 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:25 +0530 Subject: [PATCH 14/21] fix(node): require https on discovered OIDC endpoints --- .../src/dashboard/auth/oauth/providers.ts | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/sdks/node/src/dashboard/auth/oauth/providers.ts b/sdks/node/src/dashboard/auth/oauth/providers.ts index 9bbc1016..69d193cc 100644 --- a/sdks/node/src/dashboard/auth/oauth/providers.ts +++ b/sdks/node/src/dashboard/auth/oauth/providers.ts @@ -48,6 +48,28 @@ function domainAllowlistCheck(identity: ProviderIdentity, allowedDomains: string } } +/** + * Discovered endpoints are attacker-influenceable via a misconfigured or + * compromised discovery document; require https (plain http only for + * local development hosts) before sending codes or the client secret. + */ +function requireSecureEndpoint(raw: string | undefined, field: string): string { + if (!raw) { + throw new IdentityFetchError(`OIDC discovery document has no ${field}`); + } + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new IdentityFetchError(`OIDC discovery ${field} is not a valid URL`); + } + const isLocal = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname); + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocal)) { + throw new IdentityFetchError(`OIDC discovery ${field} must use https: ${raw}`); + } + return raw; +} + async function fetchJson(http: FetchLike, url: string, what: string): Promise { let response: Response; try { @@ -143,10 +165,8 @@ abstract class OidcProviderBase implements OAuthProvider { private async getJwks(): Promise<{ keys?: Array> }> { if (!this.jwks) { const discovery = await this.getDiscovery(); - if (!discovery.jwks_uri) { - throw new IdentityFetchError("OIDC discovery document has no jwks_uri"); - } - this.jwks = (await fetchJson(this.http, discovery.jwks_uri, "JWKS fetch")) as { + const jwksUri = requireSecureEndpoint(discovery.jwks_uri, "jwks_uri"); + this.jwks = (await fetchJson(this.http, jwksUri, "JWKS fetch")) as { keys?: Array>; }; } @@ -160,9 +180,10 @@ abstract class OidcProviderBase implements OAuthProvider { redirectUri: string; }): Promise { const discovery = await this.getDiscovery(); - if (!discovery.authorization_endpoint) { - throw new IdentityFetchError("OIDC discovery document has no authorization_endpoint"); - } + const authorizationEndpoint = requireSecureEndpoint( + discovery.authorization_endpoint, + "authorization_endpoint", + ); const query = new URLSearchParams({ response_type: "code", client_id: this.clientId, @@ -174,7 +195,7 @@ abstract class OidcProviderBase implements OAuthProvider { code_challenge_method: "S256", ...this.extraAuthParams(), }); - return `${discovery.authorization_endpoint}?${query.toString()}`; + return `${authorizationEndpoint}?${query.toString()}`; } async exchangeCode(params: { @@ -184,10 +205,8 @@ abstract class OidcProviderBase implements OAuthProvider { expectedNonce: string | null; }): Promise { const discovery = await this.getDiscovery(); - if (!discovery.token_endpoint) { - throw new IdentityFetchError("OIDC discovery document has no token_endpoint"); - } - const token = await fetchToken(this.http, discovery.token_endpoint, { + const tokenEndpoint = requireSecureEndpoint(discovery.token_endpoint, "token_endpoint"); + const token = await fetchToken(this.http, tokenEndpoint, { clientId: this.clientId, clientSecret: this.clientSecret, code: params.code, From 63524cecc22330a68869ef91c7a10b49fb251c64 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:28 +0530 Subject: [PATCH 15/21] fix(node): harden auth store writes Hash before the load-check-save so concurrent creates can't clobber each other across the PBKDF2 await, and revoke live sessions when a user is deleted. --- sdks/node/src/dashboard/auth/store.ts | 28 +++++++++++++++++++- sdks/node/test/dashboard/sessionAuth.test.ts | 8 +++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/sdks/node/src/dashboard/auth/store.ts b/sdks/node/src/dashboard/auth/store.ts index 5d250e4b..18533830 100644 --- a/sdks/node/src/dashboard/auth/store.ts +++ b/sdks/node/src/dashboard/auth/store.ts @@ -237,12 +237,20 @@ export class AuthStore { validateUsername(username); validatePassword(password); validateRole(role); + if (this.loadUsers()[username]) { + throw new ValidationError(`user '${username}' already exists`); + } + // Hash BEFORE the load-check-save so no await sits between reading the + // user table and writing it back — a concurrent create (e.g. racing + // first-run setup requests) can no longer be clobbered by a stale + // snapshot taken before the slow PBKDF2 step. + const passwordHash = await hashPassword(password); const users = this.loadUsers(); if (users[username]) { throw new ValidationError(`user '${username}' already exists`); } users[username] = { - password_hash: await hashPassword(password), + password_hash: passwordHash, role, created_at: Date.now(), last_login_at: null, @@ -269,9 +277,27 @@ export class AuthStore { } delete users[username]; this.saveUsers(users); + // Revoke live sessions so a deleted account stops authorizing now, + // not at session expiry. + this.deleteSessionsForUser(username); return true; } + private deleteSessionsForUser(username: string): void { + for (const [key, value] of Object.entries(this.queue.listSettings())) { + if (!key.startsWith(SESSION_PREFIX)) { + continue; + } + try { + if (JSON.parse(value)?.username === username) { + this.queue.deleteSetting(key); + } + } catch { + // Malformed rows are ignored here; pruning handles them. + } + } + } + /** The user iff username+password match; updates `last_login_at`. */ async authenticate(username: string, password: string): Promise { const users = this.loadUsers(); diff --git a/sdks/node/test/dashboard/sessionAuth.test.ts b/sdks/node/test/dashboard/sessionAuth.test.ts index 55116488..7136c101 100644 --- a/sdks/node/test/dashboard/sessionAuth.test.ts +++ b/sdks/node/test/dashboard/sessionAuth.test.ts @@ -139,10 +139,12 @@ describe("login and sessions", () => { expect(new AuthStore(queue).getSession(session.token)).toBeUndefined(); }); - it("whoami self-heals a session whose user was deleted", async () => { - const { headers } = await seedAdminAndSession(queue, { username: "doomed" }); + it("deleting a user revokes its live sessions immediately", async () => { + const { session, headers } = await seedAdminAndSession(queue, { username: "doomed" }); new AuthStore(queue).deleteUser("doomed"); - expect((await fetch(`${base}/api/auth/whoami`, { headers })).status).toBe(404); + // The session row is gone, so the gate rejects before whoami runs. + expect(new AuthStore(queue).getSession(session.token)).toBeUndefined(); + expect((await fetch(`${base}/api/auth/whoami`, { headers })).status).toBe(401); }); }); From 3fe0577a5c0c97a49ec28a7591d7b880dab479d7 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:31 +0530 Subject: [PATCH 16/21] fix(node): enforce settings value cap in bytes --- sdks/node/src/dashboard/handlers/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/node/src/dashboard/handlers/settings.ts b/sdks/node/src/dashboard/handlers/settings.ts index c2423374..78869902 100644 --- a/sdks/node/src/dashboard/handlers/settings.ts +++ b/sdks/node/src/dashboard/handlers/settings.ts @@ -61,7 +61,7 @@ export function putSetting(queue: Queue, key: string, body: unknown) { // Accept any JSON-serialisable type — re-encode for storage so callers // don't need to stringify themselves. const value = typeof raw === "string" ? raw : JSON.stringify(raw); - if (value.length > MAX_VALUE_LENGTH) { + if (Buffer.byteLength(value, "utf8") > MAX_VALUE_LENGTH) { throw new BadRequestError(`setting value exceeds ${MAX_VALUE_LENGTH} bytes`); } queue.setSetting(key, value); From 864addd1c3987308494eb0712038420782ca3a3e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:35 +0530 Subject: [PATCH 17/21] fix(node): bind dashboard after env admin bootstrap --- sdks/node/src/dashboard/index.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts index c2ca4985..e8934f2e 100644 --- a/sdks/node/src/dashboard/index.ts +++ b/sdks/node/src/dashboard/index.ts @@ -47,11 +47,13 @@ const defaultStaticDir = (): string => fileURLToPath(new URL(STATIC_REL, import. * Returns the listening HTTP server; call `.close()` to stop it. */ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Server { - if (!options.auth) { - void bootstrapAdminFromEnv(queue).catch((error) => { - log.warn(() => `admin bootstrap failed: ${String(error)}`); - }); - } + const bootstrap = options.auth + ? Promise.resolve() + : bootstrapAdminFromEnv(queue) + .then(() => undefined) + .catch((error) => { + log.warn(() => `admin bootstrap failed: ${String(error)}`); + }); const server = createDashboardServer(queue, options.staticDir ?? defaultStaticDir(), { auth: options.auth, secureCookies: options.secureCookies, @@ -61,7 +63,11 @@ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Se server.on("error", (error) => { log.error(() => "dashboard server error", error); }); - server.listen(options.port ?? 8787, options.host ?? "127.0.0.1"); + // Bind only after the env admin exists, so a first request can't race + // the intended bootstrap admin through the open setup endpoint. + void bootstrap.then(() => { + server.listen(options.port ?? 8787, options.host ?? "127.0.0.1"); + }); return server; } From 02dd6782777b8d47879f3a53102a7c2be0a84664 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:38 +0530 Subject: [PATCH 18/21] fix(node): degraded readiness 503, safe oauth slot decode --- sdks/node/src/dashboard/server.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index 03cee324..c10b7f45 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -221,7 +221,13 @@ function matchSlot(path: string, prefix: string): string | undefined { if (!path.startsWith(prefix)) { return undefined; } - const slot = decodeURIComponent(path.slice(prefix.length)); + let slot: string; + try { + slot = decodeURIComponent(path.slice(prefix.length)); + } catch { + // Malformed percent-encoding on a public path is a 404, not a 500. + return undefined; + } return slot && !slot.includes("/") ? slot : undefined; } @@ -433,7 +439,9 @@ async function serveProbe( return; } if (path === "/readiness") { - sendJson(res, 200, await readiness(queue)); + const payload = await readiness(queue); + // Non-2xx on degraded so orchestrators stop routing to this instance. + sendJson(res, payload.status === "ready" ? 200 : 503, payload); return; } try { From eaeb8a0a0f13b5a25fd5f7b1a5b2d1ad93ebb32c Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:42 +0530 Subject: [PATCH 19/21] fix(node): clamp delivery log pagination inputs --- sdks/node/src/webhooks/deliveryLog.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sdks/node/src/webhooks/deliveryLog.ts b/sdks/node/src/webhooks/deliveryLog.ts index bc1540e3..3af4aa99 100644 --- a/sdks/node/src/webhooks/deliveryLog.ts +++ b/sdks/node/src/webhooks/deliveryLog.ts @@ -79,8 +79,10 @@ export class DeliveryLog { if (filters.event) { rows = rows.filter((r) => r.event === filters.event); } - const offset = filters.offset ?? 0; - const limit = filters.limit ?? 50; + // Clamp so negative values can't trigger Array.slice's negative-index + // semantics and return the wrong page. + const offset = Math.max(0, filters.offset ?? 0); + const limit = Math.max(0, filters.limit ?? 50); return rows.slice(offset, offset + limit).map(fromRow); } From c9c521f9e063db1528305bcdc488db33c7bf7683 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:45 +0530 Subject: [PATCH 20/21] fix(node): catch detached webhook dispatch failures --- sdks/node/src/webhooks/manager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sdks/node/src/webhooks/manager.ts b/sdks/node/src/webhooks/manager.ts index e773027e..7c53a722 100644 --- a/sdks/node/src/webhooks/manager.ts +++ b/sdks/node/src/webhooks/manager.ts @@ -1,6 +1,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { Emitter, EventName, OutcomeEvent } from "../events"; import type { NativeQueue } from "../native"; +import { createLogger } from "../utils"; import { Deliverer } from "./deliverer"; import { type DeliveryFilters, DeliveryLog } from "./deliveryLog"; import { WebhookValidationError } from "./errors"; @@ -8,6 +9,8 @@ import { WebhookStore } from "./store"; import type { Delivery, Webhook, WebhookInput } from "./types"; const ALL_EVENTS: EventName[] = ["job.completed", "job.retrying", "job.dead", "job.cancelled"]; + +const log = createLogger("webhooks"); const DEFAULT_MAX_RETRIES = 3; const DEFAULT_TIMEOUT_MS = 10_000; @@ -174,7 +177,11 @@ export class WebhookManager { } void this.deliverer .deliver(webhook, event, payload) - .then((delivery) => this.deliveryLog.record(delivery)); + .then((delivery) => this.deliveryLog.record(delivery)) + .catch((error) => { + // A logging failure must never surface as an unhandled rejection. + log.warn(() => `webhook delivery log write failed for ${webhook.id}`, error); + }); } } } From 84d2e7bd4de7870d94dd4ef48232c941f951f9a4 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:22:48 +0530 Subject: [PATCH 21/21] fix(node): resolve middleware chain before task resources --- sdks/node/src/worker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts index 5d1b8f94..6ac95d8d 100644 --- a/sdks/node/src/worker.ts +++ b/sdks/node/src/worker.ts @@ -109,6 +109,10 @@ export class Worker { } const args = serializer.deserialize(payload) as unknown[]; const ctx: TaskContext = { jobId: invocation.id, taskName: invocation.taskName, args }; + // Resolve the middleware chain BEFORE allocating the cancel poller and + // task scope — it reads storage and may throw, and nothing would clean + // those up yet. + const chain = middlewareFor(invocation.taskName); // Cooperative cancel signal + job context exposed to the handler. const controller = new AbortController(); @@ -151,7 +155,6 @@ export class Worker { return task.handler(...args); }; - const chain = middlewareFor(invocation.taskName); try { for (const mw of chain) { await mw.before?.(ctx);