Compare commits
No commits in common. "34e9c4a76dfdcc2546db67588116aac12a91ac83" and "f5c44f8ac5de188e40a1eb03b68b853e734d4145" have entirely different histories.
34e9c4a76d
...
f5c44f8ac5
@ -2,18 +2,16 @@
|
|||||||
"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 && deno run -A scripts/lint_arch.ts",
|
"lint": "deno lint",
|
||||||
"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 src/**/*.ts src/**/*.tsx",
|
"check": "deno check server/**/*.ts sdk/**/*.ts ui/**/*.ts infra/**/*.ts",
|
||||||
"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,14 +20,10 @@
|
|||||||
"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",
|
||||||
@ -136,13 +132,6 @@
|
|||||||
"@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"
|
||||||
},
|
},
|
||||||
@ -158,12 +147,6 @@
|
|||||||
"@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": [
|
||||||
@ -529,11 +512,6 @@
|
|||||||
"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"
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
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.");
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { assertEquals } from "jsr:@std/assert";
|
|
||||||
import { determineClientType } from "./content_negotiation.ts";
|
|
||||||
import type { 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");
|
|
||||||
});
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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
364
src/core/db.ts
@ -1,364 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"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
23
src/main.ts
@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
# Post-Implementation Audit: Phase 1 (Hypermedia Architecture)
|
|
||||||
|
|
||||||
## 1. Test Suite Status
|
|
||||||
- **`deno fmt`**: Passed (After 2 minor corrections in test files and scripts)
|
|
||||||
- **`deno task lint`**: Passed (Fixed one verbatim-module-syntax warning in test imports)
|
|
||||||
- **`deno task check`**: Passed
|
|
||||||
- **`deno test -A --no-check`**: Passed (68 tests across 30 steps)
|
|
||||||
|
|
||||||
## 2. Structural & Architectural Verification
|
|
||||||
- **Directory Placement:** `src/core/` successfully established with all requested single-purpose modules (`db.ts`, `valkey.ts`, `spire_ffi.ts`, `auth_guards.ts`, `content_negotiation.ts`, `sse_adapter.ts`, `error_fragments.tsx`, `main.ts`).
|
|
||||||
- **Legacy Quarantine:** Jules did NOT modify any files in the read-only `server/` or `ui/` directories.
|
|
||||||
- **Dependency Invariants:** `sdk/` and `spire_ffi/` were kept isolated.
|
|
||||||
- **Architectural Linting Hook:** `scripts/lint_arch.ts` successfully wired. It correctly rejects banned DOM APIs (`getElementById`, `querySelector`, `createElement`) and `dangerouslySetInnerHTML`.
|
|
||||||
|
|
||||||
## 3. Code Hygiene & Rule Alignment
|
|
||||||
- **Flat Call Chains:** Confirmed. Functions in `src/core/auth_guards.ts` (e.g., `rateLimitGuard`, `payloadCapGuard`) are shallow and self-contained.
|
|
||||||
- **Datastar SSE Typing:** `sse_adapter.ts` correctly isolates raw protocol strings from route handlers.
|
|
||||||
- **Error Handling:** `error_fragments.tsx` conforms to the invariant to return target-directed HTML toasts instead of JSON errors.
|
|
||||||
|
|
||||||
## 4. Issues Addressed
|
|
||||||
1. Jules forgot to run a final `deno fmt` on two newly created files. (Fixed)
|
|
||||||
2. Jules included an import type issue caught by the standard linter. (Fixed via `deno lint --fix`)
|
|
||||||
3. Jules eagerly moved the Task Plan to `tasks/complete/`. Because this is a 5-phase migration, I moved it to `tasks/in-progress/` to maintain continuity.
|
|
||||||
|
|
||||||
## 5. Go / No-Go Decision
|
|
||||||
**Decision: GO.** 🟢
|
|
||||||
Phase 1 is strictly verified, cleanly isolated in `src/`, and the repository quality gates are 100% green. We are cleared to commence Phase 2.
|
|
||||||
Loading…
x
Reference in New Issue
Block a user