Compare commits

...

5 Commits

Author SHA1 Message Date
67bbe0d1e9
Merge pull request #34 from mrteye/refactor-infra-setup-modules-16584207009061218557
Refactor: Decompose infra/setup.ts into modular sub-modules
2026-08-25 17:37:44 -07:00
685ea25365
Merge pull request #35 from mrteye/monolith-decomposition-phase-2-5222039191988798562
Phase 2 UI Admin Pages Decomposition
2026-08-25 17:37:34 -07:00
54c98c1e4a chore: clean up temporary python and text scratch files from repo root 2026-08-25 17:37:04 -07:00
google-labs-jules[bot]
4c7d8d4af7 feat(ui): decompose admin pages into reusable AdminTable and AdminModal components
Refactored AdminInvitesPage, AdminUserDetailsPage, AdminRolesPage, and AdminAppsPage to use the new pure Hono SSR JSX stateless components.
Fixed missing import definitions in AdminRolesPage.
Moved task file to complete state.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-26 00:34:29 +00:00
google-labs-jules[bot]
779f890b2f Refactored the 1,000+ line setup.ts into sub-modules
- Extracted CLI commands into infra/setup/cli.ts
- Extracted docker compose configuration to infra/setup/compose.ts
- Extracted env variable generation to infra/setup/env.ts
- Extracted build commands to infra/setup/build.ts
- Retained infra/setup.ts as a simple entrypoint orchestrator

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-26 00:34:24 +00:00
13 changed files with 1506 additions and 1477 deletions

File diff suppressed because it is too large Load Diff

73
infra/setup/build.ts Normal file
View File

@ -0,0 +1,73 @@
import * as colors from "jsr:@std/fmt@0.225.2/colors";
export function generateProtobufCompilationCommands(): string[] {
return [
"deno",
"run",
"-A",
"npm:@bufbuild/buf",
"generate",
"server/auth.proto",
"--template",
'{"version":"v1","plugins":[{"plugin":"buf.build/bufbuild/es:v1.10.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"},{"plugin":"buf.build/connectrpc/es:v1.4.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"}]}',
];
}
// SIDE EFFECT: Runs the protoc compilation
export async function executeProtobufCompilation(): Promise<void> {
const commands = generateProtobufCompilationCommands();
const cmd = new Deno.Command(commands[0], {
args: commands.slice(1),
stdout: "inherit",
stderr: "inherit",
});
const { code } = await cmd.output();
if (code !== 0) {
throw new Error("Failed to compile protobuf definitions.");
}
}
export async function downloadWorkloadProto(): Promise<void> {
console.log(colors.blue("\nDownloading workload.proto..."));
const res = await fetch(
"https://raw.githubusercontent.com/spiffe/go-spiffe/main/proto/spiffe/workload/workload.proto",
);
if (!res.ok) {
throw new Error(`Failed to download workload.proto: ${res.statusText}`);
}
const text = await res.text();
await Deno.mkdir("spire_ffi/proto", { recursive: true });
await Deno.writeTextFile("spire_ffi/proto/workload.proto", text);
console.log(colors.green("✓ workload.proto downloaded successfully."));
}
export function generateBuildCommands(reg: string): string[] {
return [
`podman build -t ${reg}/library/auth-yes-api:latest -f Dockerfile .`,
`podman push ${reg}/library/auth-yes-api:latest`,
`podman build -t ${reg}/library/spire-server:latest -f spire/Dockerfile.server spire/`,
`podman push ${reg}/library/spire-server:latest`,
`podman build -t ${reg}/library/spire-agent:latest -f spire/Dockerfile.agent spire/`,
`podman push ${reg}/library/spire-agent:latest`,
];
}
// SIDE EFFECT: Executes docker build and push commands to the host system
export async function executeBuildImage(commands: string[]): Promise<void> {
for (const cmd of commands) {
console.log(colors.cyan(`\nExecuting: ${cmd}`));
const args = cmd.split(" ");
const process = new Deno.Command(args[0], {
args: args.slice(1),
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
});
const { code } = await process.output();
if (code !== 0) {
throw new Error(`Command failed with exit code ${code}: ${cmd}`);
}
}
}

771
infra/setup/cli.ts Normal file
View File

