feat: execute Phase 1 architectural hypermedia framework
- Bootstraps `src/core/` foundation (`db.ts`, `valkey.ts`, `spire_ffi.ts`, `main.ts`). - Adds `auth_guards.ts` for payload capping, CSRF check, and rate limiting. - Adds `content_negotiation.ts` and `sse_adapter.ts` for Datastar transport helpers. - Adds `error_fragments.tsx` for standardized Datastar error morphs. - Introduces `scripts/lint_arch.ts` to block imperative DOM usage. - Updates `deno.json` with src workspace configs and lint commands. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
f5c44f8ac5
commit
a613ed2b68
@ -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"
|
||||
},
|
||||
|
||||
22
deno.lock
generated
22
deno.lock
generated
@ -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"
|
||||
|
||||
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