Compare commits
1 Commits
main
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
| fe7a7688d6 |
187
src/core/audit.ts
Normal file
187
src/core/audit.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { encodeBase64 } from "jsr:@std/encoding@1/base64";
|
||||||
|
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
||||||
|
import { sqlWrapper } from "./db.ts";
|
||||||
|
import { buildMerkleTree, leafHash } from "./audit_merkle.ts";
|
||||||
|
import { fetchSpiffeIdentity } from "./spire_ffi.ts";
|
||||||
|
import { valkey } from "./valkey.ts";
|
||||||
|
|
||||||
|
let batcherIntervalId: number | null = null;
|
||||||
|
let isFlushing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIDE EFFECT: Asynchronously logs an audit record to the database.
|
||||||
|
* Does not block the main execution thread. Errors are logged but swallowed
|
||||||
|
* to prevent failing the core request due to a logging issue.
|
||||||
|
*/
|
||||||
|
export let auditLog = function auditLog(
|
||||||
|
userId: string | null,
|
||||||
|
action: string,
|
||||||
|
resource: string | null,
|
||||||
|
details: Record<string, unknown> | null,
|
||||||
|
ipAddress: string,
|
||||||
|
): void {
|
||||||
|
// Fire and forget
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const entryObj = {
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
details,
|
||||||
|
ipAddress,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const entryStr = JSON.stringify(entryObj);
|
||||||
|
const entryBytes = new TextEncoder().encode(entryStr);
|
||||||
|
const hashBuffer = await leafHash(entryBytes);
|
||||||
|
const leafHashHex = encodeHex(hashBuffer);
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO audit_records (user_id, action, resource, details, ip_address, leaf_hash)
|
||||||
|
VALUES (${userId}, ${action}, ${resource}, ${
|
||||||
|
details ? JSON.stringify(details) : null
|
||||||
|
}, ${ipAddress}, ${leafHashHex})
|
||||||
|
`;
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg = error?.code || error?.message || String(error);
|
||||||
|
console.error(`[Audit Logger] Failed to insert audit record: ${msg}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function flush(): Promise<void> {
|
||||||
|
if (isFlushing) return;
|
||||||
|
isFlushing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sthResult = await sqlWrapper.sql`
|
||||||
|
SELECT tree_size FROM audit_sths ORDER BY tree_size DESC LIMIT 1
|
||||||
|
`;
|
||||||
|
const lastTreeSize = sthResult.length > 0
|
||||||
|
? parseInt(sthResult[0].tree_size, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const allRecordsResult = await sqlWrapper.sql`
|
||||||
|
SELECT leaf_hash FROM audit_records ORDER BY created_at ASC, id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const currentTreeSize = allRecordsResult.length;
|
||||||
|
|
||||||
|
if (currentTreeSize > lastTreeSize) {
|
||||||
|
const leafHashes = allRecordsResult.map((row: any) => {
|
||||||
|
const hex = row.leaf_hash;
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootHashBytes = await buildMerkleTree(leafHashes);
|
||||||
|
const rootHashHex = encodeHex(rootHashBytes);
|
||||||
|
|
||||||
|
let svidData;
|
||||||
|
try {
|
||||||
|
svidData = await fetchSpiffeIdentity();
|
||||||
|
} catch (_e) {
|
||||||
|
svidData = { x509_svid_key: new Uint8Array() };
|
||||||
|
}
|
||||||
|
|
||||||
|
let signatureBase64 = "";
|
||||||
|
if (svidData.x509_svid_key && svidData.x509_svid_key.length > 0) {
|
||||||
|
try {
|
||||||
|
const keyBuffer = svidData.x509_svid_key.buffer as ArrayBuffer;
|
||||||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
keyBuffer,
|
||||||
|
{ name: "ECDSA", namedCurve: "P-256" },
|
||||||
|
true,
|
||||||
|
["sign"],
|
||||||
|
).catch(() =>
|
||||||
|
crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
keyBuffer,
|
||||||
|
{ name: "Ed25519" },
|
||||||
|
true,
|
||||||
|
["sign"],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const payloadBytes = new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
tree_size: currentTreeSize,
|
||||||
|
root_hash: rootHashHex,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let signAlgo: any = { name: "ECDSA", hash: "SHA-256" };
|
||||||
|
if (privateKey.algorithm.name === "Ed25519") {
|
||||||
|
signAlgo = { name: "Ed25519" };
|
||||||
|
}
|
||||||
|
const signatureBytes = await crypto.subtle.sign(
|
||||||
|
signAlgo,
|
||||||
|
privateKey,
|
||||||
|
payloadBytes,
|
||||||
|
);
|
||||||
|
signatureBase64 = encodeBase64(new Uint8Array(signatureBytes));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[Audit Batcher] Failed to import key or sign, using empty signature.",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO audit_sths (tree_size, root_hash, signature)
|
||||||
|
VALUES (${currentTreeSize}, ${rootHashHex}, ${signatureBase64})
|
||||||
|
`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
tree_size: currentTreeSize,
|
||||||
|
root_hash: rootHashHex,
|
||||||
|
signature: signatureBase64,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadStr = JSON.stringify(payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof valkey.set === "function") {
|
||||||
|
await valkey.set("auth:audit:latest_sth", payloadStr);
|
||||||
|
}
|
||||||
|
if (typeof valkey.publish === "function") {
|
||||||
|
await valkey.publish("auth:audit:sth", payloadStr);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Audit Batcher] Valkey broadcast failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Audit Batcher] Error during flush:", error);
|
||||||
|
} finally {
|
||||||
|
isFlushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startMicroBatcher(): void {
|
||||||
|
if (batcherIntervalId === null) {
|
||||||
|
batcherIntervalId = setInterval(flush, 1000) as unknown as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMicroBatcher(): void {
|
||||||
|
if (batcherIntervalId !== null) {
|
||||||
|
clearInterval(batcherIntervalId);
|
||||||
|
batcherIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditWrapper = {
|
||||||
|
get auditLog() {
|
||||||
|
return auditLog;
|
||||||
|
},
|
||||||
|
set auditLog(val: any) {
|
||||||
|
auditLog = val;
|
||||||
|
},
|
||||||
|
};
|
||||||
72
src/core/audit_merkle.ts
Normal file
72
src/core/audit_merkle.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
export async function leafHash(entry_bytes: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const data = new Uint8Array(1 + entry_bytes.length);
|
||||||
|
data[0] = 0x00;
|
||||||
|
data.set(entry_bytes, 1);
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
return new Uint8Array(hashBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nodeHash(
|
||||||
|
left: Uint8Array,
|
||||||
|
right: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const data = new Uint8Array(1 + left.length + right.length);
|
||||||
|
data[0] = 0x01;
|
||||||
|
data.set(left, 1);
|
||||||
|
data.set(right, 1 + left.length);
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
return new Uint8Array(hashBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildMerkleTree(
|
||||||
|
leaves: Uint8Array[],
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (leaves.length === 0) {
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", new Uint8Array(0));
|
||||||
|
return new Uint8Array(hashBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaves.length === 1) {
|
||||||
|
return leaves[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = Math.pow(2, Math.floor(Math.log2(leaves.length - 1)));
|
||||||
|
const leftHash = await buildMerkleTree(leaves.slice(0, k));
|
||||||
|
const rightHash = await buildMerkleTree(leaves.slice(k));
|
||||||
|
|
||||||
|
return await nodeHash(leftHash, rightHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyInclusionProof(
|
||||||
|
leaf: Uint8Array,
|
||||||
|
proof: Uint8Array[],
|
||||||
|
index: number,
|
||||||
|
treeSize: number,
|
||||||
|
expectedRoot: Uint8Array,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let currentHash = leaf;
|
||||||
|
let currentIndex = index;
|
||||||
|
let right = treeSize - 1;
|
||||||
|
|
||||||
|
for (const siblingHash of proof) {
|
||||||
|
if (currentIndex % 2 === 1) {
|
||||||
|
currentHash = await nodeHash(siblingHash, currentHash);
|
||||||
|
} else {
|
||||||
|
if (currentIndex === right) {
|
||||||
|
currentHash = await nodeHash(siblingHash, currentHash);
|
||||||
|
} else {
|
||||||
|
currentHash = await nodeHash(currentHash, siblingHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentIndex = Math.floor(currentIndex / 2);
|
||||||
|
right = Math.floor(right / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHashHex = Array.from(currentHash).map((b) =>
|
||||||
|
b.toString(16).padStart(2, "0")
|
||||||
|
).join("");
|
||||||
|
const expectedRootHex = Array.from(expectedRoot).map((b) =>
|
||||||
|
b.toString(16).padStart(2, "0")
|
||||||
|
).join("");
|
||||||
|
return currentHashHex === expectedRootHex;
|
||||||
|
}
|
||||||
84
src/core/middleware.ts
Normal file
84
src/core/middleware.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type { Context } from "jsr:@hono/hono@4";
|
||||||
|
import { getAuthenticatedUser } from "./session.ts";
|
||||||
|
import { valkey } from "./valkey.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts client IP from headers, checking X-Real-IP and X-Forwarded-For.
|
||||||
|
*/
|
||||||
|
export function getClientIp(c: Context): string {
|
||||||
|
const realIp = c.req.header("x-real-ip");
|
||||||
|
if (realIp) {
|
||||||
|
return realIp.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
let forwardedFor = c.req.header("x-forwarded-for");
|
||||||
|
if (forwardedFor) {
|
||||||
|
if (forwardedFor.length > 256) {
|
||||||
|
forwardedFor = forwardedFor.substring(0, 256);
|
||||||
|
}
|
||||||
|
const parts = forwardedFor.split(",");
|
||||||
|
return parts[parts.length - 1].trim();
|
||||||
|
}
|
||||||
|
return "unknown-ip";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
limit: number,
|
||||||
|
windowMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const current = await valkey.incr(key);
|
||||||
|
if (current === 1) {
|
||||||
|
await valkey.pexpire(key, windowMs);
|
||||||
|
}
|
||||||
|
return current <= limit;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[Rate Limiter] Valkey error:", err);
|
||||||
|
return true; // Fail open on cache error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rateLimitWrapper = {
|
||||||
|
checkRateLimit,
|
||||||
|
async isRateLimited(
|
||||||
|
key: string,
|
||||||
|
limit: number,
|
||||||
|
windowMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const allowed = await checkRateLimit(key, limit, windowMs);
|
||||||
|
return !allowed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const publicRateLimiter = async (
|
||||||
|
c: Context,
|
||||||
|
next: () => Promise<void>,
|
||||||
|
) => {
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `ratelimit:public:${ip}`;
|
||||||
|
const allowed = await checkRateLimit(key, 10, 60000);
|
||||||
|
if (!allowed) {
|
||||||
|
return c.json({ error: "Too Many Requests" }, 429);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminRateLimiter = async (
|
||||||
|
c: Context,
|
||||||
|
next: () => Promise<void>,
|
||||||
|
) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) {
|
||||||
|
return c.json({ error: "Missing or invalid session" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `ratelimit:admin:${auth.userId}`;
|
||||||
|
const allowed = await checkRateLimit(key, 60, 60000);
|
||||||
|
if (!allowed) {
|
||||||
|
return c.json({ error: "Too Many Requests" }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("userId", auth.userId);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
233
src/core/session.ts
Normal file
233
src/core/session.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import type { Context } from "jsr:@hono/hono@4";
|
||||||
|
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
|
import { sqlWrapper } from "./db.ts";
|
||||||
|
import { valkey } from "./valkey.ts";
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
username: string;
|
||||||
|
label?: string;
|
||||||
|
isAgent?: boolean;
|
||||||
|
isPaused?: boolean;
|
||||||
|
customScopes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domain?: string;
|
||||||
|
is_public?: boolean;
|
||||||
|
bypass_paths?: string[];
|
||||||
|
allowed_cidrs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all session_id tokens from the Cookie and Authorization headers.
|
||||||
|
*/
|
||||||
|
export function extractAllSessionIds(c: Context): string[] {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
const authHeader = c.req.header("authorization") || "";
|
||||||
|
if (authHeader.startsWith("Bearer ")) {
|
||||||
|
const bearerToken = authHeader.substring(7).trim();
|
||||||
|
if (bearerToken) candidates.push(bearerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieHeader = c.req.header("cookie") || "";
|
||||||
|
if (cookieHeader) {
|
||||||
|
const cookieMatches = [
|
||||||
|
...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g),
|
||||||
|
]
|
||||||
|
.map((m) => decodeURIComponent(m[1].trim()))
|
||||||
|
.filter(Boolean);
|
||||||
|
candidates.push(...cookieMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the authenticated user from Valkey cache or PostgreSQL sessions table.
|
||||||
|
*/
|
||||||
|
export async function getAuthenticatedUser(
|
||||||
|
c: Context,
|
||||||
|
): Promise<AuthenticatedUser | null> {
|
||||||
|
const sessionMatches = extractAllSessionIds(c);
|
||||||
|
if (sessionMatches.length === 0) return null;
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionMatches.length; i++) {
|
||||||
|
const candidateId = sessionMatches[i];
|
||||||
|
|
||||||
|
// 1. Try Valkey cache
|
||||||
|
try {
|
||||||
|
const sessionDataStr = await valkey.get(candidateId);
|
||||||
|
if (sessionDataStr) {
|
||||||
|
const sessionData = JSON.parse(sessionDataStr);
|
||||||
|
if (sessionData && sessionData.uuid) {
|
||||||
|
if (i > 0) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: sessionData.uuid,
|
||||||
|
sessionId: candidateId,
|
||||||
|
username: sessionData.username || "",
|
||||||
|
label: sessionData.label,
|
||||||
|
isAgent: sessionData.isAgent,
|
||||||
|
isPaused: sessionData.is_paused,
|
||||||
|
customScopes: sessionData.customScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
// Valkey cache miss or connection hiccup - fallback to DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to PostgreSQL sessions table
|
||||||
|
try {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const session = await sqlWrapper.sql`
|
||||||
|
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.is_paused, s.custom_scopes, u.username
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
|
||||||
|
`.then((res: any) => res[0]);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
const username = session.username || "";
|
||||||
|
try {
|
||||||
|
const ttlSeconds = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await valkey.setex(
|
||||||
|
candidateId,
|
||||||
|
ttlSeconds,
|
||||||
|
JSON.stringify({
|
||||||
|
uuid: session.user_id,
|
||||||
|
username,
|
||||||
|
label: session.label,
|
||||||
|
isAgent: session.is_agent,
|
||||||
|
is_paused: session.is_paused,
|
||||||
|
customScopes: session.custom_scopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: session.user_id,
|
||||||
|
sessionId: candidateId,
|
||||||
|
username,
|
||||||
|
label: session.label,
|
||||||
|
isAgent: session.is_agent,
|
||||||
|
isPaused: session.is_paused,
|
||||||
|
customScopes: session.custom_scopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
// Continue to next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user has a global admin role.
|
||||||
|
*/
|
||||||
|
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
SELECT r.name FROM user_roles ur
|
||||||
|
JOIN roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = ${userId} AND r.name = 'admin'
|
||||||
|
`;
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates if the current user's capabilities satisfy the required scope.
|
||||||
|
*/
|
||||||
|
export function hasScope(
|
||||||
|
auth: AuthenticatedUser,
|
||||||
|
requiredScope: string,
|
||||||
|
): boolean {
|
||||||
|
if (!auth.isAgent) return true;
|
||||||
|
if (!Array.isArray(auth.customScopes)) return false;
|
||||||
|
return auth.customScopes.includes("*") ||
|
||||||
|
auth.customScopes.includes(requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if the session itself is authorized as an admin.
|
||||||
|
*/
|
||||||
|
export async function isSessionAdmin(
|
||||||
|
auth: AuthenticatedUser,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const globalAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
if (!globalAdmin) return false;
|
||||||
|
if (!auth.isAgent) return true;
|
||||||
|
return Array.isArray(auth.customScopes) && auth.customScopes.includes("*");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes root cookie domain from RP_ID or host.
|
||||||
|
*/
|
||||||
|
export function getCookieDomain(customRpId?: string): string {
|
||||||
|
const rpId = customRpId || Deno.env.get("RP_ID");
|
||||||
|
if (rpId) {
|
||||||
|
if (rpId.includes("localhost") || rpId.includes("127.0.0.1")) {
|
||||||
|
return rpId;
|
||||||
|
}
|
||||||
|
return `.${rpId}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono Middleware: Blocks access if the session is a delegated agent session.
|
||||||
|
*/
|
||||||
|
export async function requirePrimarySession(
|
||||||
|
c: Context,
|
||||||
|
next: () => Promise<void>,
|
||||||
|
) {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
if (auth.isAgent) {
|
||||||
|
return c.json({ error: "Forbidden: Primary session required" }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono Middleware Factory: Requires a specific scope.
|
||||||
|
*/
|
||||||
|
export function requireScope(scope: string) {
|
||||||
|
return async (c: Context, next: () => Promise<void>) => {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
if (!hasScope(auth, scope)) {
|
||||||
|
return c.json({ error: "Forbidden: Insufficient scopes" }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono Middleware: Blocks access unless the session is an admin session.
|
||||||
|
*/
|
||||||
|
export async function requireAdmin(c: Context, next: () => Promise<void>) {
|
||||||
|
const auth = await getAuthenticatedUser(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
const isAdmin = await isSessionAdmin(auth);
|
||||||
|
if (!isAdmin) {
|
||||||
|
return c.json({ error: "Forbidden: Global admin access required" }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
}
|
||||||
@ -1,13 +1,10 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||||
|
|
||||||
import { adminRateLimiter, getClientIp } from "../../../server/middleware.ts";
|
import { adminRateLimiter, getClientIp } from "../../core/middleware.ts";
|
||||||
import {
|
import { getAuthenticatedUser, requireAdmin } from "../../core/session.ts";
|
||||||
getAuthenticatedUser,
|
|
||||||
requireAdmin,
|
|
||||||
} from "../../../server/auth-session.ts";
|
|
||||||
import { valkey } from "../../core/valkey.ts";
|
import { valkey } from "../../core/valkey.ts";
|
||||||
import { auditWrapper } from "../../../server/audit.ts";
|
import { auditWrapper } from "../../core/audit.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assignUserGrant,
|
assignUserGrant,
|
||||||
|
|||||||
@ -8,9 +8,9 @@ import {
|
|||||||
import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||||
|
|
||||||
import { valkey } from "../../core/valkey.ts";
|
import { valkey } from "../../core/valkey.ts";
|
||||||
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
|
import { getClientIp, publicRateLimiter } from "../../core/middleware.ts";
|
||||||
import { extractAllSessionIds } from "../../../server/auth-session.ts";
|
import { extractAllSessionIds } from "../../core/session.ts";
|
||||||
import { auditWrapper } from "../../../server/audit.ts";
|
import { auditWrapper } from "../../core/audit.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import {
|
|||||||
} from "jsr:@simplewebauthn/server@13";
|
} from "jsr:@simplewebauthn/server@13";
|
||||||
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||||
|
|
||||||
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
|
import { getClientIp, publicRateLimiter } from "../../core/middleware.ts";
|
||||||
import { auditWrapper } from "../../../server/audit.ts";
|
import { auditWrapper } from "../../core/audit.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindPasskey,
|
bindPasskey,
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import {
|
|||||||
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||||
|
|
||||||
import { valkey } from "../../core/valkey.ts";
|
import { valkey } from "../../core/valkey.ts";
|
||||||
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
|
import { getClientIp, publicRateLimiter } from "../../core/middleware.ts";
|
||||||
import { auditWrapper } from "../../../server/audit.ts";
|
import { auditWrapper } from "../../core/audit.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createPasskey,
|
createPasskey,
|
||||||
|
|||||||
@ -4,13 +4,12 @@ import {
|
|||||||
getCookieDomain,
|
getCookieDomain,
|
||||||
hasScope,
|
hasScope,
|
||||||
isGlobalAdmin,
|
isGlobalAdmin,
|
||||||
} from "../../../server/auth-session.ts";
|
} from "../../core/session.ts";
|
||||||
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
||||||
import { getClientIp } from "../../../server/middleware.ts";
|
import { getClientIp, rateLimitWrapper } from "../../core/middleware.ts";
|
||||||
import { valkey } from "../../core/valkey.ts";
|
import { valkey } from "../../core/valkey.ts";
|
||||||
import { auditWrapper } from "../../../server/audit.ts";
|
import { auditWrapper } from "../../core/audit.ts";
|
||||||
import { rateLimitWrapper } from "../../../server/ratelimit.ts";
|
|
||||||
import { streamDatastar } from "../../core/sse_adapter.ts";
|
import { streamDatastar } from "../../core/sse_adapter.ts";
|
||||||
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
||||||
import * as Queries from "./queries.ts";
|
import * as Queries from "./queries.ts";
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
|
|
||||||
import { valkey } from "../../core/valkey.ts";
|
import { valkey } from "../../core/valkey.ts";
|
||||||
import { auditWrapper } from "../../../server/audit.ts";
|
import { auditWrapper } from "../../core/audit.ts";
|
||||||
import {
|
import { getClientIp } from "../../core/middleware.ts";
|
||||||
getAuthenticatedUser,
|
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
||||||
getClientIp,
|
|
||||||
hasScope,
|
|
||||||
} from "../../../server/auth-session.ts";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
delegateSession,
|
delegateSession,
|
||||||
|
|||||||
@ -5,10 +5,7 @@ import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
|||||||
import {
|
import {
|
||||||
AuthenticatedLayoutFragment,
|
AuthenticatedLayoutFragment,
|
||||||
} from "../../shared/ui/layout_fragments.tsx";
|
} from "../../shared/ui/layout_fragments.tsx";
|
||||||
import {
|
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
||||||
getAuthenticatedUser,
|
|
||||||
hasScope,
|
|
||||||
} from "../../../server/auth-session.ts";
|
|
||||||
import { getAllApps } from "../admin/queries.ts";
|
import { getAllApps } from "../admin/queries.ts";
|
||||||
|
|
||||||
import { getActiveSessions } from "./queries.ts";
|
import { getActiveSessions } from "./queries.ts";
|
||||||
|
|||||||
@ -2,36 +2,62 @@
|
|||||||
|
|
||||||
## 1. Test Suite & Verification
|
## 1. Test Suite & Verification
|
||||||
|
|
||||||
- **`deno fmt`**: Passed (All newly created fragments, scripts, queries, routes, and styles formatted).
|
- **`deno fmt`**: Passed (All newly created fragments, scripts, queries, routes,
|
||||||
- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed with 0 errors; all files strictly $\le 400$ lines with no banned DOM API regressions).
|
and styles formatted).
|
||||||
- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`, `ui/`, `infra/`, `src/`).
|
- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed
|
||||||
- **`deno test -A --no-check`**: Passed (80 tests across 30 steps with 0 failures).
|
with 0 errors; all files strictly $\le 400$ lines with no banned DOM API
|
||||||
|
regressions).
|
||||||
|
- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`,
|
||||||
|
`ui/`, `infra/`, `src/`).
|
||||||
|
- **`deno test -A --no-check`**: Passed (80 tests across 30 steps with 0
|
||||||
|
failures).
|
||||||
|
|
||||||
## 2. Scope Implemented & Verified
|
## 2. Scope Implemented & Verified
|
||||||
|
|
||||||
1. **Events Vertical Slice (`src/features/events/`):**
|
1. **Events Vertical Slice (`src/features/events/`):**
|
||||||
- `cockpit_fragments.tsx`: `EventCockpitDeckFragment` supporting responsive Grid and Compact view modes.
|
- `cockpit_fragments.tsx`: `EventCockpitDeckFragment` supporting responsive
|
||||||
- `cockpit_styles.ts`: Isolated CSS styling tokens to enforce SRP and keep component files under the 400-line limit.
|
Grid and Compact view modes.
|
||||||
- `drawer_fragments.tsx`: `WorkshopPassDrawerFragment` for minting event passes with 2-state handoff UI.
|
- `cockpit_styles.ts`: Isolated CSS styling tokens to enforce SRP and keep
|
||||||
- `attendees_fragments.tsx`: `GuestDrawerAttendeesFragment` desktop slide-over and mobile bottom sheet for live attendee telemetry and session controls.
|
component files under the 400-line limit.
|
||||||
- `join_fragments.tsx`: `EventJoinPageFragment` for universal PIN and slug redemption.
|
- `drawer_fragments.tsx`: `WorkshopPassDrawerFragment` for minting event
|
||||||
- `queries.ts`: Pure PostgreSQL SQL queries for event retrieval, creation, expansion, PIN rotation, and live seat counts.
|
passes with 2-state handoff UI.
|
||||||
- `routes.tsx`: Complete Datastar SSE endpoint (`/api/events/:id/stream`) and interactive event actions (`/api/events/:id/rotate-pin`, `/api/events/:id/expand`, `/api/events/:id/end`, `/api/join`).
|
- `attendees_fragments.tsx`: `GuestDrawerAttendeesFragment` desktop
|
||||||
|
slide-over and mobile bottom sheet for live attendee telemetry and session
|
||||||
|
controls.
|
||||||
|
- `join_fragments.tsx`: `EventJoinPageFragment` for universal PIN and slug
|
||||||
|
redemption.
|
||||||
|
- `queries.ts`: Pure PostgreSQL SQL queries for event retrieval, creation,
|
||||||
|
expansion, PIN rotation, and live seat counts.
|
||||||
|
- `routes.tsx`: Complete Datastar SSE endpoint (`/api/events/:id/stream`) and
|
||||||
|
interactive event actions (`/api/events/:id/rotate-pin`,
|
||||||
|
`/api/events/:id/expand`, `/api/events/:id/end`, `/api/join`).
|
||||||
- `events.test.ts`: Verified public join route and error handling fragments.
|
- `events.test.ts`: Verified public join route and error handling fragments.
|
||||||
2. **Sessions Vertical Slice (`src/features/sessions/`):**
|
2. **Sessions Vertical Slice (`src/features/sessions/`):**
|
||||||
- `table_fragments.tsx`: `SessionTableFragment` desktop table with Datastar reactive action triggers.
|
- `table_fragments.tsx`: `SessionTableFragment` desktop table with Datastar
|
||||||
- `deck_fragments.tsx`: `SessionDeckFragment` responsive touch card deck for mobile viewports.
|
reactive action triggers.
|
||||||
- `drawer_fragments.tsx`: `DirectPassDrawerFragment` for minting scoped child agent passes.
|
- `deck_fragments.tsx`: `SessionDeckFragment` responsive touch card deck for
|
||||||
- `modal_fragments.tsx`: `ScopeModalFragment` for interactive permissions editing.
|
mobile viewports.
|
||||||
- `queries.ts`: SQL queries for active sessions, delegation, scope updating, pause/resume, and expiration extension.
|
- `drawer_fragments.tsx`: `DirectPassDrawerFragment` for minting scoped child
|
||||||
- `actions_routes.ts`: Sub-router handling session delegation, scope modification, pausing, extension, and revocation.
|
agent passes.
|
||||||
- `routes.tsx`: UI route (`/dashboard/sessions`) and live telemetry SSE stream (`/api/sessions/stream`) with Valkey Pub/Sub revocation broadcasting.
|
- `modal_fragments.tsx`: `ScopeModalFragment` for interactive permissions
|
||||||
- `sessions.test.tsx`: Verified route protection, table, deck, drawer, and modal rendering.
|
editing.
|
||||||
|
- `queries.ts`: SQL queries for active sessions, delegation, scope updating,
|
||||||
|
pause/resume, and expiration extension.
|
||||||
|
- `actions_routes.ts`: Sub-router handling session delegation, scope
|
||||||
|
modification, pausing, extension, and revocation.
|
||||||
|
- `routes.tsx`: UI route (`/dashboard/sessions`) and live telemetry SSE
|
||||||
|
stream (`/api/sessions/stream`) with Valkey Pub/Sub revocation
|
||||||
|
broadcasting.
|
||||||
|
- `sessions.test.tsx`: Verified route protection, table, deck, drawer, and
|
||||||
|
modal rendering.
|
||||||
3. **Client Assets & Compatibility:**
|
3. **Client Assets & Compatibility:**
|
||||||
- `public/sessions-scripts.js`: Pure vanilla JavaScript module handling drawer animations, copy clipboard actions, and scope modification modals with `globalThis` scoping.
|
- `public/sessions-scripts.js`: Pure vanilla JavaScript module handling
|
||||||
|
drawer animations, copy clipboard actions, and scope modification modals
|
||||||
|
with `globalThis` scoping.
|
||||||
4. **App Wiring & Dual Remote Distribution:**
|
4. **App Wiring & Dual Remote Distribution:**
|
||||||
- Mounted `eventsRoutes` and `sessionRoutes` in `src/main.ts`.
|
- Mounted `eventsRoutes` and `sessionRoutes` in `src/main.ts`.
|
||||||
- Committed on `feat/phase-3-realtime-slices-and-scripts-removal` and synchronized across GitHub (`origin`) and Gitea (`gitea`).
|
- Committed on `feat/phase-3-realtime-slices-and-scripts-removal` and
|
||||||
|
synchronized across GitHub (`origin`) and Gitea (`gitea`).
|
||||||
- Created GitHub PR #58.
|
- Created GitHub PR #58.
|
||||||
|
|
||||||
## 3. Decision
|
## 3. Decision
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user