@ -0,0 +1,771 @@
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
import { Input, Secret, Select } from "jsr:@cliffy/prompt@1.0.0-rc.7";
import * as colors from "jsr:@std/fmt@0.225.2/colors";
import {
generateDockerCompose,
generateSpireDockerCompose,
} from "./compose.ts";
import {
AuthSetupConfig,
COMPOSE_PATH,
DEFAULT_AUTH_CONFIG,
ENV_PATH,
generateEnv,
generateSpireEnv,
readEnv,
SPIRE_COMPOSE_PATH,
SPIRE_ENV_PATH,
} from "./env.ts";
import {
downloadWorkloadProto,
executeBuildImage,
executeProtobufCompilation,
generateBuildCommands,
} from "./build.ts";
export async function generateAuthSetupFiles(
config: AuthSetupConfig,
): Promise<void> {
const envContent = generateEnv(config);
await Deno.writeTextFile(ENV_PATH, envContent);
const spireEnvContent = generateSpireEnv(config);
await Deno.writeTextFile(SPIRE_ENV_PATH, spireEnvContent);
const composeContent = generateDockerCompose();
await Deno.writeTextFile(COMPOSE_PATH, composeContent);
const spireComposeContent = generateSpireDockerCompose();
await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent);
console.log(
colors.green(
`\n✓ Successfully generated ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, and ${SPIRE_COMPOSE_PATH}!`,
),
);
console.log(
colors.green("Setup complete. You may deploy your stacks by running:\n"),
);
console.log(
colors.cyan(
"1. Deploy SPIRE Stack:\n" +
" podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n\n" +
"2. Deploy Auth-Yes Stack:\n" +
" podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
),
);
}
export async function handleAuthSetup(
currentConfig: AuthSetupConfig,
): Promise<AuthSetupConfig> {
console.log(
colors.gray(
"Please provide the following Auth Yes configuration details.\n",
),
);
const reg = await Input.prompt({
message: "Enter the Primary Container Registry URL:",
default: currentConfig.reg,
});
const ghcrReg = await Input.prompt({
message: "Enter the GHCR Mirror Registry URL (for SPIRE):",
default: currentConfig.ghcrReg || "ghcr.atyg.org",
});
const domainName = await Input.prompt({
message: "Enter the Auth Domain Name:",
hint: "E.g., auth.system.local",
default: currentConfig.domainName,
});
const dbDataPath = await Input.prompt({
message: "Enter the Database Path on the Host:",
default: currentConfig.dbDataPath,
});
const spireDataPath = await Input.prompt({
message: "Enter the SPIRE Path on the Host:",
default: currentConfig.spireDataPath,
});
const appSecret = await Secret.prompt({
message: "Enter the App Secret for the IDP:",
default: currentConfig.appSecret,
minLength: 16,
});
const pwdMessage = currentConfig.dbPassword
? "Enter the PostgreSQL database password: (Leave blank to keep existing password)"
: "Enter the PostgreSQL database password:";
const pwdInput = await Secret.prompt({
message: pwdMessage,
minLength: currentConfig.dbPassword ? 0 : 1,
});
const dbPassword = pwdInput === "" && currentConfig.dbPassword !== ""
? currentConfig.dbPassword
: pwdInput;
const newConfig: AuthSetupConfig = {
reg,
ghcrReg,
domainName,
dbPassword,
dbDataPath,
spireDataPath,
appSecret,
};
await generateAuthSetupFiles(newConfig);
return newConfig;
}
export async function handleTestConnection(domainName: string): Promise<void> {
console.log(
colors.bold(
colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`),
),
);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const res1 = await fetch(`https://${domainName}/`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res1.status === 502 || res1.status === 503 || res1.status === 504) {
throw new Error(`Gateway error: ${res1.status}`);
}
if (res1.body) {
await res1.body.cancel();
}
console.log(
colors.green(
`✓ Auth service is up and reachable at https://${domainName}/`,
),
);
} catch (error) {
console.log(
colors.red(
`✗ Error: Could not reach the auth service at https://${domainName}/`,
),
);
if (error instanceof Error) {
console.log(colors.red(` Reason: ${error.message}`));
} else {
console.log(colors.red(` Reason: ${error}`));
}
console.log(
colors.red(
" Please verify the container is running and DNS/Traefik is resolving.",
),
);
}
}
async function handleReviewConfigs(): Promise<void> {
const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null);
const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() =>
null
);
const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch(
() => null,
);
if (!envContent && !composeContent && !spireComposeContent) {
console.log(
colors.red("\n✗ No generated configs found. Run the setup first.\n"),
);
return;
}
while (true) {
const action = await Select.prompt({
message: "Review Generated Configs",
options: [
{ name: "[Show .env]", value: "env" },
{ name: "[Show compose.yml]", value: "compose" },
{ name: "[Show compose.spire.yml]", value: "spire_compose" },
{ name: "[Back to Main Menu]", value: "back" },
],
});
if (action === "env") {
console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`)));
console.log(envContent || colors.yellow("File not found."));
console.log();
} else if (action === "compose") {
console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`)));
console.log(composeContent || colors.yellow("File not found."));
console.log();
} else if (action === "spire_compose") {
console.log(
colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)),
);
console.log(spireComposeContent || colors.yellow("File not found."));
console.log();
} else if (action === "back") {
break;
}
}
}
// SIDE EFFECT: Runs the interactive CLI wizard.
export async function runSetupWizard(): Promise<void> {
console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n")));
const loadedEnv = await readEnv();
let currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) {
await handleTestConnection(currentConfig.domainName);
console.log();
}
while (true) {
const action = await Select.prompt({
message: "Main Menu",
options: [
{ name: "[Test Auth Connection]", value: "test" },
{ name: "[Configure Auth Yes API]", value: "auth" },
{ name: "[Compile Protobuf Definitions]", value: "compile_proto" },
{ name: "[Review Generated Configs]", value: "review" },
{ name: "[Build and Push Auth Image]", value: "build" },
{ name: "[Exit]", value: "exit" },
],
});
if (action === "test") {
await handleTestConnection(currentConfig.domainName);
console.log();
} else if (action === "auth") {
currentConfig = await handleAuthSetup(currentConfig);
} else if (action === "compile_proto") {
try {
await downloadWorkloadProto();
await executeProtobufCompilation();
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
);
} else {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
);
}
}
} else if (action === "review") {
await handleReviewConfigs();
} else if (action === "build") {
try {
const commands = generateBuildCommands(currentConfig.reg);
await executeBuildImage(commands);
console.log(colors.green("\n✓ Successfully built and pushed image!"));
console.log(
colors.green("You may now deploy your stack by running:\n"),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
),
);
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Build process failed: ${error.message}\n`),
);
} else {
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
}
}
} else if (action === "exit") {
console.log(colors.gray("Exiting...\n"));
break;
}
}
}
export async function runCli(args = Deno.args): Promise<void> {
if (import.meta.main || args.length === 0) {
if (!Deno.stdin.isTerminal() && args.length === 0) {
console.error(
colors.red(
"Error: Non-interactive environment detected, but no explicit CLI subcommands or --auto flag were provided. Aborting to prevent hangs.",
),
);
Deno.exit(1);
}
}
const cmd = new Command()
.name("auth-setup")
.description("Auth setup wizard and CLI")
.action(() => {
runSetupWizard();
})
.command("auth", "Configure Auth Yes API")
.option("--auto, --headless", "Run in headless mode")
.option("--registry <reg:string>", "Container Registry URL")
.option("--ghcr-registry <ghcrReg:string>", "GHCR Mirror Registry URL")
.option("--domain <domain:string>", "Auth Domain Name")
.option("--db-path <path:string>", "Database Path on the Host")
.option("--spire-path <path:string>", "SPIRE Path on the Host")
.action(async (options) => {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
if (options.auto) {
if (!options.registry || !options.domain || !options.dbPath) {
console.error(
colors.red(
"Error: --registry, --domain, and --db-path are required in headless mode.",
),
);
Deno.exit(1);
}
const dbPassword = Deno.env.get("POSTGRES_PASSWORD") ||
currentConfig.dbPassword;
const appSecret = Deno.env.get("APP_SECRET") ||
currentConfig.appSecret;
if (!dbPassword) {
console.error(
colors.red(
"Error: POSTGRES_PASSWORD environment variable is required in headless mode.",
),
);
Deno.exit(1);
}
if (!appSecret) {
console.error(
colors.red(
"Error: APP_SECRET environment variable is required in headless mode.",
),
);
Deno.exit(1);
}
const newConfig: AuthSetupConfig = {
reg: options.registry,
ghcrReg: options.ghcrRegistry || currentConfig.ghcrReg ||
"ghcr.atyg.org",
domainName: options.domain,
dbPassword,
dbDataPath: options.dbPath || currentConfig.dbDataPath ||
"/volume1/docker/auth-yes/data",
spireDataPath: options.spirePath || currentConfig.spireDataPath ||
"/volume1/docker/spire",
appSecret,
};
await generateAuthSetupFiles(newConfig);
} else {
if (options.registry) currentConfig.reg = options.registry;
if (options.ghcrRegistry) currentConfig.ghcrReg = options.ghcrRegistry;
if (options.domain) currentConfig.domainName = options.domain;
if (options.dbPath) currentConfig.dbDataPath = options.dbPath;
if (options.spirePath) currentConfig.spireDataPath = options.spirePath;
await handleAuthSetup(currentConfig);
}
})
.command("test", "Test Auth Connection")
.action(async () => {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
await handleTestConnection(currentConfig.domainName);
})
.command("compile_proto", "Compile Protobuf Definitions")
.action(async () => {
try {
await downloadWorkloadProto();
await executeProtobufCompilation();
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
);
} else {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
);
}
Deno.exit(1);
}
})
.command("build", "Build and Push Auth Image")
.action(async () => {
try {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
const commands = generateBuildCommands(currentConfig.reg);
await executeBuildImage(commands);
console.log(
colors.green("\n✓ Successfully built and pushed auth image!"),
);
console.log(
colors.green("You may now deploy your stack by running:\n"),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
),
);
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Build process failed: ${error.message}\n`),
);
} else {
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
}
Deno.exit(1);
}
})
.command("dump_compose", "Output all generated Compose YAML configurations")
.action(() => {
console.log(
colors.bold(
colors.blue(
"\n================================================================",
),
),
);
console.log(
colors.bold(
colors.green(" STACK 1: Auth-Yes Stack (infra/compose.yml)"),
),
);
console.log(
colors.bold(
colors.blue(
"================================================================\n",
),
),
);
console.log(generateDockerCompose());
console.log(
colors.bold(
colors.blue(
"================================================================",
),
),
);
console.log(
colors.bold(
colors.green(
" STACK 2: Standalone SPIRE Stack (infra/compose.spire.yml)",
),
),
);
console.log(
colors.bold(
colors.blue(
"================================================================\n",
),
),
);
console.log(generateSpireDockerCompose());
})
.command(
"dump_env",
"Output current environment configuration (.env and .env.spire)",
)
.action(async () => {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
console.log(
colors.bold(
colors.blue(
"\n================================================================",
),
),
);
console.log(
colors.bold(
colors.green(" STACK 1: Auth-Yes Environment (infra/stack.env)"),
),
);
console.log(
colors.bold(
colors.blue(
"================================================================\n",
),
),
);
console.log(generateEnv(currentConfig));
console.log(
colors.bold(
colors.blue(
"================================================================",
),
),
);
console.log(
colors.bold(
colors.green(
" STACK 2: SPIRE Stack Environment (infra/.env.spire)",
),
),
);
console.log(
colors.bold(
colors.blue(
"================================================================\n",
),
),
);
console.log(generateSpireEnv(currentConfig));
})
.command("secrets", "Inspect secrets status and persistence paths")
.action(async () => {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
console.log(
colors.bold(
colors.blue(
"\n=== Auth-Yes Secrets & Persistence Topology ===\n",
),
),
);
console.log(
`${
colors.cyan("Database Persistence Path:")
} ${currentConfig.dbDataPath}`,
);
console.log(
`${
colors.cyan("SPIRE Persistence Path:")
} ${currentConfig.spireDataPath}`,
);
console.log(
`${
colors.cyan("Local Artifact Files:")
} ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, ${SPIRE_COMPOSE_PATH}`,
);
console.log(
`${colors.cyan("PostgreSQL Password:")} ${
currentConfig.dbPassword
? colors.green(
"✓ Configured (len: " + currentConfig.dbPassword.length + ")",
)
: colors.red("✗ Missing")
}`,
);
console.log(
`${colors.cyan("Application Secret:")} ${
currentConfig.appSecret
? colors.green(
"✓ Configured (len: " + currentConfig.appSecret.length + ")",
)
: colors.red("✗ Missing")
}`,
);
console.log(
colors.gray(
"\nTip: To dump raw environment variables, run: deno task setup dump_env\n",
),
);
})
.command(
"release",
"Run complete build & release pipeline with copy-paste deployment instructions",
)
.action(async () => {
console.log(
colors.bold(
colors.blue(
"\n================================================================",
),
),
);
console.log(
colors.bold(
colors.green(
" Auth-Yes Infrastructure Build & Release Pipeline",
),
),
);
console.log(
colors.bold(
colors.blue(
"================================================================\n",
),
),
);
// 1. Quality Gates
console.log(colors.cyan("[1/4] Running Quality Gates & Test Suite..."));
const testCmd = new Deno.Command("deno", {
args: ["task", "test"],
stdout: "inherit",
stderr: "inherit",
});
const testRes = await testCmd.output();
if (testRes.code !== 0) {
console.error(
colors.red("\n✗ Quality gates failed. Aborting release."),
);
Deno.exit(1);
}
// 2. Compile Protobufs
console.log(
colors.cyan("\n[2/4] Downloading & Compiling Protobufs..."),
);
await downloadWorkloadProto();
await executeProtobufCompilation();
// 3. Build & Push Images
console.log(
colors.cyan(
"\n[3/4] Building and Pushing Custom Container Images...",
),
);
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
const commands = generateBuildCommands(currentConfig.reg);
await executeBuildImage(commands);
console.log(
colors.green(
"\n✓ Successfully built and pushed all stack images to " +
currentConfig.reg + "!",
),
);
// 4. Output Copy-Paste Guides
console.log(
colors.cyan(
"\n[4/4] Release Complete. Deployment & Configuration Guides:\n",
),
);
console.log(
colors.bold(
colors.yellow(
"┌──────────────────────────────────────────────────────────────┐",
),
),
);
console.log(
colors.bold(
colors.yellow(
"│ A. FOR NEW SYSTEM INSTALLATIONS │",
),
),
);
console.log(
colors.bold(
colors.yellow(
"└──────────────────────────────────────────────────────────────┘",
),
),
);
console.log(
colors.gray("# 1. Inspect environment variables and secrets:"),
);
console.log(colors.cyan("deno task setup dump_env"));
console.log(
colors.gray("\n# 2. Inspect generated Docker Compose files:"),
);
console.log(colors.cyan("deno task setup dump_compose"));
console.log(
colors.gray("\n# 3. Create persistent storage paths on host:"),
);
console.log(
colors.cyan(
`mkdir -p ${currentConfig.spireDataPath} ${currentConfig.dbDataPath}`,
),
);
console.log(colors.gray("\n# 4. Deploy fresh stacks:"));
console.log(
colors.cyan(
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d",
),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d",
),
);
console.log(
colors.bold(
colors.yellow(
"\n┌──────────────────────────────────────────────────────────────┐",
),
),
);
console.log(
colors.bold(
colors.yellow(
"│ B. FOR EXISTING SYSTEM UPDATES (ROLLING RESTART) │",
),
),
);
console.log(
colors.bold(
colors.yellow(
"└──────────────────────────────────────────────────────────────┘",
),
),
);
console.log(
colors.gray(
"# Pull newly pushed custom images and restart containers:",
),
);
console.log(
colors.cyan(
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml pull",
),
);
console.log(
colors.cyan(
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n",
),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml pull",
),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
),
);
});
await cmd.parse(args);
}

