Merge pull request #56 from mrteye/phase1-arch-setup-14562406403991920726
feat: phase 1 vertical slicing & hypermedia foundation
This commit is contained in:
commit
a1909381d5
@ -2,16 +2,18 @@
|
|||||||
"workspace": [
|
"workspace": [
|
||||||
"./sdk",
|
"./sdk",
|
||||||
"./server",
|
"./server",
|
||||||
"./ui"
|
"./ui",
|
||||||
|
"./src"
|
||||||
],
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run --watch -A --unstable-ffi server/main.ts",
|
"dev": "deno run --watch -A --unstable-ffi server/main.ts",
|
||||||
"start": "deno run -A --unstable-ffi server/main.ts",
|
"start": "deno run -A --unstable-ffi server/main.ts",
|
||||||
"test": "deno test -A --unstable-ffi",
|
"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",
|
"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",
|
"setup": "deno run -A infra/setup.ts",
|
||||||
"release": "deno run -A infra/setup.ts release"
|
"release": "deno run -A infra/setup.ts release"
|
||||||
},
|
},
|
||||||
|
|||||||
22
deno.lock
generated
22
deno.lock
generated
@ -20,10 +20,14 @@
|
|||||||
"jsr:@std/encoding@~1.0.5": "1.0.10",
|
"jsr:@std/encoding@~1.0.5": "1.0.10",
|
||||||
"jsr:@std/fmt@0.225.2": "0.225.2",
|
"jsr:@std/fmt@0.225.2": "0.225.2",
|
||||||
"jsr:@std/fmt@~1.0.2": "1.0.8",
|
"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": "1.0.14",
|
||||||
"jsr:@std/internal@^1.0.12": "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/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@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/path@~1.0.6": "1.0.9",
|
||||||
"jsr:@std/testing@*": "1.0.20",
|
"jsr:@std/testing@*": "1.0.20",
|
||||||
"jsr:@std/text@~1.0.7": "1.0.19",
|
"jsr:@std/text@~1.0.7": "1.0.19",
|
||||||
@ -132,6 +136,13 @@
|
|||||||
"@std/fmt@1.0.8": {
|
"@std/fmt@1.0.8": {
|
||||||
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
|
"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": {
|
"@std/internal@1.0.14": {
|
||||||
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
|
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
|
||||||
},
|
},
|
||||||
@ -147,6 +158,12 @@
|
|||||||
"@std/path@1.0.9": {
|
"@std/path@1.0.9": {
|
||||||
"integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e"
|
"integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e"
|
||||||
},
|
},
|
||||||
|
"@std/path@1.1.6": {
|
||||||
|
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal@^1.0.14"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@std/testing@1.0.20": {
|
"@std/testing@1.0.20": {
|
||||||
"integrity": "21380ed438672762e4ec549cbf4fe41c5b68f5598773a30b64abe7375513e721",
|
"integrity": "21380ed438672762e4ec549cbf4fe41c5b68f5598773a30b64abe7375513e721",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@ -512,6 +529,11 @@
|
|||||||
"npm:@connectrpc/connect@^1.4.0"
|
"npm:@connectrpc/connect@^1.4.0"
|
||||||
],
|
],
|
||||||
"members": {
|
"members": {
|
||||||
|
"src": {
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@hono/hono@4"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@hono/hono@4"
|
"jsr:@hono/hono@4"
|
||||||
|
|||||||
42
scripts/lint_arch.ts
Normal file
42
scripts/lint_arch.ts
Normal file
@ -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.");
|
||||||
|
}
|
||||||
16
src/core/auth_guards.test.ts
Normal file
16
src/core/auth_guards.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
102
src/core/auth_guards.ts
Normal file
102
src/core/auth_guards.ts
Normal file
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/core/content_negotiation.ts
Normal file
39
src/core/content_negotiation.ts
Normal file
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
364
src/core/db.ts
Normal file
364
src/core/db.ts
Normal file
@ -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<void> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
43
src/core/error_fragments.tsx
Normal file
43
src/core/error_fragments.tsx
Normal file
@ -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`
|
||||||
|
<div id="status-banner" data-signals="{show: true}"
|
||||||
|
class="fixed top-4 right-4 z-50 transition-all duration-300">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 ${bgClass} text-white px-4 py-3 rounded shadow-lg"
|
||||||
|
role="alert" aria-live="polite">
|
||||||
|
<span>${icon}</span>
|
||||||
|
<span class="font-medium">${message}</span>
|
||||||
|
<button data-on-click="$show = false" aria-label="Close"
|
||||||
|
class="ml-4 hover:opacity-80">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
#status-banner { display: block; opacity: 1; transform: translateY(0); }
|
||||||
|
#status-banner[data-signals*='"show":false'],
|
||||||
|
#status-banner[data-signals*='"show": false'] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFieldErrorFragment(fieldId: string, errorMsg: string) {
|
||||||
|
// Merges the field error specifically under the input field
|
||||||
|
// Assumes a convention like <p id="{fieldId}-error" class="text-red-500 text-sm mt-1"></p>
|
||||||
|
return html`
|
||||||
|
<p id="${fieldId}-error" class="text-red-500 text-sm mt-1" role="alert"
|
||||||
|
aria-live="polite">
|
||||||
|
${errorMsg}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
263
src/core/spire_ffi.ts
Normal file
263
src/core/spire_ffi.ts
Normal file
@ -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<any> | 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<SvidResponse> {
|
||||||
|
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<Deno.PointerValue>);
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<number>);
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
44
src/core/sse_adapter.ts
Normal file
44
src/core/sse_adapter.ts
Normal file
@ -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<void>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
sleep: (ms: number) => Promise<void>;
|
||||||
|
aborted: boolean;
|
||||||
|
}) => Promise<void>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
42
src/core/valkey.ts
Normal file
42
src/core/valkey.ts
Normal file
@ -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<void> {
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/deno.json
Normal file
9
src/deno.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/main.ts
Normal file
23
src/main.ts
Normal file
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user