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 { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
|
||||
import { adminRateLimiter, getClientIp } from "../../../server/middleware.ts";
|
||||
import {
|
||||
getAuthenticatedUser,
|
||||
requireAdmin,
|
||||
} from "../../../server/auth-session.ts";
|
||||
import { adminRateLimiter, getClientIp } from "../../core/middleware.ts";
|
||||
import { getAuthenticatedUser, requireAdmin } from "../../core/session.ts";
|
||||
import { valkey } from "../../core/valkey.ts";
|
||||
import { auditWrapper } from "../../../server/audit.ts";
|
||||
import { auditWrapper } from "../../core/audit.ts";
|
||||
|
||||
import {
|
||||
assignUserGrant,
|
||||
|
||||
@ -8,9 +8,9 @@ import {
|
||||
import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { valkey } from "../../core/valkey.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
|
||||
import { extractAllSessionIds } from "../../../server/auth-session.ts";
|
||||
import { auditWrapper } from "../../../server/audit.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../../core/middleware.ts";
|
||||
import { extractAllSessionIds } from "../../core/session.ts";
|
||||
import { auditWrapper } from "../../core/audit.ts";
|
||||
|
||||
import {
|
||||
createSession,
|
||||
|
||||
@ -7,8 +7,8 @@ import {
|
||||
} from "jsr:@simplewebauthn/server@13";
|
||||
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
|
||||
import { auditWrapper } from "../../../server/audit.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../../core/middleware.ts";
|
||||
import { auditWrapper } from "../../core/audit.ts";
|
||||
|
||||
import {
|
||||
bindPasskey,
|
||||
|
||||
@ -8,8 +8,8 @@ import {
|
||||
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { valkey } from "../../core/valkey.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
|
||||
import { auditWrapper } from "../../../server/audit.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../../core/middleware.ts";
|
||||
import { auditWrapper } from "../../core/audit.ts";
|
||||
|
||||
import {
|
||||
createPasskey,
|
||||
|
||||
@ -4,13 +4,12 @@ import {
|
||||
getCookieDomain,
|
||||
hasScope,
|
||||
isGlobalAdmin,
|
||||
} from "../../../server/auth-session.ts";
|
||||
} from "../../core/session.ts";
|
||||
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
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 { auditWrapper } from "../../../server/audit.ts";
|
||||
import { rateLimitWrapper } from "../../../server/ratelimit.ts";
|
||||
import { auditWrapper } from "../../core/audit.ts";
|
||||
import { streamDatastar } from "../../core/sse_adapter.ts";
|
||||
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
||||
import * as Queries from "./queries.ts";
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
|
||||
import { valkey } from "../../core/valkey.ts";
|
||||
import { auditWrapper } from "../../../server/audit.ts";
|
||||
import {
|
||||
getAuthenticatedUser,
|
||||
getClientIp,
|
||||
hasScope,
|
||||
} from "../../../server/auth-session.ts";
|
||||
import { auditWrapper } from "../../core/audit.ts";
|
||||
import { getClientIp } from "../../core/middleware.ts";
|
||||
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
||||
|
||||
import {
|
||||
delegateSession,
|
||||
|
||||
@ -5,10 +5,7 @@ import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
||||
import {
|
||||
AuthenticatedLayoutFragment,
|
||||
} from "../../shared/ui/layout_fragments.tsx";
|
||||
import {
|
||||
getAuthenticatedUser,
|
||||
hasScope,
|
||||
} from "../../../server/auth-session.ts";
|
||||
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
||||
import { getAllApps } from "../admin/queries.ts";
|
||||
|
||||
import { getActiveSessions } from "./queries.ts";
|
||||
|
||||
@ -2,36 +2,62 @@
|
||||
|
||||
## 1. Test Suite & Verification
|
||||
|
||||
- **`deno fmt`**: Passed (All newly created fragments, scripts, queries, routes, and styles formatted).
|
||||
- **`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).
|
||||
- **`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).
|
||||
- **`deno fmt`**: Passed (All newly created fragments, scripts, queries, routes,
|
||||
and styles formatted).
|
||||
- **`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).
|
||||
- **`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
|
||||
|
||||
1. **Events Vertical Slice (`src/features/events/`):**
|
||||
- `cockpit_fragments.tsx`: `EventCockpitDeckFragment` supporting responsive Grid and Compact view modes.
|
||||
- `cockpit_styles.ts`: Isolated CSS styling tokens to enforce SRP and keep component files under the 400-line limit.
|
||||
- `drawer_fragments.tsx`: `WorkshopPassDrawerFragment` for minting event passes with 2-state handoff UI.
|
||||
- `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`).
|
||||
- `cockpit_fragments.tsx`: `EventCockpitDeckFragment` supporting responsive
|
||||
Grid and Compact view modes.
|
||||
- `cockpit_styles.ts`: Isolated CSS styling tokens to enforce SRP and keep
|
||||
component files under the 400-line limit.
|
||||
- `drawer_fragments.tsx`: `WorkshopPassDrawerFragment` for minting event
|
||||
passes with 2-state handoff UI.
|
||||
- `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.
|
||||
2. **Sessions Vertical Slice (`src/features/sessions/`):**
|
||||
- `table_fragments.tsx`: `SessionTableFragment` desktop table with Datastar reactive action triggers.
|
||||
- `deck_fragments.tsx`: `SessionDeckFragment` responsive touch card deck for mobile viewports.
|
||||
- `drawer_fragments.tsx`: `DirectPassDrawerFragment` for minting scoped child agent passes.
|
||||
- `modal_fragments.tsx`: `ScopeModalFragment` for interactive permissions 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.
|
||||
- `table_fragments.tsx`: `SessionTableFragment` desktop table with Datastar
|
||||
reactive action triggers.
|
||||
- `deck_fragments.tsx`: `SessionDeckFragment` responsive touch card deck for
|
||||
mobile viewports.
|
||||
- `drawer_fragments.tsx`: `DirectPassDrawerFragment` for minting scoped child
|
||||
agent passes.
|
||||
- `modal_fragments.tsx`: `ScopeModalFragment` for interactive permissions
|
||||
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:**
|
||||
- `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:**
|
||||
- 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.
|
||||
|
||||
## 3. Decision
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user