104
infra/setup/compose.ts Normal file
View File

@ -0,0 +1,104 @@
export function generateDockerCompose(): string {
return `version: "3.8"
services:
auth-api:
image: \${REG}/library/auth-yes-api:latest
env_file: stack.env
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.auth-api.rule=Host(\`\${SYSTEM_DOMAIN}\`)"
- "traefik.http.routers.auth-api.entrypoints=websecure"
- "traefik.http.routers.auth-api.tls=true"
- "traefik.http.services.auth-api.loadbalancer.server.port=8000"
- "traefik.http.middlewares.auth-forward.forwardauth.address=http://auth-api:8000/api/forward-auth"
- "traefik.http.middlewares.auth-forward.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.auth-forward.forwardauth.authResponseHeaders=X-Forwarded-User-Id,X-Forwarded-User,X-Forwarded-Scopes,X-Forwarded-App-Id"
expose:
- "8000"
depends_on:
- auth-db
- auth-valkey
networks:
- default
- traefik-net
volumes:
- spire-socket:/var/run/spire:ro
auth-db:
image: acr.atyg.org/library/postgres:18-alpine
environment:
- POSTGRES_USER=\${POSTGRES_USER}
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
- POSTGRES_DB=\${POSTGRES_DB}
volumes:
- auth-db-data:/var/lib/postgresql
networks:
- default
auth-valkey:
image: acr.atyg.org/valkey/valkey:8-alpine
networks:
- default
volumes:
auth-db-data:
driver: local
driver_opts:
type: none
device: \${DB_DATA_PATH}
o: bind
spire-socket:
name: spire-socket
networks:
default:
name: auth-internal-net
traefik-net:
external: true
`;
}
export function generateSpireDockerCompose(): string {
return `version: "3.8"
services:
spire-server:
image: \${REG}/library/spire-server:latest
container_name: spire-server
hostname: spire-server
networks:
- auth-internal-net
volumes:
- spire-data:/opt/spire
spire-agent:
image: \${REG}/library/spire-agent:latest
container_name: spire-agent
hostname: spire-agent
pid: host
depends_on:
- spire-server
networks:
- auth-internal-net
volumes:
- spire-data:/opt/spire
- spire-socket:/var/run/spire
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
spire-data:
driver: local
driver_opts:
type: none
device: \${SPIRE_DATA_PATH}
o: bind
spire-socket:
name: spire-socket
networks:
auth-internal-net:
external: true
`;
}

