diff --git a/deno.json b/deno.json index ed2e58f..5bd68c9 100644 --- a/deno.json +++ b/deno.json @@ -2,16 +2,18 @@ "workspace": [ "./sdk", "./server", - "./ui" + "./ui", + "./src" ], "license": "MIT OR Apache-2.0", "tasks": { "dev": "deno run --watch -A --unstable-ffi server/main.ts", "start": "deno run -A --unstable-ffi server/main.ts", "test": "deno test -A --unstable-ffi", - "lint": "deno lint", + "lint": "deno lint && deno run -A scripts/lint_arch.ts", + "lint:arch": "deno run -A scripts/lint_arch.ts", "fmt": "deno fmt", - "check": "deno check server/**/*.ts sdk/**/*.ts ui/**/*.ts infra/**/*.ts", + "check": "deno check server/**/*.ts sdk/**/*.ts ui/**/*.ts infra/**/*.ts src/**/*.ts src/**/*.tsx", "setup": "deno run -A infra/setup.ts", "release": "deno run -A infra/setup.ts release" }, diff --git a/deno.lock b/deno.lock index 7addef8..71402d5 100644 --- a/deno.lock +++ b/deno.lock @@ -20,10 +20,14 @@ "jsr:@std/encoding@~1.0.5": "1.0.10", "jsr:@std/fmt@0.225.2": "0.225.2", "jsr:@std/fmt@~1.0.2": "1.0.8", + "jsr:@std/fs@*": "1.0.24", "jsr:@std/internal@1": "1.0.14", "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/io@~0.224.9": "0.224.9", + "jsr:@std/path@*": "1.0.9", "jsr:@std/path@0.225.2": "0.225.2", + "jsr:@std/path@^1.1.5": "1.1.6", "jsr:@std/path@~1.0.6": "1.0.9", "jsr:@std/testing@*": "1.0.20", "jsr:@std/text@~1.0.7": "1.0.19", @@ -132,6 +136,13 @@ "@std/fmt@1.0.8": { "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, @@ -147,6 +158,12 @@ "@std/path@1.0.9": { "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + }, "@std/testing@1.0.20": { "integrity": "21380ed438672762e4ec549cbf4fe41c5b68f5598773a30b64abe7375513e721", "dependencies": [ @@ -512,6 +529,11 @@ "npm:@connectrpc/connect@^1.4.0" ], "members": { + "src": { + "dependencies": [ + "jsr:@hono/hono@4" + ] + }, "ui": { "dependencies": [ "jsr:@hono/hono@4" diff --git a/scripts/lint_arch.ts b/scripts/lint_arch.ts new file mode 100644 index 0000000..e513efa --- /dev/null +++ b/scripts/lint_arch.ts @@ -0,0 +1,42 @@ +import { walk } from "jsr:@std/fs"; + +const targetDir = "./src"; + +let hasErrors = false; + +async function checkFile(path: string) { + const content = await Deno.readTextFile(path); + const lines = content.split('\n'); + + lines.forEach((line, index) => { + // 1. Block banned DOM APIs + if (line.includes("document.getElementById") || + line.includes("document.querySelector") || + line.includes("document.createElement")) { + console.error(`[Arch Lint] ❌ Banned DOM API used in ${path}:${index + 1}`); + console.error(` ${line.trim()}`); + console.error(` -> Use Datastar reactive attributes or SSE morphs instead.`); + hasErrors = true; + } + + // 2. Block unescaped HTML in raw strings (basic heuristic for dangerouslySetInnerHTML) + if (line.includes("dangerouslySetInnerHTML") && !path.includes("error_fragments.tsx")) { + console.error(`[Arch Lint] ❌ dangerouslySetInnerHTML used in ${path}:${index + 1}`); + console.error(` ${line.trim()}`); + console.error(` -> Native JSX HTML escaping should be used unless in explicit core fragments.`); + hasErrors = true; + } + }); +} + +for await (const entry of walk(targetDir, { exts: [".ts", ".tsx"] })) { + if (entry.isFile) { + await checkFile(entry.path); + } +} + +if (hasErrors) { + Deno.exit(1); +} else { + console.log("✅ Architecture lint passed."); +} diff --git a/src/core/auth_guards.test.ts b/src/core/auth_guards.test.ts new file mode 100644 index 0000000..78c5529 --- /dev/null +++ b/src/core/auth_guards.test.ts @@ -0,0 +1,16 @@ +import { assertEquals } from "jsr:@std/assert"; +import { determineClientType } from "./content_negotiation.ts"; +import { Context } from "jsr:@hono/hono@4"; + +Deno.test("determineClientType - correctly identifies datastar", () => { + const mockContext = { + req: { + header: (name: string) => { + if (name === "datastar-request") return "true"; + return ""; + } + } + } as unknown as Context; + + assertEquals(determineClientType(mockContext), "datastar"); +}); diff --git a/src/core/auth_guards.ts b/src/core/auth_guards.ts new file mode 100644 index 0000000..534dcec --- /dev/null +++ b/src/core/auth_guards.ts @@ -0,0 +1,102 @@ +import type { Context, Next } from "jsr:@hono/hono@4"; +import { HTTPException } from "jsr:@hono/hono@4/http-exception"; +import { valkey } from "./valkey.ts"; + +const MAX_BODY_SIZE = 16 * 1024; // 16KB + +export async function payloadCapGuard(c: Context, next: Next) { + const contentLength = c.req.header("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) { + throw new HTTPException(413, { + message: "Payload Too Large: Max 16KB allowed.", + }); + } + + // Also guard if they stream/chunk it without content-length but we can't easily do that natively in middleware without consuming body + await next(); +} + +export function csrfOriginGuard(allowedOrigins: string[]) { + return async (c: Context, next: Next) => { + // Only check mutating methods + if (["POST", "PUT", "PATCH", "DELETE"].includes(c.req.method)) { + const origin = c.req.header("origin"); + const referer = c.req.header("referer"); + const host = c.req.header("host"); + + // Simple check: if origin matches one of allowed origins or if origin matches host + if (origin) { + let isAllowed = false; + try { + const originUrl = new URL(origin); + if ( + allowedOrigins.includes(originUrl.origin) || originUrl.host === host + ) { + isAllowed = true; + } + } catch (_e) { + // invalid url + } + if (!isAllowed) { + throw new HTTPException(403, { + message: "Forbidden: Invalid Origin for CSRF protection.", + }); + } + } else if (referer) { + try { + const refererUrl = new URL(referer); + if ( + allowedOrigins.includes(refererUrl.origin) || + refererUrl.host === host + ) { + // allowed + } else { + throw new HTTPException(403, { + message: "Forbidden: Invalid Referer for CSRF protection.", + }); + } + } catch (_e) { + throw new HTTPException(403, { + message: "Forbidden: Invalid Referer for CSRF protection.", + }); + } + } else { + // Enforce presence of Origin or Referer for mutating requests (basic CSRF protection) + throw new HTTPException(403, { + message: + "Forbidden: Origin or Referer header required for CSRF protection.", + }); + } + } + await next(); + }; +} + +export function rateLimitGuard( + limit: number, + windowSecs: number, + keyPrefix: string, +) { + return async (c: Context, next: Next) => { + // Simple sliding/fixed window rate limit implementation using Valkey + const ip = c.req.header("x-forwarded-for")?.split(",")[0] || "unknown-ip"; + const key = `ratelimit:${keyPrefix}:${ip}`; + + try { + const current = await valkey.incr(key); + if (current === 1) { + await valkey.expire(key, windowSecs); + } + + if (current > limit) { + throw new HTTPException(429, { message: "Too Many Requests" }); + } + } catch (err) { + if (err instanceof HTTPException) throw err; + // If Valkey is down, we might want to log it and allow, or fail open + console.warn("[RateLimit] Valkey error:", err); + } + + await next(); + }; +} diff --git a/src/core/content_negotiation.ts b/src/core/content_negotiation.ts new file mode 100644 index 0000000..eee8aa5 --- /dev/null +++ b/src/core/content_negotiation.ts @@ -0,0 +1,39 @@ +import type { Context, Next } from "jsr:@hono/hono@4"; + +export type ClientType = "datastar" | "cli" | "shell" | "browser"; + +export function determineClientType(c: Context): ClientType { + const accept = c.req.header("accept") || ""; + const userAgent = c.req.header("user-agent") || ""; + + if (c.req.header("datastar-request") === "true") { + return "datastar"; + } + + if ( + accept.includes("application/json") || userAgent.includes("curl") || + userAgent.includes("AuthYesCLI") + ) { + // If it's explicitly asking for JSON, or it's a CLI tool like curl/our custom CLI + if (accept.includes("application/json")) { + return "cli"; + } + // curl without explicit accept json might just want text/plain shell strings + return "shell"; + } + + if (accept.includes("text/html")) { + return "browser"; + } + + // Default to cli/json if unknown + return "cli"; +} + +export function contentNegotiation() { + return async (c: Context, next: Next) => { + const clientType = determineClientType(c); + c.set("clientType", clientType); + await next(); + }; +} diff --git a/src/core/db.ts b/src/core/db.ts new file mode 100644 index 0000000..c4968ec --- /dev/null +++ b/src/core/db.ts @@ -0,0 +1,364 @@ +import postgres from "npm:postgres@3"; + +// Evaluate environment variables dynamically to prevent crash during test imports +const host = Deno.env.get("POSTGRES_HOST") || + (import.meta.main ? "" : "localhost"); +const user = Deno.env.get("POSTGRES_USER") || + (import.meta.main ? "" : "postgres"); +const password = Deno.env.get("POSTGRES_PASSWORD") || + (import.meta.main ? "" : "postgres"); +const db = Deno.env.get("POSTGRES_DB") || (import.meta.main ? "" : "postgres"); +const port = Deno.env.get("POSTGRES_PORT") || "5432"; + +if (import.meta.main && (!host || !user || !password || !db)) { + throw new Error( + "Missing critical database environment variables. Required: POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB.", + ); +} + +const connectionString = `postgres://${user}:${password}@${host}:${port}/${db}`; + +// Export it as let so we can override it in tests +export let sql = postgres(connectionString); + +/** + * SIDE EFFECT: Initializes the database schema. + */ +export async function initDb(): Promise { + console.log("[Auth DB] Initializing central identity database schema..."); + + // We ensure new users are 'pending' to satisfy Use Case 3 (Manual state machine activation) + // If the table exists we will attempt to alter the default. + await sql` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT UNIQUE NOT NULL, + display_name TEXT, + account_status TEXT DEFAULT 'pending', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + try { + await sql`ALTER TABLE users ALTER COLUMN account_status SET DEFAULT 'pending'`; + await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`; + } catch { + // Ignore if unsupported + } + + await sql` + CREATE TABLE IF NOT EXISTS apps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + spiffe_id VARCHAR(255) UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + // Ensure app_secret column exists for API validation mapping + try { + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS app_secret TEXT UNIQUE`; + } catch { + // Soft ignore if column already exists + // Ignore and check next + } + + // Ensure domain column exists for Edge authorization routing + try { + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS domain TEXT UNIQUE`; + } catch { + // Soft ignore if column already exists + } + + // Tier 1 Ingress Control + try { + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE`; + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS bypass_paths TEXT[] DEFAULT '{}'`; + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS allowed_cidrs TEXT[] DEFAULT '{}'`; + } catch { + // Soft ignore if columns already exist + } + + await sql` + CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + app_id UUID REFERENCES apps(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(name, app_id) + ); + `; + + // Seed standard global roles if table is empty + try { + const existingRoles = await sql`SELECT count(*)::int as count FROM roles` + .then((res) => res[0]?.count || 0); + if (existingRoles === 0) { + await sql` + INSERT INTO roles (name, description, app_id) VALUES + ('admin', 'Full administrative access across all management capabilities', NULL), + ('editor', 'Read and write access with permissions to modify records', NULL), + ('operator', 'Operational execution access for runtime tasks', NULL), + ('viewer', 'Read-only access across application telemetry and views', NULL) + ON CONFLICT DO NOTHING + `; + } + } catch { + // Ignore seed errors on race conditions + } + + await sql` + CREATE TABLE IF NOT EXISTS grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'user', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, app_id) + ); + `; + + await sql` + CREATE TABLE IF NOT EXISTS invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT UNIQUE NOT NULL, + app_id UUID REFERENCES apps(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'user', + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + max_uses INT DEFAULT 1, + uses_count INT DEFAULT 0, + auto_activate BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + used_by UUID REFERENCES users(id) ON DELETE SET NULL + ); + `; + + // Migrations for existing invites table + try { + await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS max_uses INT DEFAULT 1`; + await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS uses_count INT DEFAULT 0`; + await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS auto_activate BOOLEAN DEFAULT TRUE`; + } catch { + // Ignore migration column exists + } + + await sql` + CREATE TABLE IF NOT EXISTS invite_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invite_id UUID NOT NULL REFERENCES invites(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + await sql` + CREATE TABLE IF NOT EXISTS aaguid_allowlist ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + aaguid UUID UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + await sql` + CREATE TABLE IF NOT EXISTS recovery_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT UNIQUE NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE + ); + `; + + await sql` + CREATE TABLE IF NOT EXISTS recovery_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + server_share TEXT NOT NULL, + pin_hash TEXT NOT NULL, + attempts_count INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + await sql` + CREATE TABLE IF NOT EXISTS audit_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details JSONB, + ip_address TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + leaf_hash TEXT + ); + `; + + try { + await sql`ALTER TABLE audit_records ADD COLUMN IF NOT EXISTS leaf_hash TEXT`; + } catch { + // Ignore migration column exists + } + + await sql` + CREATE TABLE IF NOT EXISTS event_passes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL, + pin_code TEXT UNIQUE, + name TEXT NOT NULL, + app_id UUID REFERENCES apps(id) ON DELETE SET NULL, + role TEXT DEFAULT 'viewer', + max_seats INT DEFAULT 50, + seats_claimed INT DEFAULT 0, + lifespan_hours INT DEFAULT 3, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + is_active BOOLEAN DEFAULT TRUE, + is_paused BOOLEAN DEFAULT FALSE, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + try { + await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`; + await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS event_pass_id UUID REFERENCES event_passes(id) ON DELETE CASCADE`; + + // Backfill legacy event passes to ensure older guests can still be revoked/extended + await sql` + UPDATE users u + SET event_pass_id = ep.id + FROM event_passes ep + WHERE u.account_status = 'guest' + AND u.event_pass_id IS NULL + AND u.username LIKE 'guest_' || ep.slug || '\\_%' + `; + } catch { + // Ignore migration column exists + } + + await sql` + CREATE TABLE IF NOT EXISTS audit_sths ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_size BIGINT NOT NULL, + root_hash TEXT NOT NULL, + signature TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + await sql` + CREATE TABLE IF NOT EXISTS passkeys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT UNIQUE NOT NULL, + public_key TEXT NOT NULL, + counter BIGINT NOT NULL, + prf_enabled BOOLEAN DEFAULT FALSE, + prf_salt TEXT + ); + `; + + // Ensure prf columns exist + try { + await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_enabled BOOLEAN DEFAULT FALSE`; + await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_salt TEXT`; + } catch { + // Ignore migration column exists + } + + await sql` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT, + is_agent BOOLEAN DEFAULT FALSE, + custom_scopes TEXT[], + last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_activity_action TEXT, + is_paused BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL + ); + `; + + try { + await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS label TEXT`; + await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_agent BOOLEAN DEFAULT FALSE`; + await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS custom_scopes TEXT[]`; + await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`; + await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_action TEXT`; + await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`; + } catch { + // Ignore migration column exists + } + + await sql` + CREATE TABLE IF NOT EXISTS hwk_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + fingerprint TEXT UNIQUE NOT NULL, + public_key JSONB NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + + // Seed ed-droid app record with spiffe_id + await sql` + INSERT INTO apps (name, spiffe_id) + VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend') + ON CONFLICT (spiffe_id) DO NOTHING + `; + + // Seed the Central Auth-Yes Management App for global administration + await sql` + INSERT INTO apps (name, spiffe_id) + VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management') + ON CONFLICT (spiffe_id) DO NOTHING + `; + + // Seed initial bootstrap admin invite if no users exist in the database + try { + const userCount = await sql`SELECT count(*)::int as count FROM users`.then( + (res) => res[0]?.count || 0, + ); + if (userCount === 0) { + const inviteCount = await sql` + SELECT count(*)::int as count FROM invites + WHERE role = 'admin' AND expires_at > NOW() + `.then((res) => res[0]?.count || 0); + + if (inviteCount === 0) { + const bootstrapCode = Deno.env.get("BOOTSTRAP_INVITE_CODE") || + "bootstrap-admin"; + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + await sql` + INSERT INTO invites (code, app_id, role, max_uses, uses_count, auto_activate, expires_at) + VALUES (${bootstrapCode}, NULL, 'admin', 1, 0, TRUE, ${expiresAt}) + ON CONFLICT (code) DO NOTHING + `; + console.log( + `[Auth DB] Initial bootstrap admin invite code seeded: '${bootstrapCode}'`, + ); + } + } + } catch (err) { + console.warn("[Auth DB] Bootstrap invite check skipped:", err); + } + + console.log("[Auth DB] Central identity database schema initialized."); +} + +export const sqlWrapper = { + get sql() { + return sql; + }, + set sql(val: any) { + sql = val; + }, +}; diff --git a/src/core/error_fragments.tsx b/src/core/error_fragments.tsx new file mode 100644 index 0000000..e44fee0 --- /dev/null +++ b/src/core/error_fragments.tsx @@ -0,0 +1,43 @@ +import { html } from "jsr:@hono/hono@4/html"; + +export function renderErrorToastFragment(message: string, isError = true) { + const bgClass = isError ? "bg-red-500" : "bg-green-500"; + const icon = isError ? "⚠️" : "✅"; + + // Renders a toast fragment intended to morph onto a #status-banner or similar container + return html` +
+ + +
+ `; +} + +export function renderFieldErrorFragment(fieldId: string, errorMsg: string) { + // Merges the field error specifically under the input field + // Assumes a convention like

+ return html` + + `; +} diff --git a/src/core/spire_ffi.ts b/src/core/spire_ffi.ts new file mode 100644 index 0000000..d79c671 --- /dev/null +++ b/src/core/spire_ffi.ts @@ -0,0 +1,263 @@ +import { X509Certificate } from "npm:@peculiar/x509"; +import { AsnParser } from "npm:@peculiar/asn1-schema"; +import { SubjectAlternativeName } from "npm:@peculiar/asn1-x509"; + +// Deno binding for the spire_ffi Rust crate + +if (Deno.build.arch !== "x86_64" && Deno.build.arch !== "aarch64") { + throw new Error("Unsupported architecture"); +} + +const libPath = (() => { + if (Deno.build.os === "windows") { + return "../spire_ffi/target/release/spire_ffi.dll"; + } + if (Deno.build.os === "darwin") { + return "../spire_ffi/target/release/libspire_ffi.dylib"; + } + return "../spire_ffi/target/release/libspire_ffi.so"; +})(); + +let dylib: Deno.DynamicLibrary | null = null; + +try { + dylib = Deno.dlopen(libPath, { + fetch_svid: { + parameters: ["pointer"], + result: "pointer", + nonblocking: true, + }, + free_svid: { + parameters: ["pointer"], + result: "void", + }, + argon2id_derive: { + parameters: [ + "pointer", + "usize", + "pointer", + "usize", + "u32", + "u32", + "pointer", + "usize", + ], + result: "i32", + nonblocking: true, + }, + }); +} catch (_e) { + console.warn( + `Failed to load ${libPath}. Workload API fetching will be mocked/disabled if used.`, + ); +} + +export interface SvidResponse { + spiffe_id: string; + x509_svid: Uint8Array; + x509_svid_key: Uint8Array; + bundle: Uint8Array; +} + +export async function fetchSpiffeIdentity( + socketPath: string = "/var/run/spire/agent.sock", +): Promise { + if (!dylib) { + console.warn( + `[SPIRE FFI] Dynamic library (${libPath}) is not loaded. Mocking SVID response for local development.`, + ); + return { + spiffe_id: "spiffe://local.dev/mock", + x509_svid: new Uint8Array(), + x509_svid_key: new Uint8Array(), + bundle: new Uint8Array(), + }; + } + + const encoder = new TextEncoder(); + const encodedPath = encoder.encode(socketPath + "\0"); + const pathPtr = Deno.UnsafePointer.of(encodedPath); + + const fetch_svid = dylib.symbols + .fetch_svid as unknown as (( + ptr: Deno.PointerValue, + ) => Promise); + const free_svid = dylib.symbols + .free_svid as unknown as ((ptr: Deno.PointerValue) => void); + + const resPtr = await fetch_svid(pathPtr); + + if (resPtr === null) { + throw new Error("fetch_svid returned a null pointer"); + } + + const resView = new Deno.UnsafePointerView(resPtr); + let errorMsg: string | null = null; + let spiffe_id: string | null = null; + + let offset = 0; + const ptrSize = 8; // 64-bit pointers + + const spiffe_id_ptr = resView.getPointer(offset); + offset += ptrSize; + const x509_svid_ptr = resView.getPointer(offset); + offset += ptrSize; + const x509_svid_len = Number(resView.getBigUint64(offset)); + offset += ptrSize; + const x509_svid_key_ptr = resView.getPointer(offset); + offset += ptrSize; + const x509_svid_key_len = Number(resView.getBigUint64(offset)); + offset += ptrSize; + const bundle_ptr = resView.getPointer(offset); + offset += ptrSize; + const bundle_len = Number(resView.getBigUint64(offset)); + offset += ptrSize; + const error_ptr = resView.getPointer(offset); + + if (error_ptr !== null) { + errorMsg = new Deno.UnsafePointerView(error_ptr).getCString(); + } + + if (errorMsg !== null) { + free_svid(resPtr); + throw new Error(errorMsg); + } + + if (spiffe_id_ptr !== null) { + spiffe_id = new Deno.UnsafePointerView(spiffe_id_ptr).getCString(); + } + + if (!spiffe_id) { + free_svid(resPtr); + throw new Error("spiffe_id is null"); + } + + const x509_svid = x509_svid_ptr !== null && x509_svid_len > 0 + ? new Uint8Array( + new Deno.UnsafePointerView(x509_svid_ptr).getArrayBuffer(x509_svid_len), + ) + : new Uint8Array(); + + const x509_svid_key = x509_svid_key_ptr !== null && x509_svid_key_len > 0 + ? new Uint8Array( + new Deno.UnsafePointerView(x509_svid_key_ptr).getArrayBuffer( + x509_svid_key_len, + ), + ) + : new Uint8Array(); + + const bundle = bundle_ptr !== null && bundle_len > 0 + ? new Uint8Array( + new Deno.UnsafePointerView(bundle_ptr).getArrayBuffer(bundle_len), + ) + : new Uint8Array(); + + // Create copies of the typed arrays before freeing the memory + const svidData = { + spiffe_id, + x509_svid: new Uint8Array(x509_svid), + x509_svid_key: new Uint8Array(x509_svid_key), + bundle: new Uint8Array(bundle), + }; + + // Free the memory on the Rust side + free_svid(resPtr); + + return svidData; +} + +/** + * Extracts the SPIFFE ID from an incoming client TLS connection. + */ +export let extractSpiffeIdFromCert = function extractSpiffeIdFromCert( + certBundle: string, +): string | null { + if (!certBundle || typeof certBundle !== "string") { + return null; + } + + try { + const cert = new X509Certificate(certBundle); + const sanExtension = cert.extensions.find((ext) => + ext.type === "2.5.29.17" + ); // Subject Alternative Name + + if (!sanExtension) { + return null; + } + + const san = AsnParser.parse(sanExtension.value, SubjectAlternativeName); + for (const name of san) { + if ( + name.uniformResourceIdentifier && + name.uniformResourceIdentifier.startsWith("spiffe://") + ) { + return name.uniformResourceIdentifier; + } + } + } catch (_e) { + return null; + } + + return null; +}; + +export async function deriveArgon2idKey( + password: Uint8Array, + salt: Uint8Array, +): Promise { + const outBuf = new Uint8Array(32); + + if (!dylib) { + console.warn( + `[SPIRE FFI] Dynamic library (${libPath}) is not loaded. Mocking Argon2id derivation for local development.`, + ); + outBuf.fill(0xaa); + return outBuf; + } + + const iterations = 12; + const memoryKb = 65536; + + const passwordPtr = Deno.UnsafePointer.of(password); + const saltPtr = Deno.UnsafePointer.of(salt); + const outPtr = Deno.UnsafePointer.of(outBuf); + + const argon2id_derive = dylib.symbols + .argon2id_derive as unknown as (( + passwordPtr: Deno.PointerValue, + passwordLen: number | bigint, + saltPtr: Deno.PointerValue, + saltLen: number | bigint, + iterations: number, + memoryKb: number, + outPtr: Deno.PointerValue, + outLen: number | bigint, + ) => Promise); + + const res = await argon2id_derive( + passwordPtr, + password.length, + saltPtr, + salt.length, + iterations, + memoryKb, + outPtr, + outBuf.length, + ); + + if (res !== 0) { + throw new Error(`Argon2id derivation failed: ${res}`); + } + + return outBuf; +} + +export const spireWrapper = { + get extractSpiffeIdFromCert() { + return extractSpiffeIdFromCert; + }, + set extractSpiffeIdFromCert(val: any) { + extractSpiffeIdFromCert = val; + }, +}; diff --git a/src/core/sse_adapter.ts b/src/core/sse_adapter.ts new file mode 100644 index 0000000..b10b8a9 --- /dev/null +++ b/src/core/sse_adapter.ts @@ -0,0 +1,44 @@ +import type { Context } from "jsr:@hono/hono@4"; +import { streamSSE } from "jsr:@hono/hono@4/streaming"; + +export interface DatastarSSEEvent { + event?: string; // usually 'datastar-fragment', 'datastar-signal', etc + data: string; // The HTML fragment or JSON payload + id?: string; + retry?: number; +} + +export function streamDatastar( + c: Context, + callback: (stream: { + write: (event: DatastarSSEEvent) => Promise; + close: () => Promise; + sleep: (ms: number) => Promise; + aborted: boolean; + }) => Promise, +) { + return streamSSE(c, async (stream) => { + // Add custom datastar helper methods + const adapter = { + write: async (event: DatastarSSEEvent) => { + await stream.writeSSE({ + data: event.data, + event: event.event || "datastar-fragment", + id: event.id, + retry: event.retry, + }); + }, + close: async () => { + await stream.close(); + }, + sleep: async (ms: number) => { + await stream.sleep(ms); + }, + get aborted() { + return stream.aborted; + }, + }; + + await callback(adapter); + }); +} diff --git a/src/core/valkey.ts b/src/core/valkey.ts new file mode 100644 index 0000000..6fe7d66 --- /dev/null +++ b/src/core/valkey.ts @@ -0,0 +1,42 @@ +import { Redis } from "npm:ioredis"; + +const VALKEY_URL = Deno.env.get("VALKEY_URL") || + (import.meta.main ? "redis://auth-valkey:6379" : ""); + +export const valkey = VALKEY_URL + ? new Redis(VALKEY_URL, { + enableOfflineQueue: false, + maxRetriesPerRequest: 1, + retryStrategy: (times) => (times > 3 ? null : Math.min(times * 100, 1000)), + }) + : new Redis({ + lazyConnect: true, + enableOfflineQueue: false, + maxRetriesPerRequest: 1, + retryStrategy: () => null, + }); + +valkey.on("error", (err) => { + if (Deno.env.get("DEBUG_VALKEY")) { + console.warn("[Valkey] Connection warning:", err.message); + } +}); + +export async function pingValkey(): Promise { + if (!VALKEY_URL) return; // Skip in test + try { + const result = await valkey.ping(); + if (result !== "PONG") { + throw new Error(`Unexpected ping response: ${result}`); + } + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Fatal: Failed to connect to Valkey session cache. Halting boot. ${error.message}`, + ); + } + throw new Error( + `Fatal: Failed to connect to Valkey session cache. Halting boot.`, + ); + } +} diff --git a/src/deno.json b/src/deno.json new file mode 100644 index 0000000..c546af3 --- /dev/null +++ b/src/deno.json @@ -0,0 +1,9 @@ +{ + "name": "@auth-yes/src", + "version": "0.1.0", + "exports": "./main.ts", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "jsr:@hono/hono@4/jsx" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..7f3bb68 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,23 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { serveStatic } from "jsr:@hono/hono@4/deno"; +import { initDb } from "./core/db.ts"; +import { pingValkey } from "./core/valkey.ts"; + +const app: Hono = new Hono(); + +// Serve static assets (specifically Datastar) +app.use("/public/*", serveStatic({ root: "./" })); + +// Basic health check for foundation +app.get("/healthz", (c) => c.text("OK")); + +if (import.meta.main) { + console.log("[Auth-Yes Next] Bootstrapping core foundation..."); + await initDb(); + await pingValkey(); + + const port = parseInt(Deno.env.get("PORT") || "8000", 10); + Deno.serve({ port }, app.fetch); +} + +export default app; diff --git a/tasks/new/2026-0827.07.jul.plan.arch.hypermedia-and-vertical-slicing-1715.md b/tasks/complete/2026-0827.07.jul.plan.arch.hypermedia-and-vertical-slicing-1715.md similarity index 100% rename from tasks/new/2026-0827.07.jul.plan.arch.hypermedia-and-vertical-slicing-1715.md rename to tasks/complete/2026-0827.07.jul.plan.arch.hypermedia-and-vertical-slicing-1715.md