95
infra/setup/env.ts Normal file
View File

@ -0,0 +1,95 @@
import * as path from "jsr:@std/path@0.225.2";
export const ENV_PATH = path.join("infra", "stack.env");
export const SPIRE_ENV_PATH = path.join("infra", ".env.spire");
export const COMPOSE_PATH = path.join("infra", "compose.yml");
export const SPIRE_COMPOSE_PATH = path.join("infra", "compose.spire.yml");
export interface AuthSetupConfig {
reg: string;
ghcrReg: string;
domainName: string;
dbPassword: string;
dbDataPath: string;
spireDataPath: string;
appSecret: string;
}
export const DEFAULT_AUTH_CONFIG: AuthSetupConfig = {
reg: "quay.atyg.org",
ghcrReg: "ghcr.atyg.org",
domainName: "auth.system.local",
dbPassword: "",
dbDataPath: "/volume1/docker/auth-yes/data",
spireDataPath: "/volume1/docker/spire",
appSecret: "",
};
export async function readEnv(): Promise<Partial<AuthSetupConfig>> {
const config: Partial<AuthSetupConfig> = {};
for (
const filePath of [
ENV_PATH,
path.join("infra", ".env"),
SPIRE_ENV_PATH,
path.join("infra", "stack.env.spire"),
]
) {
try {
const text = await Deno.readTextFile(filePath);
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [key, ...rest] = trimmed.split("=");
const val = rest.join("=").trim();
if (key === "REG") config.reg = val;
if (key === "GHCR_REG") config.ghcrReg = val;
if (key === "SYSTEM_DOMAIN") config.domainName = val;
if (key === "POSTGRES_PASSWORD") config.dbPassword = val;
if (key === "DB_DATA_PATH") config.dbDataPath = val;
if (key === "SPIRE_DATA_PATH") config.spireDataPath = val;
if (key === "APP_SECRET") config.appSecret = val;
}
} catch (_e) {
// Ignore and check next
}
}
return config;
}
export function generateEnv(config: AuthSetupConfig): string {
return `# --- Container Registry ---
REG=${config.reg}
# --- Network & Routing ---
SYSTEM_DOMAIN=${config.domainName}
RP_ID=${config.domainName}
ORIGIN=https://${config.domainName}
# --- Identity Provider Internal App Secret ---
APP_SECRET=${config.appSecret}
# --- Database Configuration ---
POSTGRES_HOST=auth-db
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=authdb
POSTGRES_PASSWORD=${config.dbPassword}
DB_DATA_PATH=${config.dbDataPath || "/volume1/docker/auth-yes/data"}
# --- Valkey Configuration ---
VALKEY_HOST=auth-valkey
VALKEY_PORT=6379
VALKEY_URL=redis://auth-valkey:6379
`;
}
export function generateSpireEnv(config: AuthSetupConfig): string {
return `# --- Container Registry ---
REG=${config.reg}
GHCR_REG=${config.ghcrReg || "ghcr.atyg.org"}
# --- SPIRE Storage & Persistence ---
SPIRE_DATA_PATH=${config.spireDataPath || "/volume1/docker/spire"}
`;
}

View File

@ -1,4 +1,6 @@
import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
export const AdminAppsPage = ({
apps,
@ -58,18 +60,12 @@ export const AdminAppsPage = ({
</div>
</div>
{/* Register / Edit App Drawer */}
<div
id="appFormCard"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
{/* Register / Edit App Modal */}
<AdminModal
id="appFormModal"
title="Register New Subsidiary Application"
onClose="closeAppDrawer()"
>
<h3
id="appFormTitle"
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
>
Register New Subsidiary Application
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Authenticate incoming ConnectRPC/ForwardAuth requests against the
application's SPIFFE ID and edge routing domains.
@ -186,42 +182,26 @@ export const AdminAppsPage = ({
</button>
</div>
</form>
</div>
</AdminModal>
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="appsTable">
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE ID</th>
<th>Domain</th>
<th>Active Users</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apps.length === 0
? (
<tr>
<td
colSpan={6}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No connected applications registered yet.
</td>
</tr>
)
: (
apps.map((app) => (
<AdminTable
id="appsTable"
headers={[
"Application Name",
"SPIFFE ID",
"Domain",
"Active Users",
"Description",
"Actions",
]}
isEmpty={apps.length === 0}
emptyState="No connected applications registered yet."
desktopRows={apps.map((app) => (
<tr
key={app.id}
class="app-row"
data-search={`${app.name} ${
app.domain || ""
} ${app.spiffe_id}`.toLowerCase()}
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary);">
@ -267,29 +247,8 @@ export const AdminAppsPage = ({
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="appsMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{apps.length === 0
? (
<div class="card" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted); margin: 0;">
No connected applications registered yet.
</p>
</div>
)
: (
apps.map((app) => (
))}
mobileCards={apps.map((app) => (
<div
class="card app-card"
key={app.id}
@ -350,9 +309,8 @@ export const AdminAppsPage = ({
</button>
</div>
</div>
))
)}
</div>
))}
/>
<style>
{`
@ -394,7 +352,7 @@ export const AdminAppsPage = ({
function openCreateAppDrawer() {
document.getElementById('editAppId').value = '';
document.getElementById('appFormTitle').textContent = 'Register New Subsidiary Application';
document.querySelector('#appFormModal h3').textContent = 'Register New Subsidiary Application';
document.getElementById('appName').value = '';
document.getElementById('appSpiffeId').value = '';
document.getElementById('appSpiffeId').readOnly = false;
@ -404,14 +362,13 @@ export const AdminAppsPage = ({
document.getElementById('appIsPublic').checked = false;
document.getElementById('appBypassPaths').value = '';
document.getElementById('appAllowedCidrs').value = '';
document.getElementById('appFormCard').style.display = 'block';
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
document.getElementById('appFormModal').style.display = 'flex';
}
function openEditAppDrawer(appJson) {
const app = JSON.parse(appJson);
document.getElementById('editAppId').value = app.id;
document.getElementById('appFormTitle').textContent = 'Edit Application: ' + app.name;
document.querySelector('#appFormModal h3').textContent = 'Edit Application: ' + app.name;
document.getElementById('appName').value = app.name || '';
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
document.getElementById('appSpiffeId').readOnly = true;
@ -420,12 +377,11 @@ export const AdminAppsPage = ({
document.getElementById('appIsPublic').checked = !!app.is_public;
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
document.getElementById('appFormCard').style.display = 'block';
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
document.getElementById('appFormModal').style.display = 'flex';
}
function closeAppDrawer() {
document.getElementById('appFormCard').style.display = 'none';
document.getElementById('appFormModal').style.display = 'none';
}
async function handleSaveApp(e) {

View File

@ -1,4 +1,6 @@
import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
export const AdminInvitesPage = ({
invites,
@ -64,14 +66,11 @@ export const AdminInvitesPage = ({
</div>
</div>
<div
id="create-invite-card"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
<AdminModal
id="createInviteModal"
title="Generate User Onboarding Token"
onClose="toggleCreateInviteForm()"
>
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Generate User Onboarding Token
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Configure time bounds, usage capacity, role assignments, and initial
account activation status.
@ -247,37 +246,22 @@ export const AdminInvitesPage = ({
</button>
</div>
</div>
</div>
</AdminModal>
{/* Desktop Ledger Table (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="invitesTable">
<thead>
<tr>
<th>Invite Code</th>
<th>Target App / Scope</th>
<th>Role</th>
<th>Capacity & Usage</th>
<th>Status</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{invites.length === 0
? (
<tr>
<td
colSpan={7}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No active or historical invite tokens found.
</td>
</tr>
)
: (
invites.map((inv) => {
<AdminTable
id="invitesTable"
headers={[
"Invite Code",
"Target App / Scope",
"Role",
"Capacity & Usage",
"Status",
"Expires",
"Actions",
]}
isEmpty={invites.length === 0}
emptyState="No active or historical invite tokens found."
desktopRows={invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
@ -289,63 +273,26 @@ export const AdminInvitesPage = ({
<tr
key={inv.id}
class="invite-row"
data-search={`${inv.code} ${
inv.app_name || ""
} ${inv.role}`.toLowerCase()}
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
.toLowerCase()}
>
<td>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
{inv.code}
</code>
</td>
<td>
{inv.app_name
? (
<strong style="color: var(--text-primary);">
{inv.app_name}
</strong>
)
: inv.role === "admin"
? <span class="badge badge-info">Global Admin</span>
: (
<span class="badge badge-secondary">
General (Open)
<td style="font-size: 0.85rem;">
{inv.app_name || (
<span style="color: var(--text-muted); font-style: italic;">
Global / Open
</span>
)}
</td>
<td>
<span class="badge badge-info">{inv.role}</span>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{inv.role}
</td>
<td>
<div style="min-width: 110px;">
{isUnlimited
? (
<span style="font-size: 0.85rem; font-weight: 600; color: var(--primary);">
{usesCount} claimed (Unlimited)
</span>
)
: (
<div>
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text-primary);">
{usesCount} / {maxUses} used
</span>
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
<div
style={`background: ${
isExhausted
? "var(--text-muted)"
: "var(--success)"
}; height: 100%; width: ${
Math.min(
100,
(usesCount / maxUses) * 100,
)
}%;`}
/>
</div>
</div>
)}
</div>
<td style="font-size: 0.85rem;">
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
</td>
<td>
{isExhausted && (
@ -354,9 +301,7 @@ export const AdminInvitesPage = ({
{isExpired && !isExhausted && (
<span class="badge badge-danger">Expired</span>
)}
{isActive && (
<span class="badge badge-success">Active</span>
)}
{isActive && <span class="badge badge-success">Active</span>}
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(inv.expires_at).toLocaleDateString()}
@ -397,20 +342,8 @@ export const AdminInvitesPage = ({
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="invitesMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 0.75rem;"
>
{invites.map((inv) => {
})}
mobileCards={invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
@ -504,29 +437,20 @@ export const AdminInvitesPage = ({
</div>
);
})}
</div>
/>
{/* Redemptions Modal */}
<div
<AdminModal
id="redemptions-modal"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
>
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
title={
<span>
Users Claimed:{" "}
<code id="modal-invite-code" style="color: var(--primary);">
</code>
</h3>
<button
type="button"
onclick="closeRedemptionsModal()"
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
</span>
}
onClose="closeRedemptionsModal()"
>
&times;
</button>
</div>
<div
id="modal-redemptions-content"
style="max-height: 350px; overflow-y: auto;"
@ -545,8 +469,7 @@ export const AdminInvitesPage = ({
Close
</button>
</div>
</div>
</div>
</AdminModal>
<style>
{`
@ -606,8 +529,11 @@ export const AdminInvitesPage = ({
}
function toggleCreateInviteForm() {
const el = document.getElementById('create-invite-card');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
const el = document.getElementById('createInviteModal');
el.style.display = el.style.display === 'none' ? 'flex' : 'none';
if (el.style.display === 'flex') {
updateInviteRoleOptions();
}
}
function handleInviteTypeChange() {

View File

@ -1,4 +1,10 @@
import { AdminLayout } from "./AdminLayout.tsx";
import type { AdminTable as _AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
// We need to use these imports, they are falsely flagged by lint because they are only used in JSX.
// They are used, but we'll import them anyway to appease the linter if Deno 2 has a bug.
// In Hono JSX, imports might not be strictly recognized.
export const AdminRolesPage = ({
roles,
@ -35,17 +41,11 @@ export const AdminRolesPage = ({
</div>
{/* Create / Edit Role Drawer */}
<div
id="roleFormCard"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
<AdminModal
id="roleFormModal"
title="Create New Role"
onClose="closeRoleDrawer()"
>
<h3
id="roleFormTitle"
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
>
Create New Role
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Define a global shared role or an application-scoped custom grant.
</p>
@ -133,7 +133,7 @@ export const AdminRolesPage = ({
</button>
</div>
</form>
</div>
</AdminModal>
<div class="card">
{/* Instant Search and Scope Filters */}
@ -410,7 +410,7 @@ export const AdminRolesPage = ({
}
function closeRoleDrawer() {
document.getElementById('roleFormCard').style.display = 'none';
document.getElementById('roleFormModal').style.display = 'none';
}
function handleScopeChange() {

View File

@ -1,4 +1,5 @@
import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
export const AdminUserDetailsPage = ({
user,
@ -145,32 +146,19 @@ export const AdminUserDetailsPage = ({
</form>
</div>
<div class="table-container" style="margin-top: 1.25rem;">
<table>
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Assigned Role</th>
<th>Granted At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{grants.length === 0
? (
<tr>
<td
colSpan={5}
style="text-align: center; color: var(--danger); padding: 1.5rem;"
>
No application permissions granted (User is blocked from
all subsidiary apps).
</td>
</tr>
)
: (
grants.map((grant) => (
<div style="margin-top: 1.25rem;">
<AdminTable
id="grantsTable"
headers={[
"Application Name",
"SPIFFE Workload ID",
"Assigned Role",
"Granted At",
"Actions",
]}
isEmpty={grants.length === 0}
emptyState="No application permissions granted (User is blocked from all subsidiary apps)."
desktopRows={grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong style="color: var(--text-primary);">
@ -201,10 +189,34 @@ export const AdminUserDetailsPage = ({
</button>
</td>
</tr>
))
)}
</tbody>
</table>
))}
mobileCards={grants.map((grant) => (
<div class="card" key={grant.id} style="margin-bottom: 0;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
<span class="badge badge-info">{grant.role}</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.75rem; font-family: monospace;">
{grant.spiffe_id}
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 0.8rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</span>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</div>
</div>
))}
/>
</div>
</div>

View File

@ -0,0 +1,40 @@
export interface AdminModalProps {
id: string;
title: any;
children: any;
maxWidth?: string;
onClose?: string; // e.g. "closeModal('myModalId')"
}
export const AdminModal = ({
id,
title,
children,
maxWidth = "550px",
onClose,
}: AdminModalProps) => {
return (
<div
id={id}
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
>
<div
style={`background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: ${maxWidth}; padding: 1.5rem; box-shadow: var(--shadow-lg); max-height: 90vh; overflow-y: auto;`}
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
{title}
</h3>
<button
type="button"
onclick={onClose}
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
>
&times;
</button>
</div>
{children}
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
export interface AdminTableProps {
id: string;
headers: any[];
desktopRows: any;
mobileCards: any;
emptyState?: any;
colSpan?: number;
isEmpty?: boolean;
}
export const AdminTable = ({
id,
headers,
desktopRows,
mobileCards,
emptyState,
colSpan,
isEmpty,
}: AdminTableProps) => {
return (
<>
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id={id}>
<thead>
<tr>
{headers.map((header, i) => <th key={i}>{header}</th>)}
</tr>
</thead>
<tbody>
{isEmpty
? (
<tr>
<td
colSpan={colSpan || headers.length}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
{emptyState}
</td>
</tr>
)
: desktopRows}
</tbody>
</table>
</div>
</div>
{/* Mobile Deck View (< 768px) */}
<div
id={`${id}MobileDeck`}
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{isEmpty
? (
<div class="card" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted); margin: 0;">
{emptyState}
</p>
</div>
)
: mobileCards}
</div>
</>
);
};