From 061dd7ac00dd5e3b90c795b92830c3fcc60bf956 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:17:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20=E5=AE=9E=E7=8E=B0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E7=AE=A1=E7=90=86=E5=90=8E=E7=AB=AF=E4=B8=8E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.prod.yml | 19 +- deploy/docker-compose.yml | 19 +- scripts/init-db.sql | 44 +- src/app.ts | 151 +++-- src/clients/client-management.service.ts | 546 ++++++++++++++++++ src/config.ts | 47 +- src/management/management-security.ts | 128 ++++ src/management/management-session.service.ts | 82 +++ src/oidc/adapter.ts | 23 +- src/oidc/client-config.ts | 531 +++++++++++------ src/oidc/provider.ts | 4 +- src/persistence/contracts.ts | 120 +++- .../management-session.repository.ts | 105 ++++ src/persistence/oidc-client.repository.ts | 477 ++++++++++++--- src/persistence/persistence.ts | 255 +++++++- src/routes/management.ts | 473 +++++++++++++++ src/scripts/seed-oidc-client.ts | 6 +- 17 files changed, 2670 insertions(+), 360 deletions(-) create mode 100644 src/clients/client-management.service.ts create mode 100644 src/management/management-security.ts create mode 100644 src/management/management-session.service.ts create mode 100644 src/persistence/management-session.repository.ts create mode 100644 src/routes/management.ts diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 95e80eb..aa1d720 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -16,6 +16,13 @@ services: TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-1} TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} OIDC_CLIENTS_CONFIG_PATH: ${OIDC_CLIENTS_CONFIG_PATH:-/app/config/oidc-clients.json} + OIDC_ADMIN_SUBJECT_IDS: ${OIDC_ADMIN_SUBJECT_IDS:-} + OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT:-10} + OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT:-5} + OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT: ${OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT:-true} + OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX:-5} + OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX:-20} + OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS:-3600} OIDC_ARTIFACT_CLEANUP_ENABLED: ${OIDC_ARTIFACT_CLEANUP_ENABLED:-true} OIDC_ARTIFACT_CLEANUP_CRON: "${OIDC_ARTIFACT_CLEANUP_CRON:-*/5 * * * *}" ports: @@ -28,7 +35,11 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1"] + test: + [ + "CMD-SHELL", + "wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1", + ] interval: 10s timeout: 5s retries: 6 @@ -71,7 +82,11 @@ services: - postgres_data:/var/lib/postgresql/data - ../scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}", + ] interval: 10s timeout: 5s retries: 6 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 83329df..7c561a5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -15,6 +15,13 @@ services: OIDC_COOKIE_SECURE: ${OIDC_COOKIE_SECURE:-false} TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-0} OIDC_CLIENTS_CONFIG_PATH: ${OIDC_CLIENTS_CONFIG_PATH:-/app/config/oidc-clients.json} + OIDC_ADMIN_SUBJECT_IDS: ${OIDC_ADMIN_SUBJECT_IDS:-} + OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT:-10} + OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT:-5} + OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT: ${OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT:-true} + OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX:-5} + OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX:-20} + OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS:-3600} OIDC_ARTIFACT_CLEANUP_ENABLED: ${OIDC_ARTIFACT_CLEANUP_ENABLED:-true} OIDC_ARTIFACT_CLEANUP_CRON: "${OIDC_ARTIFACT_CLEANUP_CRON:-*/5 * * * *}" ports: @@ -27,7 +34,11 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1"] + test: + [ + "CMD-SHELL", + "wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1", + ] interval: 10s timeout: 5s retries: 6 @@ -70,7 +81,11 @@ services: - postgres_data:/var/lib/postgresql/data - ../scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}", + ] interval: 10s timeout: 5s retries: 6 diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 374b384..09ba3a3 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -39,7 +39,10 @@ create table if not exists subject_profiles ( create table if not exists oidc_clients ( client_id text primary key, client_secret_hash text, - application_type text not null, + display_name text not null, + description text not null default '', + owner_subject_id text references subjects(subject_id), + application_type text not null check (application_type = 'web'), token_endpoint_auth_method text not null, redirect_uris jsonb not null, post_logout_redirect_uris jsonb not null default '[]'::jsonb, @@ -49,11 +52,46 @@ create table if not exists oidc_clients ( require_pkce boolean not null default true, allow_refresh_token_for_public_client boolean not null default false, auto_consent boolean not null default false, - status text not null default 'active', + status text not null default 'pending' check (status in ('draft', 'pending', 'active', 'disabled', 'rejected')), + rejection_reason text, created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + updated_at timestamptz not null default now(), + version integer not null default 1 check (version > 0) +); + +create index if not exists idx_oidc_clients_owner_updated +on oidc_clients (owner_subject_id, updated_at desc); + +create index if not exists idx_oidc_clients_status_updated +on oidc_clients (status, updated_at desc); + +create table if not exists oidc_client_audit_logs ( + id bigserial primary key, + client_id text not null, + actor_subject_id text, + action text not null, + changed_fields jsonb not null default '[]'::jsonb, + previous_status text, + new_status text, + reason text, + source_ip text, + created_at timestamptz not null default now() ); +create index if not exists idx_oidc_client_audit_logs_client_created +on oidc_client_audit_logs (client_id, created_at desc); + +create table if not exists management_sessions ( + token_hash text primary key, + subject_id text not null references subjects(subject_id), + created_at timestamptz not null, + last_seen_at timestamptz not null, + expires_at timestamptz not null +); + +create index if not exists idx_management_sessions_expires_at +on management_sessions (expires_at); + create table if not exists oidc_artifacts ( id text primary key, kind text not null, diff --git a/src/app.ts b/src/app.ts index 5ba0afe..a4dc9d7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,12 +1,14 @@ import { randomBytes } from "node:crypto"; +import { resolve } from "node:path"; import express from "express"; import type { Request, Response, NextFunction } from "express"; import { readOidcOpConfig, type OidcOpConfig } from "./config.js"; import { createOidcServices } from "./oidc/provider.js"; -import { RateLimitService, RateLimitUnavailableError } from "./persistence/rate-limit.service.js"; +import { RateLimitService } from "./persistence/rate-limit.service.js"; import type { OidcPersistence } from "./persistence/contracts.js"; import { OidcPersistenceImpl } from "./persistence/persistence.js"; import { createInteractionRouter } from "./routes/interactions.js"; +import { createManagementRouter } from "./routes/management.js"; import type { EmailSender } from "./email/email-sender.js"; type AppState = { @@ -21,15 +23,17 @@ type AppDependencies = { emailSender?: EmailSender; }; -function errorHandler(error: unknown, _request: Request, response: Response, _next: NextFunction) { +function errorHandler( + error: unknown, + _request: Request, + response: Response, + _next: NextFunction, +) { console.error("[oidc-op] unhandled application error", error); - response - .status(500) - .setHeader("Cache-Control", "no-store") - .json({ - error: "server_error", - error_description: "internal server error" - }); + response.status(500).setHeader("Cache-Control", "no-store").json({ + error: "server_error", + error_description: "internal server error", + }); } function cspSourceForOrigin(origin: string): string | undefined { @@ -59,34 +63,48 @@ function buildFormActionSources(config: OidcOpConfig, redirectUris: string[]) { return [...sources]; } -function applySecurityHeaders(config: OidcOpConfig, formActionSources: string[]) { - return (_request: Request, response: Response, next: NextFunction) => { - const scriptNonce = randomBytes(16).toString("base64"); - response.locals["cspScriptNonce"] = scriptNonce; - response.setHeader( - "Content-Security-Policy", - [ - "default-src 'none'", - "base-uri 'none'", - "object-src 'none'", - "frame-ancestors 'none'", - `form-action ${formActionSources.join(" ")}`, - "style-src 'unsafe-inline'", - `script-src 'nonce-${scriptNonce}'` - ].join("; ") - ); - response.setHeader("X-Frame-Options", "DENY"); - response.setHeader("X-Content-Type-Options", "nosniff"); - response.setHeader("Referrer-Policy", "no-referrer"); - next(); +function applySecurityHeaders(getFormActionSources: () => Promise) { + return async (_request: Request, response: Response, next: NextFunction) => { + let formActionSources: string[]; + try { + formActionSources = await getFormActionSources(); + } catch (error) { + next(error); + return; + } + const scriptNonce = randomBytes(16).toString("base64"); + response.locals["cspScriptNonce"] = scriptNonce; + response.setHeader( + "Content-Security-Policy", + [ + "default-src 'none'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + `form-action ${formActionSources.join(" ")}`, + "style-src 'self' 'unsafe-inline'", + `script-src 'nonce-${scriptNonce}' 'self'`, + "connect-src 'self'", + "img-src 'self' data:", + ].join("; "), + ); + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Referrer-Policy", "no-referrer"); + next(); }; } export async function createOidcApp( env: NodeJS.ProcessEnv = process.env, - dependencies: AppDependencies = {} + dependencies: AppDependencies = {}, ) { const config = readOidcOpConfig(env); + if (config.adminSubjectIds.length === 0) { + console.warn( + "[oidc-op] OIDC_ADMIN_SUBJECT_IDS is empty; client approvals are unavailable", + ); + } const store = new OidcPersistenceImpl(config); await store.init(); const rateLimitService = new RateLimitService(config); @@ -95,23 +113,34 @@ export async function createOidcApp( config, store, rateLimitService, - dependencies.emailSender + dependencies.emailSender, ); const app = express(); app.disable("x-powered-by"); app.set("trust proxy", config.trustProxyHops); - const clients = await store.listActiveOidcClients(); - const formActionSources = buildFormActionSources( - config, - clients.flatMap((client) => client.redirectUris) - ); - app.use(applySecurityHeaders(config, formActionSources)); - + let formActionSources = buildFormActionSources(config, []); + let formActionSourcesExpiresAt = 0; + const invalidateClientOrigins = () => { + formActionSourcesExpiresAt = 0; + }; + const getFormActionSources = async () => { + if (Date.now() >= formActionSourcesExpiresAt) { + const clients = await store.listActiveOidcClients(); + formActionSources = buildFormActionSources( + config, + clients.flatMap((client) => client.redirectUris), + ); + formActionSourcesExpiresAt = Date.now() + 5_000; + } + return formActionSources; + }; app.get("/health/live", (_request, response) => { response.json({ status: "live" }); }); + app.use(applySecurityHeaders(getFormActionSources)); + app.get("/health/ready", async (_request, response) => { const databaseReady = await store.checkReadiness(); const redisReady = await rateLimitService.checkReadiness(); @@ -119,22 +148,58 @@ export async function createOidcApp( status: databaseReady && redisReady ? "ready" : "degraded", issuer: config.issuer, database: store.hasDatabase() ? "postgres" : "memory", - redis: config.redisUrl ? (redisReady ? "ready" : "unavailable") : "optional" + redis: config.redisUrl + ? redisReady + ? "ready" + : "unavailable" + : "optional", }); }); - app.use("/interaction", createInteractionRouter(config, services.provider, services, store, rateLimitService)); + app.use( + "/api/management", + createManagementRouter( + config, + services, + store, + rateLimitService, + invalidateClientOrigins, + ), + ); + const managementAssets = resolve(process.cwd(), "dist/management"); + app.use( + "/manage/assets", + express.static(resolve(managementAssets, "assets"), { + immutable: true, + maxAge: "1y", + }), + ); + app.get(/^\/manage(?:\/.*)?$/, (_request, response) => { + response.setHeader("Cache-Control", "no-store"); + response.sendFile(resolve(managementAssets, "index.html")); + }); + + app.use( + "/interaction", + createInteractionRouter( + config, + services.provider, + services, + store, + rateLimitService, + ), + ); app.use((request, response, next) => { if (request.path === "/session/end") { response.clearCookie("op_csrf", { path: "/", sameSite: "lax", - secure: config.cookieSecure + secure: config.cookieSecure, }); response.clearCookie("op_csrf_nonce", { path: "/", sameSite: "lax", - secure: config.cookieSecure + secure: config.cookieSecure, }); } next(); @@ -147,7 +212,7 @@ export async function createOidcApp( provider: services.provider, store, rateLimitService, - closeOidcServices: services.close + closeOidcServices: services.close, }; return { app, state } as { app: express.Express; state: AppState }; } diff --git a/src/clients/client-management.service.ts b/src/clients/client-management.service.ts new file mode 100644 index 0000000..3fbd63a --- /dev/null +++ b/src/clients/client-management.service.ts @@ -0,0 +1,546 @@ +import { randomBytes } from "node:crypto"; +import { createClientSecretDigest } from "../crypto.js"; +import { + ClientValidationError, + configurationToProtocolFields, + validateManagedClientConfiguration, + type ManagedClientConfiguration, + type ManagedClientType, +} from "../oidc/client-config.js"; +import type { + OidcClientAuditRecord, + OidcClientRecord, + OidcClientRepository, +} from "../persistence/contracts.js"; +import { base64Url, randomId } from "../utils.js"; + +export type ClientActor = { + subjectId: string; + isAdmin: boolean; + sourceIp?: string; +}; + +export type PublicOidcClient = { + clientId: string; + displayName: string; + description: string; + ownerSubjectId: string | null; + clientType: ManagedClientType; + redirectUris: string[]; + postLogoutRedirectUris: string[]; + scopeWhitelist: string[]; + status: OidcClientRecord["status"]; + rejectionReason: string | null; + createdAt: string; + updatedAt: string; + version: number; +}; + +export class ClientManagementError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly field?: string, + ) { + super(message); + this.name = "ClientManagementError"; + } +} + +type ServiceDependencies = { + now?: () => Date; + createClientId?: () => string; + createSecret?: () => string; + digestSecret?: (secret: string) => Promise; + maxClientsPerOwner?: number; + maxPendingClientsPerOwner?: number; + adminQuotaExempt?: boolean; +}; + +const editableFields = [ + "displayName", + "description", + "redirectUris", + "postLogoutRedirectUris", + "scopeWhitelist", +] as const; +const createFields = ["clientType", ...editableFields] as const; +const sensitiveFields = new Set([ + "redirectUris", + "postLogoutRedirectUris", + "scopeWhitelist", +]); + +export class ClientManagementService { + private readonly now: () => Date; + private readonly createClientId: () => string; + private readonly createSecret: () => string; + private readonly digestSecret: (secret: string) => Promise; + private readonly maxClientsPerOwner: number; + private readonly maxPendingClientsPerOwner: number; + private readonly adminQuotaExempt: boolean; + + constructor( + private readonly repository: OidcClientRepository, + private readonly appEnv: string, + dependencies: ServiceDependencies = {}, + ) { + this.now = dependencies.now ?? (() => new Date()); + this.createClientId = + dependencies.createClientId ?? (() => randomId("client", 18)); + this.createSecret = + dependencies.createSecret ?? (() => base64Url(randomBytes(32))); + this.digestSecret = dependencies.digestSecret ?? createClientSecretDigest; + this.maxClientsPerOwner = dependencies.maxClientsPerOwner ?? 10; + this.maxPendingClientsPerOwner = + dependencies.maxPendingClientsPerOwner ?? 5; + this.adminQuotaExempt = dependencies.adminQuotaExempt ?? true; + } + + async list(actor: ClientActor, viewAll = false) { + if (viewAll && !actor.isAdmin) { + throw new ClientManagementError( + 403, + "access_denied", + "administrator access is required", + ); + } + const clients = viewAll + ? await this.repository.listOidcClients() + : await this.repository.listOidcClientsByOwner(actor.subjectId); + return clients.map(toPublicClient); + } + + async listPending(actor: ClientActor) { + this.requireAdmin(actor); + return (await this.repository.listPendingOidcClients()).map(toPublicClient); + } + + async get(actor: ClientActor, clientId: string) { + return toPublicClient(await this.requireAccessible(actor, clientId)); + } + + async create(actor: ClientActor, raw: unknown) { + this.assertAllowedKeys(raw, createFields); + const configuration = this.validate(raw); + const clientId = this.createClientId(); + const secret = + configuration.clientType === "web" ? this.createSecret() : undefined; + const digest = secret ? await this.digestSecret(secret) : undefined; + const timestamp = this.now().toISOString(); + const protocol = configurationToProtocolFields(configuration); + const client: OidcClientRecord = { + clientId, + clientSecretDigest: digest, + displayName: configuration.displayName, + description: configuration.description, + ownerSubjectId: actor.subjectId, + ...protocol, + redirectUris: configuration.redirectUris, + postLogoutRedirectUris: configuration.postLogoutRedirectUris, + scopeWhitelist: configuration.scopeWhitelist, + status: "pending", + createdAt: timestamp, + updatedAt: timestamp, + version: 1, + }; + const audits: OidcClientAuditRecord[] = [ + this.audit( + actor, + clientId, + "client.created", + [...createFields], + undefined, + "pending", + timestamp, + ), + ]; + if (secret) { + audits.push( + this.audit( + actor, + clientId, + "client.secret_generated", + [], + undefined, + undefined, + timestamp, + ), + ); + } + const created = await this.repository.createOidcClient( + client, + audits, + actor.isAdmin && this.adminQuotaExempt + ? undefined + : { + maxNonDisabledClients: this.maxClientsPerOwner, + maxPendingClients: this.maxPendingClientsPerOwner, + }, + ); + if (!created) { + throw new ClientManagementError( + 409, + "client_quota_exceeded", + "client quota exceeded for this account", + ); + } + return { + client: toPublicClient(client), + ...(secret ? { clientSecret: secret } : {}), + }; + } + + async update(actor: ClientActor, clientId: string, raw: unknown) { + const patch = this.requireObject(raw); + this.assertAllowedKeys(patch, ["version", ...editableFields]); + const version = this.parseVersion(patch["version"]); + const current = await this.requireAccessible(actor, clientId); + if (current.status === "disabled") { + throw new ClientManagementError( + 409, + "invalid_client_state", + "disabled clients cannot be edited", + ); + } + const currentConfiguration = configurationFromRecord(current); + const merged = { + clientType: currentConfiguration.clientType, + ...Object.fromEntries( + editableFields.map((field) => [ + field, + patch[field] === undefined + ? currentConfiguration[field] + : patch[field], + ]), + ), + }; + const configuration = this.validate(merged); + const changedFields = editableFields.filter( + (field) => + JSON.stringify(configuration[field]) !== + JSON.stringify(currentConfiguration[field]), + ); + if (changedFields.length === 0) { + throw new ClientManagementError( + 400, + "invalid_request", + "at least one client field must change", + ); + } + const becomesPending = changedFields.some((field) => + sensitiveFields.has(field), + ); + if (current.status === "active" && becomesPending) { + throw new ClientManagementError( + 409, + "invalid_client_state", + "active clients cannot change redirect URIs or scopes; create a new client", + ); + } + const nextStatus = current.status === "rejected" ? "draft" : current.status; + const timestamp = this.now().toISOString(); + const next: OidcClientRecord = { + ...current, + ...configurationToProtocolFields(configuration), + displayName: configuration.displayName, + description: configuration.description, + redirectUris: configuration.redirectUris, + postLogoutRedirectUris: configuration.postLogoutRedirectUris, + scopeWhitelist: configuration.scopeWhitelist, + status: nextStatus, + updatedAt: timestamp, + version: current.version + 1, + }; + const updated = await this.repository.updateOidcClient( + next, + version, + this.audit( + actor, + clientId, + "client.updated", + changedFields, + current.status, + nextStatus, + timestamp, + ), + ); + if (!updated) { + throw new ClientManagementError( + 409, + "version_conflict", + "client was modified; reload and retry", + ); + } + return toPublicClient(updated); + } + + async submit(actor: ClientActor, clientId: string, raw: unknown) { + const body = this.requireObject(raw); + this.assertAllowedKeys(body, ["version"]); + const current = await this.requireAccessible(actor, clientId); + if (current.status !== "draft") { + throw new ClientManagementError( + 409, + "invalid_client_state", + "only draft clients can be submitted", + ); + } + return this.transition( + actor, + current, + this.parseVersion(body["version"]), + "pending", + "client.submitted", + ); + } + + async disable(actor: ClientActor, clientId: string, raw: unknown) { + const body = this.requireObject(raw); + this.assertAllowedKeys(body, ["version"]); + const version = this.parseVersion(body["version"]); + const current = await this.requireAccessible(actor, clientId); + if (current.status === "disabled") { + throw new ClientManagementError( + 409, + "invalid_client_state", + "client is already disabled", + ); + } + return this.transition( + actor, + current, + version, + "disabled", + "client.disabled", + ); + } + + async approve(actor: ClientActor, clientId: string, raw: unknown) { + this.requireAdmin(actor); + const body = this.requireObject(raw); + this.assertAllowedKeys(body, ["version"]); + const current = await this.requireExisting(clientId); + if (current.status !== "pending") { + throw new ClientManagementError( + 409, + "invalid_client_state", + "only pending clients can be approved", + ); + } + return this.transition( + actor, + current, + this.parseVersion(body["version"]), + "active", + "client.approved", + ); + } + + async reject(actor: ClientActor, clientId: string, raw: unknown) { + this.requireAdmin(actor); + const body = this.requireObject(raw); + this.assertAllowedKeys(body, ["version", "reason"]); + const current = await this.requireExisting(clientId); + if (current.status !== "pending") { + throw new ClientManagementError( + 409, + "invalid_client_state", + "only pending clients can be rejected", + ); + } + const reason = + body["reason"] === undefined + ? undefined + : typeof body["reason"] === "string" && + body["reason"].trim().length <= 500 + ? body["reason"].trim() + : (() => { + throw new ClientManagementError( + 400, + "invalid_request", + "reason must be at most 500 characters", + "reason", + ); + })(); + return this.transition( + actor, + current, + this.parseVersion(body["version"]), + "rejected", + "client.rejected", + reason, + ); + } + + private async transition( + actor: ClientActor, + current: OidcClientRecord, + version: number, + status: OidcClientRecord["status"], + action: OidcClientAuditRecord["action"], + reason?: string, + ) { + const timestamp = this.now().toISOString(); + const next = { + ...current, + status, + ...(status === "rejected" ? { rejectionReason: reason } : {}), + updatedAt: timestamp, + version: current.version + 1, + }; + const updated = await this.repository.updateOidcClient(next, version, { + ...this.audit( + actor, + current.clientId, + action, + ["status"], + current.status, + status, + timestamp, + ), + ...(reason ? { reason } : {}), + }); + if (!updated) { + throw new ClientManagementError( + 409, + "version_conflict", + "client was modified; reload and retry", + ); + } + return toPublicClient(updated); + } + + private validate(raw: unknown) { + try { + return validateManagedClientConfiguration(raw, this.appEnv); + } catch (error) { + if (error instanceof ClientValidationError) { + throw new ClientManagementError( + 400, + "invalid_client_metadata", + error.message, + error.field, + ); + } + throw error; + } + } + + private async requireAccessible(actor: ClientActor, clientId: string) { + const client = await this.requireExisting(clientId); + if (!actor.isAdmin && client.ownerSubjectId !== actor.subjectId) { + throw new ClientManagementError(404, "not_found", "client not found"); + } + return client; + } + + private async requireExisting(clientId: string) { + const client = await this.repository.findOidcClient(clientId); + if (!client) { + throw new ClientManagementError(404, "not_found", "client not found"); + } + return client; + } + + private requireAdmin(actor: ClientActor) { + if (!actor.isAdmin) { + throw new ClientManagementError( + 403, + "access_denied", + "administrator access is required", + ); + } + } + + private requireObject(raw: unknown): Record { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new ClientManagementError( + 400, + "invalid_request", + "request body must be an object", + ); + } + return raw as Record; + } + + private assertAllowedKeys(raw: unknown, allowed: readonly string[]) { + const body = this.requireObject(raw); + const unexpected = Object.keys(body).filter( + (key) => !allowed.includes(key), + ); + if (unexpected.length > 0) { + throw new ClientManagementError( + 400, + "invalid_request", + `unsupported request field: ${unexpected[0]}`, + unexpected[0], + ); + } + } + + private parseVersion(value: unknown) { + if (!Number.isInteger(value) || Number(value) <= 0) { + throw new ClientManagementError( + 400, + "invalid_request", + "version must be a positive integer", + "version", + ); + } + return Number(value); + } + + private audit( + actor: ClientActor, + clientId: string, + action: OidcClientAuditRecord["action"], + changedFields: readonly string[], + previousStatus: OidcClientRecord["status"] | undefined, + newStatus: OidcClientRecord["status"] | undefined, + createdAt: string, + ): OidcClientAuditRecord { + return { + clientId, + actorSubjectId: actor.subjectId, + action, + changedFields: [...changedFields], + ...(previousStatus ? { previousStatus } : {}), + ...(newStatus ? { newStatus } : {}), + ...(actor.sourceIp ? { sourceIp: actor.sourceIp } : {}), + createdAt, + }; + } +} + +export function toPublicClient(client: OidcClientRecord): PublicOidcClient { + return { + clientId: client.clientId, + displayName: client.displayName, + description: client.description, + ownerSubjectId: client.ownerSubjectId, + clientType: client.tokenEndpointAuthMethod === "none" ? "spa" : "web", + redirectUris: client.redirectUris, + postLogoutRedirectUris: client.postLogoutRedirectUris, + scopeWhitelist: client.scopeWhitelist, + status: client.status, + rejectionReason: client.rejectionReason ?? null, + createdAt: client.createdAt, + updatedAt: client.updatedAt, + version: client.version, + }; +} + +function configurationFromRecord( + client: OidcClientRecord, +): ManagedClientConfiguration { + return { + clientType: client.tokenEndpointAuthMethod === "none" ? "spa" : "web", + displayName: client.displayName, + description: client.description, + redirectUris: client.redirectUris, + postLogoutRedirectUris: client.postLogoutRedirectUris, + scopeWhitelist: client.scopeWhitelist, + }; +} diff --git a/src/config.ts b/src/config.ts index c695626..fa48c42 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,12 @@ export type OidcOpConfig = { loginFailureWindowSeconds: number; tokenRateLimitMax: number; tokenRateLimitWindowSeconds: number; + managementClientMaxPerSubject: number; + managementClientMaxPendingPerSubject: number; + managementClientCreateRateLimitSubjectMax: number; + managementClientCreateRateLimitIpMax: number; + managementClientCreateRateLimitWindowSeconds: number; + managementClientQuotaAdminExempt: boolean; rateLimitFailClosed: boolean; rateLimitMemoryMaxKeys: number; rateLimitMemoryCleanupIntervalSeconds: number; @@ -61,6 +67,7 @@ export type OidcOpConfig = { signingKeyRefreshIntervalSeconds: number; oidcClientsConfigPath: string; autoSeedSigningKey: boolean; + adminSubjectIds: string[]; }; function requireSecret(env: NodeJS.ProcessEnv, key: string, allowDefaultForTest = false): string { @@ -73,7 +80,6 @@ function requireSecret(env: NodeJS.ProcessEnv, key: string, allowDefaultForTest } throw new Error(`${key} is required`); } - function parseCsv(value: string | undefined): string[] { return value ? value.split(",").map((entry) => entry.trim()).filter(Boolean) : []; } @@ -282,6 +288,37 @@ export function readOidcOpConfig(env: NodeJS.ProcessEnv = process.env): OidcOpCo if (!Number.isInteger(rateLimitMemoryCleanupIntervalSeconds) || rateLimitMemoryCleanupIntervalSeconds <= 0) { throw new Error("OIDC_RATE_LIMIT_MEMORY_CLEANUP_INTERVAL_SECONDS must be a positive integer"); } + const managementClientMaxPerSubject = Number( + env["OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT"] ?? 10 + ); + const managementClientMaxPendingPerSubject = Number( + env["OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT"] ?? 5 + ); + const managementClientCreateRateLimitSubjectMax = Number( + env["OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX"] ?? 5 + ); + const managementClientCreateRateLimitIpMax = Number( + env["OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX"] ?? 20 + ); + const managementClientCreateRateLimitWindowSeconds = Number( + env["OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS"] ?? 3600 + ); + for (const [key, value] of [ + ["OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT", managementClientMaxPerSubject], + ["OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT", managementClientMaxPendingPerSubject], + ["OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX", managementClientCreateRateLimitSubjectMax], + ["OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX", managementClientCreateRateLimitIpMax], + ["OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS", managementClientCreateRateLimitWindowSeconds] + ] as const) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${key} must be a positive integer`); + } + } + if (managementClientMaxPendingPerSubject > managementClientMaxPerSubject) { + throw new Error( + "OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT must not exceed OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT" + ); + } if (!Number.isInteger(trustProxyHops) || trustProxyHops < 0) { throw new Error("TRUST_PROXY_HOPS must be a non-negative integer"); } @@ -430,6 +467,13 @@ export function readOidcOpConfig(env: NodeJS.ProcessEnv = process.env): OidcOpCo loginFailureWindowSeconds: Number(env["OIDC_LOGIN_FAILURE_WINDOW_SECONDS"] ?? 60 * 5), tokenRateLimitMax: Number(env["OIDC_TOKEN_RATE_LIMIT_MAX"] ?? 20), tokenRateLimitWindowSeconds: Number(env["OIDC_TOKEN_RATE_LIMIT_WINDOW_SECONDS"] ?? 60), + managementClientMaxPerSubject, + managementClientMaxPendingPerSubject, + managementClientCreateRateLimitSubjectMax, + managementClientCreateRateLimitIpMax, + managementClientCreateRateLimitWindowSeconds, + managementClientQuotaAdminExempt: + env["OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT"] !== "false", rateLimitFailClosed, rateLimitMemoryMaxKeys, rateLimitMemoryCleanupIntervalSeconds, @@ -439,6 +483,7 @@ export function readOidcOpConfig(env: NodeJS.ProcessEnv = process.env): OidcOpCo artifactOpportunisticCleanupIntervalSeconds, signingKeyRefreshIntervalSeconds, oidcClientsConfigPath, + adminSubjectIds: parseCsv(env["OIDC_ADMIN_SUBJECT_IDS"]), autoSeedSigningKey: env["OIDC_AUTO_SEED_SIGNING_KEY"] !== undefined ? env["OIDC_AUTO_SEED_SIGNING_KEY"] === "true" diff --git a/src/management/management-security.ts b/src/management/management-security.ts new file mode 100644 index 0000000..93d2d4e --- /dev/null +++ b/src/management/management-security.ts @@ -0,0 +1,128 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import type { Request, Response } from "express"; +import type { OidcOpConfig } from "../config.js"; +import { base64Url, parseCookies, sha256 } from "../utils.js"; + +export function managementSessionCookieName( + config: Pick, +) { + return config.cookieSecure ? "__Host-cqut_manage_sid" : "cqut_manage_sid"; +} + +function managementNonceCookieName(config: Pick) { + return config.cookieSecure ? "__Host-cqut_manage_csrf" : "cqut_manage_csrf"; +} + +export function readManagementSessionToken( + request: Request, + config: Pick, +) { + return parseCookies(request.get("cookie"))[ + managementSessionCookieName(config) + ]; +} + +export function setManagementSessionCookie( + response: Response, + config: Pick, + token: string, +) { + response.cookie(managementSessionCookieName(config), token, { + httpOnly: true, + secure: config.cookieSecure, + sameSite: "lax", + path: "/", + maxAge: config.sessionTtlSeconds * 1000, + }); +} + +export function clearManagementSessionCookie( + response: Response, + config: Pick, +) { + response.clearCookie(managementSessionCookieName(config), { + httpOnly: true, + secure: config.cookieSecure, + sameSite: "lax", + path: "/", + }); +} + +export function ensureManagementNonce( + request: Request, + response: Response, + config: Pick, +) { + const name = managementNonceCookieName(config); + const existing = parseCookies(request.get("cookie"))[name]; + if (existing) { + return existing; + } + const nonce = base64Url(randomBytes(24)); + response.cookie(name, nonce, { + httpOnly: true, + secure: config.cookieSecure, + sameSite: "lax", + path: "/", + maxAge: config.csrfTokenTtlSeconds * 1000, + }); + return nonce; +} + +export function issueManagementCsrf( + config: Pick, + binding: string, + now = Math.floor(Date.now() / 1000), +) { + const payload = `${now + config.csrfTokenTtlSeconds}.${sha256(binding)}`; + const signature = createHmac("sha256", config.csrfSigningSecret) + .update(payload) + .digest("base64url"); + return `${payload}.${signature}`; +} + +export function validateManagementCsrf( + request: Request, + config: Pick, + binding: string, + now = Math.floor(Date.now() / 1000), +) { + const origin = request.get("origin"); + if (origin && origin !== new URL(config.issuer).origin) { + return false; + } + const token = request.get("x-csrf-token"); + if (!token) { + return false; + } + const parts = token.split("."); + if (parts.length !== 3) { + return false; + } + const [expiresRaw, bindingHash, signature] = parts; + const expires = Number(expiresRaw); + if ( + !Number.isInteger(expires) || + expires < now || + bindingHash !== sha256(binding) + ) { + return false; + } + const expected = createHmac("sha256", config.csrfSigningSecret) + .update(`${expiresRaw}.${bindingHash}`) + .digest(); + let actual: Buffer; + try { + actual = Buffer.from(signature ?? "", "base64url"); + } catch { + return false; + } + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +export function readManagementNonce( + request: Request, + config: Pick, +) { + return parseCookies(request.get("cookie"))[managementNonceCookieName(config)]; +} diff --git a/src/management/management-session.service.ts b/src/management/management-session.service.ts new file mode 100644 index 0000000..61c235c --- /dev/null +++ b/src/management/management-session.service.ts @@ -0,0 +1,82 @@ +import { randomBytes } from "node:crypto"; +import type { AuthenticatedPrincipal } from "../identity/index.js"; +import type { + ManagementSessionRepository, + IdentityRepository, +} from "../persistence/contracts.js"; +import { base64Url, sha256 } from "../utils.js"; + +type SessionStore = ManagementSessionRepository & + Pick; + +export class ManagementSessionService { + constructor( + private readonly store: SessionStore, + private readonly absoluteTtlSeconds: number, + private readonly idleTtlSeconds: number, + private readonly now: () => Date = () => new Date(), + private readonly createToken: () => string = () => + base64Url(randomBytes(32)), + ) {} + + async create(subjectId: string) { + const token = this.createToken(); + const tokenHash = sha256(token); + const now = this.now(); + const expiresAt = new Date( + now.getTime() + this.absoluteTtlSeconds * 1000, + ).toISOString(); + await this.store.deleteExpiredManagementSessions(now.toISOString()); + await this.store.createManagementSession({ + tokenHash, + subjectId, + createdAt: now.toISOString(), + lastSeenAt: now.toISOString(), + expiresAt, + }); + return { token, expiresAt }; + } + + async authenticate( + token: string | undefined, + ): Promise { + if (!token) { + return null; + } + const tokenHash = sha256(token); + const session = await this.store.findManagementSession(tokenHash); + if (!session) { + return null; + } + const now = this.now(); + const idleExpiresAt = + new Date(session.lastSeenAt).getTime() + this.idleTtlSeconds * 1000; + if ( + new Date(session.expiresAt).getTime() <= now.getTime() || + idleExpiresAt <= now.getTime() + ) { + await this.store.deleteManagementSession(tokenHash); + return null; + } + const principal = await this.store.findPrincipalBySubjectId( + session.subjectId, + ); + if (!principal) { + await this.store.deleteManagementSession(tokenHash); + return null; + } + if ( + now.getTime() - new Date(session.lastSeenAt).getTime() >= + 5 * 60 * 1000 + ) { + await this.store.touchManagementSession(tokenHash, now.toISOString()); + } + return principal; + } + + async revoke(token: string | undefined) { + if (token) { + await this.store.deleteManagementSession(sha256(token)); + } + } +} diff --git a/src/oidc/adapter.ts b/src/oidc/adapter.ts index 4c0f83d..62bdecf 100644 --- a/src/oidc/adapter.ts +++ b/src/oidc/adapter.ts @@ -1,4 +1,7 @@ -import type { OidcClientRecord, OidcPersistence } from "../persistence/contracts.js"; +import type { + OidcClientRecord, + OidcPersistence, +} from "../persistence/contracts.js"; type AdapterStore = Pick< OidcPersistence, @@ -15,7 +18,8 @@ type AdapterStore = Pick< function providerClientMetadata(client: OidcClientRecord) { const metadata: Record = { client_id: client.clientId, - application_type: client.applicationType === "native" ? "native" : "web", + client_name: client.displayName, + application_type: "web", token_endpoint_auth_method: client.tokenEndpointAuthMethod, redirect_uris: client.redirectUris, post_logout_redirect_uris: client.postLogoutRedirectUris, @@ -23,7 +27,7 @@ function providerClientMetadata(client: OidcClientRecord) { response_types: client.responseTypes, scope: client.scopeWhitelist.join(" "), allowRefreshTokenForPublicClient: client.allowRefreshTokenForPublicClient, - clientSecretDigest: client.clientSecretDigest + clientSecretDigest: client.clientSecretDigest, }; if (client.clientSecretDigest) { metadata["client_secret"] = `placeholder:${client.clientId}`; @@ -40,11 +44,20 @@ export function createAdapter(store: AdapterStore) { this.modelName = modelName; } - async upsert(id: string, payload: Record, expiresIn: number) { + async upsert( + id: string, + payload: Record, + expiresIn: number, + ) { if (this.modelName === "Client") { throw new Error("Client adapter upsert is disabled in managed profile"); } - await store.upsertArtifact(`${this.modelName}:${id}`, this.modelName, payload, expiresIn); + await store.upsertArtifact( + `${this.modelName}:${id}`, + this.modelName, + payload, + expiresIn, + ); } async find(id: string) { diff --git a/src/oidc/client-config.ts b/src/oidc/client-config.ts index ac35fc5..552eff8 100644 --- a/src/oidc/client-config.ts +++ b/src/oidc/client-config.ts @@ -1,67 +1,65 @@ import { readFile } from "node:fs/promises"; -import { DEFAULT_OIDC_SCOPES, OIDC_SCOPES, type OidcScope } from "../shared/oidc-contracts.js"; +import { + DEFAULT_OIDC_SCOPES, + OIDC_SCOPES, + type OidcScope, +} from "../shared/oidc-contracts.js"; import type { OidcOpConfig } from "../config.js"; -import type { OidcClientRecord, OidcPersistence } from "../persistence/contracts.js"; +import type { + OidcClientAuditRecord, + OidcClientRecord, + OidcPersistence, +} from "../persistence/contracts.js"; -type RawClientsDocument = { - clients: unknown; -}; +export type ManagedClientType = "web" | "spa"; -type RawClient = { - clientId?: unknown; - clientSecretDigest?: unknown; - applicationType?: unknown; - tokenEndpointAuthMethod?: unknown; - redirectUris?: unknown; - postLogoutRedirectUris?: unknown; - grantTypes?: unknown; - responseTypes?: unknown; - scopeWhitelist?: unknown; - requirePkce?: unknown; - allowRefreshTokenForPublicClient?: unknown; - autoConsent?: unknown; - status?: unknown; +export type ManagedClientConfiguration = { + clientType: ManagedClientType; + displayName: string; + description: string; + redirectUris: string[]; + postLogoutRedirectUris: string[]; + scopeWhitelist: OidcScope[]; }; -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseAbsoluteUrl(value: string, key: string, clientId: string): URL { - try { - return new URL(value); - } catch { - throw new Error(`oidc client ${clientId}: ${key} must be an absolute URL`); +export class ClientValidationError extends Error { + constructor( + message: string, + readonly field?: string, + ) { + super(message); + this.name = "ClientValidationError"; } } -function isLoopbackHostname(hostname: string): boolean { - return hostname === "localhost" || hostname === "127.0.0.1"; +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -function assertHttpsOrTestLoopbackHttp(value: string, key: string, appEnv: string, clientId: string) { - const parsed = parseAbsoluteUrl(value, key, clientId); - if (parsed.protocol === "https:") { - return; - } - if (appEnv === "test" && parsed.protocol === "http:" && isLoopbackHostname(parsed.hostname)) { - return; - } - if (appEnv === "test") { - throw new Error( - `oidc client ${clientId}: ${key} must use https:// or loopback http://localhost|127.0.0.1 in test` - ); +function parseString( + value: unknown, + key: string, + label: string, + options?: { optional?: boolean; max?: number }, +) { + if (value === undefined && options?.optional) { + return ""; } - throw new Error(`oidc client ${clientId}: ${key} must use https:// when APP_ENV is not test`); -} - -function parseString(value: unknown, key: string, clientId: string): string { if (typeof value !== "string") { - throw new Error(`oidc client ${clientId}: ${key} must be a string`); + throw new ClientValidationError(`${label}: ${key} must be a string`, key); } const normalized = value.trim(); - if (!normalized) { - throw new Error(`oidc client ${clientId}: ${key} must be a non-empty string`); + if (!normalized && !options?.optional) { + throw new ClientValidationError( + `${label}: ${key} must be a non-empty string`, + key, + ); + } + if (options?.max && normalized.length > options.max) { + throw new ClientValidationError( + `${label}: ${key} must be at most ${options.max} characters`, + key, + ); } return normalized; } @@ -69,214 +67,377 @@ function parseString(value: unknown, key: string, clientId: string): string { function parseStringArray( value: unknown, key: string, - clientId: string, - options: { required: boolean; defaultValue?: string[] } = { required: true } -): string[] { - if (value === undefined) { - if (options.defaultValue) { - return options.defaultValue; - } - if (options.required) { - throw new Error(`oidc client ${clientId}: ${key} is required`); - } - return []; + label: string, + options: { required: boolean; defaultValue?: string[] }, +) { + if (value === undefined && options.defaultValue) { + return [...options.defaultValue]; } if (!Array.isArray(value)) { - throw new Error(`oidc client ${clientId}: ${key} must be an array of strings`); + throw new ClientValidationError( + `${label}: ${key} must be an array of strings`, + key, + ); } const parsed = value.map((entry, index) => { if (typeof entry !== "string" || !entry.trim()) { - throw new Error(`oidc client ${clientId}: ${key}[${index}] must be a non-empty string`); + throw new ClientValidationError( + `${label}: ${key}[${index}] must be a non-empty string`, + key, + ); } return entry.trim(); }); - if (parsed.length === 0 && options.required) { - throw new Error(`oidc client ${clientId}: ${key} must contain at least one item`); - } - return parsed; -} - -function parseBoolean(value: unknown, key: string, clientId: string, fallback: boolean): boolean { - if (value === undefined) { - return fallback; + const unique = [...new Set(parsed)]; + if (unique.length === 0 && options.required) { + throw new ClientValidationError( + `${label}: ${key} must contain at least one item`, + key, + ); } - if (typeof value !== "boolean") { - throw new Error(`oidc client ${clientId}: ${key} must be a boolean`); + if (unique.length > 20) { + throw new ClientValidationError( + `${label}: ${key} must contain at most 20 items`, + key, + ); } - return value; + return unique; } -function parseTokenEndpointAuthMethod(value: unknown, clientId: string): OidcClientRecord["tokenEndpointAuthMethod"] { - if (value === undefined) { - return "client_secret_basic"; +function validateUri( + value: string, + key: string, + appEnv: string, + label: string, +) { + if (value.includes("*")) { + throw new ClientValidationError( + `${label}: ${key} must not contain wildcards`, + key, + ); } - if (value === "client_secret_basic" || value === "none") { - return value; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new ClientValidationError( + `${label}: ${key} must be an absolute URL`, + key, + ); } - throw new Error(`oidc client ${clientId}: tokenEndpointAuthMethod must be client_secret_basic or none`); -} - -function parseApplicationType(value: unknown, clientId: string): OidcClientRecord["applicationType"] { - if (value === undefined) { - return "web"; + if (parsed.hash) { + throw new ClientValidationError( + `${label}: ${key} must not contain a fragment`, + key, + ); } - if (value === "web" || value === "native" || value === "service") { - return value; + if (parsed.username || parsed.password) { + throw new ClientValidationError( + `${label}: ${key} must not contain credentials`, + key, + ); } - throw new Error(`oidc client ${clientId}: applicationType must be web, native, or service`); -} - -function parseStatus(value: unknown, clientId: string): OidcClientRecord["status"] { - if (value === undefined) { - return "active"; + if (parsed.protocol === "https:") { + return; } - if (value === "active" || value === "disabled") { - return value; + const loopback = + parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + if (appEnv === "test" && parsed.protocol === "http:" && loopback) { + return; } - throw new Error(`oidc client ${clientId}: status must be active or disabled`); + throw new ClientValidationError( + appEnv === "test" + ? `${label}: ${key} must use https:// or loopback http://localhost|127.0.0.1 in test` + : `${label}: ${key} must use https:// when APP_ENV is not test`, + key, + ); } -function parseScopeWhitelist(value: unknown, clientId: string): OidcScope[] { - const scopes = parseStringArray(value, "scopeWhitelist", clientId, { +function parseScopes(value: unknown, label: string): OidcScope[] { + const scopes = parseStringArray(value, "scopeWhitelist", label, { required: true, - defaultValue: [...DEFAULT_OIDC_SCOPES] + defaultValue: [...DEFAULT_OIDC_SCOPES], }); const supported = new Set(OIDC_SCOPES); for (const scope of scopes) { if (!supported.has(scope)) { - throw new Error(`oidc client ${clientId}: unsupported scope ${scope}`); + throw new ClientValidationError( + `${label}: unsupported scope ${scope}`, + "scopeWhitelist", + ); } } + if (!scopes.includes("openid")) { + throw new ClientValidationError( + `${label}: scopeWhitelist must include openid`, + "scopeWhitelist", + ); + } return scopes as OidcScope[]; } -function parseClient(raw: unknown, appEnv: string, seenClientIds: Set): OidcClientRecord { +export function validateManagedClientConfiguration( + raw: unknown, + appEnv: string, + label = "oidc client", +): ManagedClientConfiguration { if (!isObject(raw)) { - throw new Error("each oidc client item must be an object"); + throw new ClientValidationError(`${label} must be an object`); + } + const clientType = raw["clientType"]; + if (clientType !== "web" && clientType !== "spa") { + throw new ClientValidationError( + `${label}: clientType must be web or spa`, + "clientType", + ); + } + const redirectUris = parseStringArray( + raw["redirectUris"], + "redirectUris", + label, + { + required: true, + }, + ); + const postLogoutRedirectUris = parseStringArray( + raw["postLogoutRedirectUris"], + "postLogoutRedirectUris", + label, + { required: false, defaultValue: [] }, + ); + for (const uri of redirectUris) { + validateUri(uri, "redirectUris", appEnv, label); } - const client = raw as RawClient; - const clientId = parseString(client.clientId, "clientId", ""); - if (seenClientIds.has(clientId)) { - throw new Error(`duplicate oidc client clientId: ${clientId}`); + for (const uri of postLogoutRedirectUris) { + validateUri(uri, "postLogoutRedirectUris", appEnv, label); } - seenClientIds.add(clientId); + const scopeWhitelist = parseScopes(raw["scopeWhitelist"], label); + if (clientType === "spa" && scopeWhitelist.includes("offline_access")) { + throw new ClientValidationError( + `${label}: SPA clients cannot request offline_access in this release`, + "scopeWhitelist", + ); + } + return { + clientType, + displayName: parseString(raw["displayName"], "displayName", label, { + max: 100, + }), + description: parseString(raw["description"], "description", label, { + optional: true, + max: 1000, + }), + redirectUris, + postLogoutRedirectUris, + scopeWhitelist, + }; +} - const tokenEndpointAuthMethod = parseTokenEndpointAuthMethod(client.tokenEndpointAuthMethod, clientId); - const clientSecretDigestRaw = client.clientSecretDigest; - const clientSecretDigest = - clientSecretDigestRaw === undefined - ? undefined - : parseString(clientSecretDigestRaw, "clientSecretDigest", clientId); +export function configurationToProtocolFields( + configuration: ManagedClientConfiguration, +) { + const confidential = configuration.clientType === "web"; + return { + applicationType: "web" as const, + tokenEndpointAuthMethod: confidential + ? ("client_secret_basic" as const) + : ("none" as const), + grantTypes: confidential + ? ["authorization_code", "refresh_token"] + : ["authorization_code"], + responseTypes: ["code"], + requirePkce: true, + allowRefreshTokenForPublicClient: false, + autoConsent: false, + }; +} - if (tokenEndpointAuthMethod === "client_secret_basic" && !clientSecretDigest) { - throw new Error(`oidc client ${clientId}: clientSecretDigest is required for client_secret_basic`); - } - if (tokenEndpointAuthMethod === "none" && clientSecretDigest) { - throw new Error(`oidc client ${clientId}: clientSecretDigest must be omitted when tokenEndpointAuthMethod=none`); +function parseStatus( + value: unknown, + clientId: string, +): OidcClientRecord["status"] { + if (value === undefined) { + return "active"; } - if (clientSecretDigest && !clientSecretDigest.startsWith("scrypt$")) { - throw new Error(`oidc client ${clientId}: clientSecretDigest must use scrypt digest format`); + if ( + value === "draft" || + value === "pending" || + value === "active" || + value === "disabled" || + value === "rejected" + ) { + return value; } + throw new ClientValidationError( + `oidc client ${clientId}: invalid status`, + "status", + ); +} - const redirectUris = parseStringArray(client.redirectUris, "redirectUris", clientId); - const postLogoutRedirectUris = parseStringArray( - client.postLogoutRedirectUris, - "postLogoutRedirectUris", - clientId +function parseBootstrapClient( + raw: unknown, + appEnv: string, + seen: Set, +): OidcClientRecord { + if (!isObject(raw)) { + throw new ClientValidationError("each oidc client item must be an object"); + } + const clientId = parseString( + raw["clientId"], + "clientId", + "oidc client ", + { max: 200 }, ); - for (const redirectUri of redirectUris) { - assertHttpsOrTestLoopbackHttp(redirectUri, "redirectUris", appEnv, clientId); + if (seen.has(clientId)) { + throw new ClientValidationError( + `duplicate oidc client clientId: ${clientId}`, + "clientId", + ); } - for (const postLogoutRedirectUri of postLogoutRedirectUris) { - assertHttpsOrTestLoopbackHttp(postLogoutRedirectUri, "postLogoutRedirectUris", appEnv, clientId); + seen.add(clientId); + if ( + raw["applicationType"] !== undefined && + raw["applicationType"] !== "web" + ) { + throw new ClientValidationError( + `oidc client ${clientId}: only web applicationType is supported`, + ); } - - const grantTypes = parseStringArray(client.grantTypes, "grantTypes", clientId, { - required: true, - defaultValue: - tokenEndpointAuthMethod === "none" ? ["authorization_code"] : ["authorization_code", "refresh_token"] - }); - const scopeWhitelist = parseScopeWhitelist(client.scopeWhitelist, clientId); - const allowRefreshTokenForPublicClient = parseBoolean( - client.allowRefreshTokenForPublicClient, - "allowRefreshTokenForPublicClient", - clientId, - false + const tokenMethod = raw["tokenEndpointAuthMethod"] ?? "client_secret_basic"; + if (tokenMethod !== "client_secret_basic" && tokenMethod !== "none") { + throw new ClientValidationError( + `oidc client ${clientId}: invalid tokenEndpointAuthMethod`, + ); + } + const clientType: ManagedClientType = tokenMethod === "none" ? "spa" : "web"; + const configuration = validateManagedClientConfiguration( + { + clientType, + displayName: raw["displayName"] ?? clientId, + description: raw["description"] ?? "", + redirectUris: raw["redirectUris"], + postLogoutRedirectUris: raw["postLogoutRedirectUris"] ?? [], + scopeWhitelist: raw["scopeWhitelist"], + }, + appEnv, + `oidc client ${clientId}`, ); + const digest = raw["clientSecretDigest"]; if ( - tokenEndpointAuthMethod === "none" && - grantTypes.includes("refresh_token") && - !allowRefreshTokenForPublicClient + clientType === "web" && + (typeof digest !== "string" || !digest.startsWith("scrypt$")) ) { - throw new Error( - `oidc client ${clientId}: allowRefreshTokenForPublicClient=true is required when public clients allow refresh_token` + throw new ClientValidationError( + `oidc client ${clientId}: clientSecretDigest is required and must use scrypt digest format`, + ); + } + if (clientType === "spa" && digest !== undefined) { + throw new ClientValidationError( + `oidc client ${clientId}: clientSecretDigest must be omitted for SPA clients`, + ); + } + const protocol = configurationToProtocolFields(configuration); + if ( + raw["grantTypes"] !== undefined && + JSON.stringify(raw["grantTypes"]) !== JSON.stringify(protocol.grantTypes) + ) { + throw new ClientValidationError( + `oidc client ${clientId}: grantTypes must be ${protocol.grantTypes.join(", ")}`, + "grantTypes", + ); + } + if ( + raw["responseTypes"] !== undefined && + JSON.stringify(raw["responseTypes"]) !== JSON.stringify(["code"]) + ) { + throw new ClientValidationError( + `oidc client ${clientId}: responseTypes must be code`, + "responseTypes", + ); + } + if (raw["requirePkce"] === false) { + throw new ClientValidationError( + `oidc client ${clientId}: requirePkce must be true`, + "requirePkce", + ); + } + if (raw["allowRefreshTokenForPublicClient"] === true) { + throw new ClientValidationError( + `oidc client ${clientId}: public client refresh tokens are not supported`, + "allowRefreshTokenForPublicClient", ); } - const now = new Date().toISOString(); return { clientId, - clientSecretDigest, - applicationType: parseApplicationType(client.applicationType, clientId), - tokenEndpointAuthMethod, - redirectUris, - postLogoutRedirectUris, - grantTypes, - responseTypes: parseStringArray(client.responseTypes, "responseTypes", clientId, { - required: true, - defaultValue: ["code"] - }), - scopeWhitelist, - requirePkce: parseBoolean(client.requirePkce, "requirePkce", clientId, true), - allowRefreshTokenForPublicClient, - autoConsent: parseBoolean(client.autoConsent, "autoConsent", clientId, false), - status: parseStatus(client.status, clientId), + clientSecretDigest: typeof digest === "string" ? digest : undefined, + displayName: configuration.displayName, + description: configuration.description, + ownerSubjectId: null, + ...protocol, + redirectUris: configuration.redirectUris, + postLogoutRedirectUris: configuration.postLogoutRedirectUris, + scopeWhitelist: configuration.scopeWhitelist, + autoConsent: raw["autoConsent"] === true, + status: parseStatus(raw["status"], clientId), createdAt: now, - updatedAt: now + updatedAt: now, + version: 1, }; } -export async function loadOidcClientsFromConfig(config: Pick) { +export async function loadOidcClientsFromConfig( + config: Pick, +) { let rawText: string; try { rawText = await readFile(config.oidcClientsConfigPath, "utf8"); } catch (error) { - const description = error instanceof Error ? error.message : String(error); + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } throw new Error( - `failed to read OIDC clients config (${config.oidcClientsConfigPath}): ${description}` + `failed to read OIDC clients config (${config.oidcClientsConfigPath}): ${error instanceof Error ? error.message : String(error)}`, ); } - let parsed: unknown; try { parsed = JSON.parse(rawText); } catch (error) { - const description = error instanceof Error ? error.message : String(error); throw new Error( - `failed to parse OIDC clients config (${config.oidcClientsConfigPath}) as JSON: ${description}` + `failed to parse OIDC clients config (${config.oidcClientsConfigPath}) as JSON: ${error instanceof Error ? error.message : String(error)}`, ); } - if (!isObject(parsed)) { - throw new Error(`OIDC clients config (${config.oidcClientsConfigPath}) must be a JSON object`); - } - const document = parsed as RawClientsDocument; - if (!Array.isArray(document.clients)) { - throw new Error(`OIDC clients config (${config.oidcClientsConfigPath}) must contain a "clients" array`); - } - if (document.clients.length === 0) { - throw new Error(`OIDC clients config (${config.oidcClientsConfigPath}) must contain at least one client`); + if (!isObject(parsed) || !Array.isArray(parsed["clients"])) { + throw new Error( + `OIDC clients config (${config.oidcClientsConfigPath}) must contain a "clients" array`, + ); } - const seenClientIds = new Set(); - return document.clients.map((entry) => parseClient(entry, config.appEnv, seenClientIds)); + const seen = new Set(); + return parsed["clients"].map((entry) => + parseBootstrapClient(entry, config.appEnv, seen), + ); } -export async function upsertOidcClientsFromConfig( +export async function initializeOidcClientsFromConfig( store: OidcPersistence, - config: Pick + config: Pick, ) { + if ((await store.countOidcClients()) > 0) { + return { imported: false, count: 0 }; + } const clients = await loadOidcClientsFromConfig(config); - for (const client of clients) { - await store.upsertOidcClient(client); + if (clients.length === 0) { + return { imported: false, count: 0 }; } - return clients; + const now = new Date().toISOString(); + const audits: OidcClientAuditRecord[] = clients.map((client) => ({ + clientId: client.clientId, + actorSubjectId: null, + action: "client.initialized", + changedFields: [], + newStatus: client.status, + createdAt: now, + })); + return store.initializeOidcClientsIfEmpty(clients, audits); } diff --git a/src/oidc/provider.ts b/src/oidc/provider.ts index 9c3a0a9..9fcc2f7 100644 --- a/src/oidc/provider.ts +++ b/src/oidc/provider.ts @@ -19,7 +19,7 @@ import { resolveTrustedKoaRequestIp } from "../request-ip.js"; import { createAdapter } from "./adapter.js"; import { verifyClientSecretDigest } from "../crypto.js"; import { randomId, parseScope, escapeHtml } from "../utils.js"; -import { upsertOidcClientsFromConfig } from "./client-config.js"; +import { initializeOidcClientsFromConfig } from "./client-config.js"; import type { EmailSender, SendVerificationCodeInput } from "../email/email-sender.js"; import { ResendEmailSender } from "../email/resend-email-sender.js"; @@ -505,7 +505,7 @@ export async function createOidcServices( rateLimitService: RateLimitService, providedEmailSender?: EmailSender ): Promise { - await upsertOidcClientsFromConfig(store, config); + await initializeOidcClientsFromConfig(store, config); await ensureSigningKey(store, config); const providerRegistry = new ProviderRegistry( diff --git a/src/persistence/contracts.ts b/src/persistence/contracts.ts index 22da282..e60b8fd 100644 --- a/src/persistence/contracts.ts +++ b/src/persistence/contracts.ts @@ -3,14 +3,17 @@ import type { IdentityStore, SubjectIdentityRecord, SubjectProfileRecord, - SubjectRecord + SubjectRecord, } from "../identity/index.js"; import type { OidcScope } from "../shared/oidc-contracts.js"; export type OidcClientRecord = { clientId: string; clientSecretDigest: string | undefined; - applicationType: "web" | "native" | "service"; + displayName: string; + description: string; + ownerSubjectId: string | null; + applicationType: "web"; tokenEndpointAuthMethod: "client_secret_basic" | "none"; redirectUris: string[]; postLogoutRedirectUris: string[]; @@ -20,9 +23,42 @@ export type OidcClientRecord = { requirePkce: boolean; allowRefreshTokenForPublicClient: boolean; autoConsent: boolean; - status: "active" | "disabled"; + status: "draft" | "pending" | "active" | "disabled" | "rejected"; + rejectionReason?: string | undefined; createdAt: string; updatedAt: string; + version: number; +}; + +export type OidcClientAuditAction = + | "client.initialized" + | "client.created" + | "client.updated" + | "client.submitted" + | "client.approved" + | "client.rejected" + | "client.disabled" + | "client.secret_generated"; + +export type OidcClientAuditRecord = { + id?: number; + clientId: string; + actorSubjectId: string | null; + action: OidcClientAuditAction; + changedFields: string[]; + previousStatus?: OidcClientRecord["status"] | undefined; + newStatus?: OidcClientRecord["status"] | undefined; + reason?: string | undefined; + sourceIp?: string | undefined; + createdAt: string; +}; + +export type ManagementSessionRecord = { + tokenHash: string; + subjectId: string; + createdAt: string; + lastSeenAt: string; + expiresAt: string; }; export type PendingInteractionLogin = { @@ -51,25 +87,65 @@ export type OidcSigningKeyRecord = { export interface IdentityRepository extends IdentityStore { findSubject(subjectId: string): Promise; - findIdentity(provider: string, identityKey: string): Promise; + findIdentity( + provider: string, + identityKey: string, + ): Promise; createSubjectWithIdentity( subject: SubjectRecord, - identity: SubjectIdentityRecord + identity: SubjectIdentityRecord, ): Promise; updateIdentity( provider: string, identityKey: string, - patch: Pick + patch: Pick< + SubjectIdentityRecord, + "schoolUid" | "currentStudentStatus" | "school" | "updatedAt" + >, ): Promise; getProfile(subjectId: string): Promise; upsertProfile(profile: SubjectProfileRecord): Promise; - findPrincipalBySubjectId(subjectId: string): Promise; + findPrincipalBySubjectId( + subjectId: string, + ): Promise; } export interface OidcClientRepository { upsertOidcClient(client: OidcClientRecord): Promise; + countOidcClients(): Promise; + initializeOidcClientsIfEmpty( + clients: OidcClientRecord[], + audits: OidcClientAuditRecord[], + ): Promise<{ imported: boolean; count: number }>; + createOidcClient( + client: OidcClientRecord, + audits: OidcClientAuditRecord[], + ownerLimits?: { + maxNonDisabledClients: number; + maxPendingClients: number; + }, + ): Promise; + updateOidcClient( + client: OidcClientRecord, + expectedVersion: number, + audit: OidcClientAuditRecord, + ): Promise; findOidcClient(clientId: string): Promise; listActiveOidcClients(): Promise; + listOidcClientsByOwner(ownerSubjectId: string): Promise; + listOidcClients(): Promise; + listPendingOidcClients(): Promise; + listOidcClientAuditLogs(clientId?: string): Promise; +} + +export interface ManagementSessionRepository { + createManagementSession(session: ManagementSessionRecord): Promise; + findManagementSession( + tokenHash: string, + ): Promise; + touchManagementSession(tokenHash: string, lastSeenAt: string): Promise; + deleteManagementSession(tokenHash: string): Promise; + deleteExpiredManagementSessions(now: string): Promise; } export interface OidcArtifactRepository { @@ -77,24 +153,36 @@ export interface OidcArtifactRepository { id: string, kind: string, payload: Record, - expiresIn: number + expiresIn: number, ): Promise; findArtifact(id: string): Promise | undefined>; destroyArtifact(id: string): Promise; consumeArtifact(id: string): Promise; - findArtifactByUid(uid: string, kind?: string): Promise | undefined>; - findArtifactByUserCode(userCode: string): Promise | undefined>; + findArtifactByUid( + uid: string, + kind?: string, + ): Promise | undefined>; + findArtifactByUserCode( + userCode: string, + ): Promise | undefined>; revokeArtifactsByGrantId(grantId: string): Promise; - saveInteractionLogin(uid: string, value: PendingInteractionLogin): Promise; - getInteractionLogin(uid: string): Promise; + saveInteractionLogin( + uid: string, + value: PendingInteractionLogin, + ): Promise; + getInteractionLogin( + uid: string, + ): Promise; deleteInteractionLogin(uid: string): Promise; } export interface SigningKeyRepository { upsertSigningKey(key: OidcSigningKeyRecord): Promise; - listSigningKeys(statuses?: Array): Promise; + listSigningKeys( + statuses?: Array, + ): Promise; loadPrivateSigningJwks( - statuses?: Array + statuses?: Array, ): Promise>; } @@ -110,9 +198,11 @@ export interface PersistenceRuntime { } export interface OidcPersistence - extends PersistenceRuntime, + extends + PersistenceRuntime, IdentityRepository, OidcClientRepository, + ManagementSessionRepository, OidcArtifactRepository, SigningKeyRepository, JwkCipherService {} diff --git a/src/persistence/management-session.repository.ts b/src/persistence/management-session.repository.ts new file mode 100644 index 0000000..e4f050d --- /dev/null +++ b/src/persistence/management-session.repository.ts @@ -0,0 +1,105 @@ +import type { Pool } from "pg"; +import type { + ManagementSessionRecord, + ManagementSessionRepository, +} from "./contracts.js"; + +export class ManagementSessionRepositoryImpl implements ManagementSessionRepository { + private readonly sessions = new Map(); + + constructor(private readonly poolProvider: () => Pool | undefined) {} + + async createManagementSession( + session: ManagementSessionRecord, + ): Promise { + const pool = this.poolProvider(); + if (!pool) { + this.sessions.set(session.tokenHash, session); + return; + } + await pool.query( + `insert into management_sessions ( + token_hash, subject_id, created_at, last_seen_at, expires_at + ) values ($1, $2, $3::timestamptz, $4::timestamptz, $5::timestamptz)`, + [ + session.tokenHash, + session.subjectId, + session.createdAt, + session.lastSeenAt, + session.expiresAt, + ], + ); + } + + async findManagementSession( + tokenHash: string, + ): Promise { + const pool = this.poolProvider(); + if (!pool) { + return this.sessions.get(tokenHash) ?? null; + } + const result = await pool.query( + "select * from management_sessions where token_hash = $1 limit 1", + [tokenHash], + ); + const row = result.rows[0]; + if (!row) { + return null; + } + return { + tokenHash: row.token_hash, + subjectId: row.subject_id, + createdAt: row.created_at.toISOString(), + lastSeenAt: row.last_seen_at.toISOString(), + expiresAt: row.expires_at.toISOString(), + }; + } + + async touchManagementSession( + tokenHash: string, + lastSeenAt: string, + ): Promise { + const pool = this.poolProvider(); + if (!pool) { + const current = this.sessions.get(tokenHash); + if (current) { + this.sessions.set(tokenHash, { ...current, lastSeenAt }); + } + return; + } + await pool.query( + "update management_sessions set last_seen_at = $2::timestamptz where token_hash = $1", + [tokenHash, lastSeenAt], + ); + } + + async deleteManagementSession(tokenHash: string): Promise { + const pool = this.poolProvider(); + if (!pool) { + this.sessions.delete(tokenHash); + return; + } + await pool.query("delete from management_sessions where token_hash = $1", [ + tokenHash, + ]); + } + + async deleteExpiredManagementSessions(now: string): Promise { + const pool = this.poolProvider(); + if (!pool) { + let deleted = 0; + for (const [tokenHash, session] of this.sessions) { + if (session.expiresAt <= now) { + this.sessions.delete(tokenHash); + deleted += 1; + } + } + return deleted; + } + const result = await pool.query( + "delete from management_sessions where expires_at <= $1::timestamptz", + [now], + ); + return result.rowCount ?? 0; + } +} diff --git a/src/persistence/oidc-client.repository.ts b/src/persistence/oidc-client.repository.ts index 198fdef..e763d18 100644 --- a/src/persistence/oidc-client.repository.ts +++ b/src/persistence/oidc-client.repository.ts @@ -1,8 +1,15 @@ -import type { Pool } from "pg"; -import type { OidcClientRecord, OidcClientRepository } from "./contracts.js"; +import type { Pool, PoolClient } from "pg"; +import type { + OidcClientAuditRecord, + OidcClientRecord, + OidcClientRepository, +} from "./contracts.js"; + +type Queryable = Pick | Pick; export class OidcClientRepositoryImpl implements OidcClientRepository { private readonly clients = new Map(); + private readonly audits: OidcClientAuditRecord[] = []; constructor(private readonly poolProvider: () => Pool | undefined) {} @@ -13,101 +20,437 @@ export class OidcClientRepositoryImpl implements OidcClientRepository { return client; } await pool.query( - ` - insert into oidc_clients ( - client_id, - client_secret_hash, - application_type, - token_endpoint_auth_method, - redirect_uris, - post_logout_redirect_uris, - grant_types, - response_types, - scope_whitelist, - require_pkce, - allow_refresh_token_for_public_client, - auto_consent, - status, - created_at, - updated_at - ) - values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10, $11, $12, $13, $14::timestamptz, $15::timestamptz) - on conflict (client_id) do update - set client_secret_hash = excluded.client_secret_hash, - application_type = excluded.application_type, - token_endpoint_auth_method = excluded.token_endpoint_auth_method, - redirect_uris = excluded.redirect_uris, - post_logout_redirect_uris = excluded.post_logout_redirect_uris, - grant_types = excluded.grant_types, - response_types = excluded.response_types, - scope_whitelist = excluded.scope_whitelist, - require_pkce = excluded.require_pkce, - allow_refresh_token_for_public_client = excluded.allow_refresh_token_for_public_client, - auto_consent = excluded.auto_consent, - status = excluded.status, - updated_at = excluded.updated_at - `, - [ - client.clientId, - client.clientSecretDigest ?? null, - client.applicationType, - client.tokenEndpointAuthMethod, - JSON.stringify(client.redirectUris), - JSON.stringify(client.postLogoutRedirectUris), - JSON.stringify(client.grantTypes), - JSON.stringify(client.responseTypes), - JSON.stringify(client.scopeWhitelist), - client.requirePkce, - client.allowRefreshTokenForPublicClient, - client.autoConsent, - client.status, - client.createdAt, - client.updatedAt - ] + `${this.insertSql()} + on conflict (client_id) do update set + client_secret_hash = excluded.client_secret_hash, + display_name = excluded.display_name, + description = excluded.description, + owner_subject_id = excluded.owner_subject_id, + application_type = excluded.application_type, + token_endpoint_auth_method = excluded.token_endpoint_auth_method, + redirect_uris = excluded.redirect_uris, + post_logout_redirect_uris = excluded.post_logout_redirect_uris, + grant_types = excluded.grant_types, + response_types = excluded.response_types, + scope_whitelist = excluded.scope_whitelist, + require_pkce = excluded.require_pkce, + allow_refresh_token_for_public_client = excluded.allow_refresh_token_for_public_client, + auto_consent = excluded.auto_consent, + status = excluded.status, + rejection_reason = excluded.rejection_reason, + updated_at = excluded.updated_at, + version = excluded.version`, + this.clientValues(client), ); return client; } + async countOidcClients(): Promise { + const pool = this.poolProvider(); + if (!pool) { + return this.clients.size; + } + const result = await pool.query( + "select count(*)::int as count from oidc_clients", + ); + return Number(result.rows[0]?.count ?? 0); + } + + async initializeOidcClientsIfEmpty( + clients: OidcClientRecord[], + audits: OidcClientAuditRecord[], + ): Promise<{ imported: boolean; count: number }> { + const pool = this.poolProvider(); + if (!pool) { + if (this.clients.size > 0) { + return { imported: false, count: 0 }; + } + for (const client of clients) { + this.clients.set(client.clientId, client); + } + this.audits.push(...audits); + return { imported: true, count: clients.length }; + } + + const connection = await pool.connect(); + try { + await connection.query("begin"); + await connection.query( + "select pg_advisory_xact_lock(hashtext('cqut-auth:oidc-client-initialize'))", + ); + const countResult = await connection.query( + "select count(*)::int as count from oidc_clients", + ); + if (Number(countResult.rows[0]?.count ?? 0) > 0) { + await connection.query("commit"); + return { imported: false, count: 0 }; + } + for (const client of clients) { + await connection.query(this.insertSql(), this.clientValues(client)); + } + for (const audit of audits) { + await this.insertAudit(connection, audit); + } + await connection.query("commit"); + return { imported: true, count: clients.length }; + } catch (error) { + await connection.query("rollback"); + throw error; + } finally { + connection.release(); + } + } + + async createOidcClient( + client: OidcClientRecord, + audits: OidcClientAuditRecord[], + ownerLimits?: { + maxNonDisabledClients: number; + maxPendingClients: number; + }, + ): Promise { + const pool = this.poolProvider(); + if (!pool) { + if (this.clients.has(client.clientId)) { + throw new Error(`oidc client already exists: ${client.clientId}`); + } + if (ownerLimits && this.ownerQuotaExceeded(client, ownerLimits)) { + return null; + } + this.clients.set(client.clientId, client); + this.audits.push(...audits); + return client; + } + const connection = await pool.connect(); + try { + await connection.query("begin"); + if (ownerLimits && client.ownerSubjectId) { + await connection.query("select pg_advisory_xact_lock(hashtext($1))", [ + `cqut-auth:oidc-client-owner:${client.ownerSubjectId}`, + ]); + const count = await connection.query( + `select + count(*) filter (where status <> 'disabled')::int as non_disabled, + count(*) filter (where status = 'pending')::int as pending + from oidc_clients where owner_subject_id = $1`, + [client.ownerSubjectId], + ); + if ( + Number(count.rows[0]?.non_disabled ?? 0) >= + ownerLimits.maxNonDisabledClients || + Number(count.rows[0]?.pending ?? 0) >= ownerLimits.maxPendingClients + ) { + await connection.query("rollback"); + return null; + } + } + await connection.query(this.insertSql(), this.clientValues(client)); + for (const audit of audits) { + await this.insertAudit(connection, audit); + } + await connection.query("commit"); + return client; + } catch (error) { + await connection.query("rollback"); + throw error; + } finally { + connection.release(); + } + } + + async updateOidcClient( + client: OidcClientRecord, + expectedVersion: number, + audit: OidcClientAuditRecord, + ): Promise { + const pool = this.poolProvider(); + if (!pool) { + const current = this.clients.get(client.clientId); + if (!current || current.version !== expectedVersion) { + return null; + } + this.clients.set(client.clientId, client); + this.audits.push(audit); + return client; + } + const connection = await pool.connect(); + try { + await connection.query("begin"); + const result = await connection.query( + `update oidc_clients set + display_name = $3, + description = $4, + application_type = $5, + token_endpoint_auth_method = $6, + redirect_uris = $7::jsonb, + post_logout_redirect_uris = $8::jsonb, + grant_types = $9::jsonb, + response_types = $10::jsonb, + scope_whitelist = $11::jsonb, + require_pkce = $12, + allow_refresh_token_for_public_client = $13, + auto_consent = $14, + status = $15, + rejection_reason = $16, + updated_at = $17::timestamptz, + version = $18 + where client_id = $1 and version = $2 + returning *`, + [ + client.clientId, + expectedVersion, + client.displayName, + client.description, + client.applicationType, + client.tokenEndpointAuthMethod, + JSON.stringify(client.redirectUris), + JSON.stringify(client.postLogoutRedirectUris), + JSON.stringify(client.grantTypes), + JSON.stringify(client.responseTypes), + JSON.stringify(client.scopeWhitelist), + client.requirePkce, + client.allowRefreshTokenForPublicClient, + client.autoConsent, + client.status, + client.rejectionReason ?? null, + client.updatedAt, + client.version, + ], + ); + if (!result.rows[0]) { + await connection.query("rollback"); + return null; + } + await this.insertAudit(connection, audit); + await connection.query("commit"); + return this.mapClientRow(result.rows[0]); + } catch (error) { + await connection.query("rollback"); + throw error; + } finally { + connection.release(); + } + } + async findOidcClient(clientId: string): Promise { const pool = this.poolProvider(); if (!pool) { return this.clients.get(clientId) ?? null; } - const result = await pool.query("select * from oidc_clients where client_id = $1 limit 1", [clientId]); + const result = await pool.query( + "select * from oidc_clients where client_id = $1 limit 1", + [clientId], + ); return this.mapClientRow(result.rows[0]); } async listActiveOidcClients(): Promise { + return this.listWhere("status = 'active'", []); + } + + async listOidcClientsByOwner( + ownerSubjectId: string, + ): Promise { const pool = this.poolProvider(); if (!pool) { - return [...this.clients.values()].filter((client) => client.status === "active"); + return [...this.clients.values()] + .filter((client) => client.ownerSubjectId === ownerSubjectId) + .sort(this.sortClients); } - const result = await pool.query("select * from oidc_clients where status = 'active' order by client_id asc"); - return result.rows - .map((row: Record) => this.mapClientRow(row)) - .filter(Boolean) as OidcClientRecord[]; + return this.listWhere("owner_subject_id = $1", [ownerSubjectId]); } - private mapClientRow(row: Record | undefined): OidcClientRecord | null { + async listOidcClients(): Promise { + return this.listWhere("true", []); + } + + async listPendingOidcClients(): Promise { + return this.listWhere("status = 'pending'", []); + } + + async listOidcClientAuditLogs( + clientId?: string, + ): Promise { + const pool = this.poolProvider(); + if (!pool) { + return this.audits + .filter((audit) => !clientId || audit.clientId === clientId) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + const result = clientId + ? await pool.query( + "select * from oidc_client_audit_logs where client_id = $1 order by created_at asc, id asc", + [clientId], + ) + : await pool.query( + "select * from oidc_client_audit_logs order by created_at asc, id asc", + ); + return result.rows.map((row: Record) => ({ + id: Number(row["id"]), + clientId: String(row["client_id"]), + actorSubjectId: (row["actor_subject_id"] as string | null) ?? null, + action: row["action"] as OidcClientAuditRecord["action"], + changedFields: row["changed_fields"] as string[], + previousStatus: + (row["previous_status"] as OidcClientRecord["status"] | null) ?? + undefined, + newStatus: + (row["new_status"] as OidcClientRecord["status"] | null) ?? undefined, + reason: (row["reason"] as string | null) ?? undefined, + sourceIp: (row["source_ip"] as string | null) ?? undefined, + createdAt: this.toIso(row["created_at"]), + })); + } + + private async listWhere(where: string, values: unknown[]) { + const pool = this.poolProvider(); + if (!pool) { + const clients = [...this.clients.values()]; + if (where === "status = 'active'") { + return clients + .filter((client) => client.status === "active") + .sort(this.sortClients); + } + if (where === "status = 'pending'") { + return clients + .filter((client) => client.status === "pending") + .sort(this.sortClients); + } + return clients.sort(this.sortClients); + } + const result = await pool.query( + `select * from oidc_clients where ${where} order by updated_at desc, client_id asc`, + values, + ); + return result.rows.map((row: Record) => + this.mapClientRow(row), + ) as OidcClientRecord[]; + } + + private readonly sortClients = ( + left: OidcClientRecord, + right: OidcClientRecord, + ) => + right.updatedAt.localeCompare(left.updatedAt) || + left.clientId.localeCompare(right.clientId); + + private insertSql() { + return `insert into oidc_clients ( + client_id, client_secret_hash, display_name, description, owner_subject_id, + application_type, token_endpoint_auth_method, redirect_uris, post_logout_redirect_uris, + grant_types, response_types, scope_whitelist, require_pkce, + allow_refresh_token_for_public_client, auto_consent, status, rejection_reason, + created_at, updated_at, version + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, + $10::jsonb, $11::jsonb, $12::jsonb, $13, $14, $15, $16, $17, + $18::timestamptz, $19::timestamptz, $20 + )`; + } + + private clientValues(client: OidcClientRecord) { + return [ + client.clientId, + client.clientSecretDigest ?? null, + client.displayName, + client.description, + client.ownerSubjectId, + client.applicationType, + client.tokenEndpointAuthMethod, + JSON.stringify(client.redirectUris), + JSON.stringify(client.postLogoutRedirectUris), + JSON.stringify(client.grantTypes), + JSON.stringify(client.responseTypes), + JSON.stringify(client.scopeWhitelist), + client.requirePkce, + client.allowRefreshTokenForPublicClient, + client.autoConsent, + client.status, + client.rejectionReason ?? null, + client.createdAt, + client.updatedAt, + client.version, + ]; + } + + private async insertAudit( + queryable: Queryable, + audit: OidcClientAuditRecord, + ) { + await queryable.query( + `insert into oidc_client_audit_logs ( + client_id, actor_subject_id, action, changed_fields, previous_status, + new_status, reason, source_ip, created_at + ) values ($1, $2, $3, $4::jsonb, $5, $6, $7, $8, $9::timestamptz)`, + [ + audit.clientId, + audit.actorSubjectId, + audit.action, + JSON.stringify(audit.changedFields), + audit.previousStatus ?? null, + audit.newStatus ?? null, + audit.reason ?? null, + audit.sourceIp ?? null, + audit.createdAt, + ], + ); + } + + private mapClientRow( + row: Record | undefined, + ): OidcClientRecord | null { if (!row) { return null; } return { clientId: String(row["client_id"]), - clientSecretDigest: (row["client_secret_hash"] as string | null) ?? undefined, - applicationType: row["application_type"] as OidcClientRecord["applicationType"], - tokenEndpointAuthMethod: row["token_endpoint_auth_method"] as OidcClientRecord["tokenEndpointAuthMethod"], + clientSecretDigest: + (row["client_secret_hash"] as string | null) ?? undefined, + displayName: String(row["display_name"]), + description: String(row["description"] ?? ""), + ownerSubjectId: (row["owner_subject_id"] as string | null) ?? null, + applicationType: "web", + tokenEndpointAuthMethod: row[ + "token_endpoint_auth_method" + ] as OidcClientRecord["tokenEndpointAuthMethod"], redirectUris: row["redirect_uris"] as string[], postLogoutRedirectUris: row["post_logout_redirect_uris"] as string[], grantTypes: row["grant_types"] as string[], responseTypes: row["response_types"] as string[], - scopeWhitelist: row["scope_whitelist"] as OidcClientRecord["scopeWhitelist"], + scopeWhitelist: row[ + "scope_whitelist" + ] as OidcClientRecord["scopeWhitelist"], requirePkce: Boolean(row["require_pkce"]), - allowRefreshTokenForPublicClient: Boolean(row["allow_refresh_token_for_public_client"]), + allowRefreshTokenForPublicClient: Boolean( + row["allow_refresh_token_for_public_client"], + ), autoConsent: Boolean(row["auto_consent"]), status: row["status"] as OidcClientRecord["status"], - createdAt: (row["created_at"] as Date).toISOString(), - updatedAt: (row["updated_at"] as Date).toISOString() + rejectionReason: (row["rejection_reason"] as string | null) ?? undefined, + createdAt: this.toIso(row["created_at"]), + updatedAt: this.toIso(row["updated_at"]), + version: Number(row["version"]), }; } + + private toIso(value: unknown) { + return value instanceof Date + ? value.toISOString() + : new Date(String(value)).toISOString(); + } + + private ownerQuotaExceeded( + client: OidcClientRecord, + limits: { maxNonDisabledClients: number; maxPendingClients: number }, + ) { + const owned = [...this.clients.values()].filter( + (candidate) => candidate.ownerSubjectId === client.ownerSubjectId, + ); + return ( + owned.filter((candidate) => candidate.status !== "disabled").length >= + limits.maxNonDisabledClients || + owned.filter((candidate) => candidate.status === "pending").length >= + limits.maxPendingClients + ); + } } diff --git a/src/persistence/persistence.ts b/src/persistence/persistence.ts index a2e6b0c..1d1eabc 100644 --- a/src/persistence/persistence.ts +++ b/src/persistence/persistence.ts @@ -2,25 +2,28 @@ import type { AuthenticatedPrincipal, SubjectIdentityRecord, SubjectProfileRecord, - SubjectRecord + SubjectRecord, } from "../identity/index.js"; import { Pool } from "pg"; import type { OidcOpConfig } from "../config.js"; import { ensureArtifactCleanupJob, - ArtifactCleanupConfigurationError + ArtifactCleanupConfigurationError, } from "./artifact-cleanup.scheduler.js"; import { ArtifactPayloadCipherServiceImpl } from "./artifact-payload-cipher.service.js"; import type { + ManagementSessionRecord, + OidcClientAuditRecord, OidcClientRecord, OidcPersistence, OidcSigningKeyRecord, - PendingInteractionLogin + PendingInteractionLogin, } from "./contracts.js"; import { IdentityRepositoryImpl } from "./identity.repository.js"; import { JwkCipherServiceImpl } from "./jwk-cipher.service.js"; import { OidcArtifactRepositoryImpl } from "./oidc-artifact.repository.js"; import { OidcClientRepositoryImpl } from "./oidc-client.repository.js"; +import { ManagementSessionRepositoryImpl } from "./management-session.repository.js"; import { SigningKeyRepositoryImpl } from "./signing-key.repository.js"; export class OidcPersistenceImpl implements OidcPersistence { @@ -30,17 +33,23 @@ export class OidcPersistenceImpl implements OidcPersistence { private readonly artifactPayloadCipherService: ArtifactPayloadCipherServiceImpl; private readonly identityRepository: IdentityRepositoryImpl; private readonly oidcClientRepository: OidcClientRepositoryImpl; + private readonly managementSessionRepository: ManagementSessionRepositoryImpl; private readonly oidcArtifactRepository: OidcArtifactRepositoryImpl; private readonly signingKeyRepository: SigningKeyRepositoryImpl; constructor(private readonly config: OidcOpConfig) { const poolProvider = () => this.pool; - this.jwkCipherService = new JwkCipherServiceImpl(config.keyEncryptionSecret); + this.jwkCipherService = new JwkCipherServiceImpl( + config.keyEncryptionSecret, + ); this.artifactPayloadCipherService = new ArtifactPayloadCipherServiceImpl( - config.artifactEncryptionSecret + config.artifactEncryptionSecret, ); this.identityRepository = new IdentityRepositoryImpl(poolProvider); this.oidcClientRepository = new OidcClientRepositoryImpl(poolProvider); + this.managementSessionRepository = new ManagementSessionRepositoryImpl( + poolProvider, + ); this.oidcArtifactRepository = new OidcArtifactRepositoryImpl( poolProvider, config.interactionTtlSeconds, @@ -48,17 +57,22 @@ export class OidcPersistenceImpl implements OidcPersistence { enabled: config.artifactOpportunisticCleanupEnabled, sampleRate: config.artifactOpportunisticCleanupSampleRate, batchSize: config.artifactOpportunisticCleanupBatchSize, - minIntervalSeconds: config.artifactOpportunisticCleanupIntervalSeconds + minIntervalSeconds: config.artifactOpportunisticCleanupIntervalSeconds, }, - this.artifactPayloadCipherService + this.artifactPayloadCipherService, + ); + this.signingKeyRepository = new SigningKeyRepositoryImpl( + poolProvider, + this.jwkCipherService, ); - this.signingKeyRepository = new SigningKeyRepositoryImpl(poolProvider, this.jwkCipherService); } async init() { if (!this.config.databaseUrl) { if (this.config.allowInMemoryStore) { - this.logger.warn("DATABASE_URL not configured for oidc-op, using in-memory store"); + this.logger.warn( + "DATABASE_URL not configured for oidc-op, using in-memory store", + ); return; } throw new Error("DATABASE_URL is required for oidc-op"); @@ -66,14 +80,14 @@ export class OidcPersistenceImpl implements OidcPersistence { try { this.pool = new Pool({ - connectionString: this.config.databaseUrl + connectionString: this.config.databaseUrl, }); await this.pool.query("select 1"); await this.ensureSchema(); await ensureArtifactCleanupJob(this.pool, { enabled: this.config.artifactCleanupEnabled, schedule: this.config.artifactCleanupCron, - batchSize: this.config.artifactCleanupBatchSize + batchSize: this.config.artifactCleanupBatchSize, }); } catch (error) { await this.pool?.end().catch(() => undefined); @@ -85,7 +99,7 @@ export class OidcPersistenceImpl implements OidcPersistence { throw error; } this.logger.warn( - `database unavailable for oidc-op, using in-memory store: ${error instanceof Error ? error.message : "unknown error"}` + `database unavailable for oidc-op, using in-memory store: ${error instanceof Error ? error.message : "unknown error"}`, ); } } @@ -114,13 +128,16 @@ export class OidcPersistenceImpl implements OidcPersistence { return this.identityRepository.findSubject(subjectId); } - async findIdentity(provider: string, identityKey: string): Promise { + async findIdentity( + provider: string, + identityKey: string, + ): Promise { return this.identityRepository.findIdentity(provider, identityKey); } async createSubjectWithIdentity( subject: SubjectRecord, - identity: SubjectIdentityRecord + identity: SubjectIdentityRecord, ): Promise { return this.identityRepository.createSubjectWithIdentity(subject, identity); } @@ -128,7 +145,10 @@ export class OidcPersistenceImpl implements OidcPersistence { async updateIdentity( provider: string, identityKey: string, - patch: Pick + patch: Pick< + SubjectIdentityRecord, + "schoolUid" | "currentStudentStatus" | "school" | "updatedAt" + >, ): Promise { return this.identityRepository.updateIdentity(provider, identityKey, patch); } @@ -137,11 +157,15 @@ export class OidcPersistenceImpl implements OidcPersistence { return this.identityRepository.getProfile(subjectId); } - async upsertProfile(profile: SubjectProfileRecord): Promise { + async upsertProfile( + profile: SubjectProfileRecord, + ): Promise { return this.identityRepository.upsertProfile(profile); } - async findPrincipalBySubjectId(subjectId: string): Promise { + async findPrincipalBySubjectId( + subjectId: string, + ): Promise { return this.identityRepository.findPrincipalBySubjectId(subjectId); } @@ -149,6 +173,47 @@ export class OidcPersistenceImpl implements OidcPersistence { return this.oidcClientRepository.upsertOidcClient(client); } + async countOidcClients(): Promise { + return this.oidcClientRepository.countOidcClients(); + } + + async initializeOidcClientsIfEmpty( + clients: OidcClientRecord[], + audits: OidcClientAuditRecord[], + ) { + return this.oidcClientRepository.initializeOidcClientsIfEmpty( + clients, + audits, + ); + } + + async createOidcClient( + client: OidcClientRecord, + audits: OidcClientAuditRecord[], + ownerLimits?: { + maxNonDisabledClients: number; + maxPendingClients: number; + }, + ) { + return this.oidcClientRepository.createOidcClient( + client, + audits, + ownerLimits, + ); + } + + async updateOidcClient( + client: OidcClientRecord, + expectedVersion: number, + audit: OidcClientAuditRecord, + ) { + return this.oidcClientRepository.updateOidcClient( + client, + expectedVersion, + audit, + ); + } + async findOidcClient(clientId: string): Promise { return this.oidcClientRepository.findOidcClient(clientId); } @@ -157,13 +222,59 @@ export class OidcPersistenceImpl implements OidcPersistence { return this.oidcClientRepository.listActiveOidcClients(); } + async listOidcClientsByOwner(ownerSubjectId: string) { + return this.oidcClientRepository.listOidcClientsByOwner(ownerSubjectId); + } + + async listOidcClients() { + return this.oidcClientRepository.listOidcClients(); + } + + async listPendingOidcClients() { + return this.oidcClientRepository.listPendingOidcClients(); + } + + async listOidcClientAuditLogs(clientId?: string) { + return this.oidcClientRepository.listOidcClientAuditLogs(clientId); + } + + async createManagementSession(session: ManagementSessionRecord) { + return this.managementSessionRepository.createManagementSession(session); + } + + async findManagementSession(tokenHash: string) { + return this.managementSessionRepository.findManagementSession(tokenHash); + } + + async touchManagementSession(tokenHash: string, lastSeenAt: string) { + return this.managementSessionRepository.touchManagementSession( + tokenHash, + lastSeenAt, + ); + } + + async deleteManagementSession(tokenHash: string) { + return this.managementSessionRepository.deleteManagementSession(tokenHash); + } + + async deleteExpiredManagementSessions(now: string) { + return this.managementSessionRepository.deleteExpiredManagementSessions( + now, + ); + } + async upsertArtifact( id: string, kind: string, payload: Record, - expiresIn: number + expiresIn: number, ): Promise { - return this.oidcArtifactRepository.upsertArtifact(id, kind, payload, expiresIn); + return this.oidcArtifactRepository.upsertArtifact( + id, + kind, + payload, + expiresIn, + ); } async findArtifact(id: string): Promise | undefined> { @@ -178,11 +289,16 @@ export class OidcPersistenceImpl implements OidcPersistence { return this.oidcArtifactRepository.consumeArtifact(id); } - async findArtifactByUid(uid: string, kind?: string): Promise | undefined> { + async findArtifactByUid( + uid: string, + kind?: string, + ): Promise | undefined> { return this.oidcArtifactRepository.findArtifactByUid(uid, kind); } - async findArtifactByUserCode(userCode: string): Promise | undefined> { + async findArtifactByUserCode( + userCode: string, + ): Promise | undefined> { return this.oidcArtifactRepository.findArtifactByUserCode(userCode); } @@ -190,11 +306,16 @@ export class OidcPersistenceImpl implements OidcPersistence { return this.oidcArtifactRepository.revokeArtifactsByGrantId(grantId); } - async saveInteractionLogin(uid: string, value: PendingInteractionLogin): Promise { + async saveInteractionLogin( + uid: string, + value: PendingInteractionLogin, + ): Promise { return this.oidcArtifactRepository.saveInteractionLogin(uid, value); } - async getInteractionLogin(uid: string): Promise { + async getInteractionLogin( + uid: string, + ): Promise { return this.oidcArtifactRepository.getInteractionLogin(uid); } @@ -202,17 +323,21 @@ export class OidcPersistenceImpl implements OidcPersistence { return this.oidcArtifactRepository.deleteInteractionLogin(uid); } - async upsertSigningKey(key: OidcSigningKeyRecord): Promise { + async upsertSigningKey( + key: OidcSigningKeyRecord, + ): Promise { return this.signingKeyRepository.upsertSigningKey(key); } async listSigningKeys( - statuses: Array = ["active", "retiring"] + statuses: Array = ["active", "retiring"], ): Promise { return this.signingKeyRepository.listSigningKeys(statuses); } - async loadPrivateSigningJwks(statuses: Array = ["active", "retiring"]) { + async loadPrivateSigningJwks( + statuses: Array = ["active", "retiring"], + ) { return this.signingKeyRepository.loadPrivateSigningJwks(statuses); } @@ -264,7 +389,10 @@ export class OidcPersistenceImpl implements OidcPersistence { create table if not exists oidc_clients ( client_id text primary key, client_secret_hash text, - application_type text not null, + display_name text not null, + description text not null default '', + owner_subject_id text references subjects(subject_id), + application_type text not null check (application_type = 'web'), token_endpoint_auth_method text not null, redirect_uris jsonb not null, post_logout_redirect_uris jsonb not null default '[]'::jsonb, @@ -274,15 +402,52 @@ export class OidcPersistenceImpl implements OidcPersistence { require_pkce boolean not null default true, allow_refresh_token_for_public_client boolean not null default false, auto_consent boolean not null default false, - status text not null default 'active', + status text not null default 'pending' check (status in ('draft', 'pending', 'active', 'disabled', 'rejected')), + rejection_reason text, created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + updated_at timestamptz not null default now(), + version integer not null default 1 check (version > 0) ); `); + await this.assertFreshOidcClientSchema(); await this.pool.query(` - alter table oidc_clients - add column if not exists auto_consent boolean not null default false, - add column if not exists allow_refresh_token_for_public_client boolean not null default false; + create index if not exists idx_oidc_clients_owner_updated + on oidc_clients (owner_subject_id, updated_at desc); + `); + await this.pool.query(` + create index if not exists idx_oidc_clients_status_updated + on oidc_clients (status, updated_at desc); + `); + await this.pool.query(` + create table if not exists oidc_client_audit_logs ( + id bigserial primary key, + client_id text not null, + actor_subject_id text, + action text not null, + changed_fields jsonb not null default '[]'::jsonb, + previous_status text, + new_status text, + reason text, + source_ip text, + created_at timestamptz not null default now() + ); + `); + await this.pool.query(` + create index if not exists idx_oidc_client_audit_logs_client_created + on oidc_client_audit_logs (client_id, created_at desc); + `); + await this.pool.query(` + create table if not exists management_sessions ( + token_hash text primary key, + subject_id text not null references subjects(subject_id), + created_at timestamptz not null, + last_seen_at timestamptz not null, + expires_at timestamptz not null + ); + `); + await this.pool.query(` + create index if not exists idx_management_sessions_expires_at + on management_sessions (expires_at); `); await this.pool.query(` create table if not exists oidc_artifacts ( @@ -341,4 +506,30 @@ export class OidcPersistenceImpl implements OidcPersistence { ); `); } + + private async assertFreshOidcClientSchema() { + if (!this.pool) { + return; + } + const result = await this.pool.query( + `select column_name from information_schema.columns + where table_schema = current_schema() and table_name = 'oidc_clients'`, + ); + const columns = new Set( + result.rows.map((row: { column_name: string }) => row.column_name), + ); + const required = [ + "display_name", + "description", + "owner_subject_id", + "rejection_reason", + "version", + ]; + const missing = required.filter((column) => !columns.has(column)); + if (missing.length > 0) { + throw new Error( + `incompatible legacy oidc_clients schema; rebuild the database for the client management release (missing: ${missing.join(", ")})`, + ); + } + } } diff --git a/src/routes/management.ts b/src/routes/management.ts new file mode 100644 index 0000000..7cd2467 --- /dev/null +++ b/src/routes/management.ts @@ -0,0 +1,473 @@ +import express from "express"; +import type { NextFunction, Request, Response, Router } from "express"; +import type { OidcOpConfig } from "../config.js"; +import type { + AuthenticatedPrincipal, + InteractiveAuthenticatorService, +} from "../identity/index.js"; +import { + ClientManagementError, + ClientManagementService, +} from "../clients/client-management.service.js"; +import type { OidcPersistence } from "../persistence/contracts.js"; +import { + RateLimitService, + RateLimitUnavailableError, +} from "../persistence/rate-limit.service.js"; +import { resolveTrustedExpressRequestIp } from "../request-ip.js"; +import { sha256 } from "../utils.js"; +import { ManagementSessionService } from "../management/management-session.service.js"; +import { + clearManagementSessionCookie, + ensureManagementNonce, + issueManagementCsrf, + readManagementNonce, + readManagementSessionToken, + setManagementSessionCookie, + validateManagementCsrf, +} from "../management/management-security.js"; + +type ManagementRouterServices = { + interactiveAuthenticator: InteractiveAuthenticatorService; +}; + +export function createManagementRouter( + config: OidcOpConfig, + services: ManagementRouterServices, + store: OidcPersistence, + rateLimitService: RateLimitService, + onClientsChanged: () => void, +): Router { + const router = express.Router(); + const jsonParser = express.json({ limit: "64kb", strict: true }); + const sessions = new ManagementSessionService( + store, + config.sessionTtlSeconds, + config.sessionIdleTtlSeconds, + ); + const clients = new ClientManagementService(store, config.appEnv, { + maxClientsPerOwner: config.managementClientMaxPerSubject, + maxPendingClientsPerOwner: config.managementClientMaxPendingPerSubject, + adminQuotaExempt: config.managementClientQuotaAdminExempt, + }); + const adminIds = new Set(config.adminSubjectIds); + + router.use((_request, response, next) => { + response.setHeader("Cache-Control", "no-store"); + next(); + }); + + router.get("/auth/context", async (request, response, next) => { + try { + const token = readManagementSessionToken(request, config); + const principal = await sessions.authenticate(token); + if (!principal || !token) { + const nonce = ensureManagementNonce(request, response, config); + response.json({ + authenticated: false, + csrfToken: issueManagementCsrf(config, nonce), + }); + return; + } + response.json( + contextPayload( + config, + principal, + adminIds.has(principal.subjectId), + token, + ), + ); + } catch (error) { + next(error); + } + }); + + router.post("/auth/login", jsonParser, async (request, response, next) => { + try { + const nonce = readManagementNonce(request, config); + if (!nonce || !validateManagementCsrf(request, config, nonce)) { + response.status(400).json({ + error: "invalid_request", + error_description: "CSRF validation failed", + }); + return; + } + const account = + typeof request.body?.account === "string" + ? request.body.account.trim() + : ""; + const password = + typeof request.body?.password === "string" ? request.body.password : ""; + const ip = resolveTrustedExpressRequestIp(config, request); + const attemptKey = `oidc:login:attempt:account-ip:${sha256(account || "unknown")}:${ip}`; + const attempt = await rateLimitService.consume( + attemptKey, + config.loginRateLimitMax, + config.loginRateLimitWindowSeconds, + ); + if (!attempt.allowed) { + response.setHeader("Retry-After", String(attempt.retryAfterSeconds)); + response.status(429).json({ + error: "rate_limited", + error_description: "login attempts exceeded", + }); + return; + } + try { + const principal = await services.interactiveAuthenticator.authenticate({ + provider: config.authProvider, + account, + password, + ip, + ...(request.get("user-agent") + ? { userAgent: request.get("user-agent") as string } + : {}), + }); + await rateLimitService.reset(attemptKey).catch(() => undefined); + const session = await sessions.create(principal.subjectId); + setManagementSessionCookie(response, config, session.token); + response.json( + contextPayload( + config, + principal, + adminIds.has(principal.subjectId), + session.token, + ), + ); + } catch (error) { + const failureKey = `oidc:login:failure:account-ip:${sha256(account || "unknown")}:${ip}`; + const failure = await rateLimitService.consume( + failureKey, + config.loginFailureLimit, + config.loginFailureWindowSeconds, + ); + if (!failure.allowed) { + response.setHeader("Retry-After", String(failure.retryAfterSeconds)); + response.status(429).json({ + error: "rate_limited", + error_description: "login failures exceeded", + }); + return; + } + console.error( + "[oidc-op] management sign-in failed", + error instanceof Error + ? `${error.name}: ${error.message}` + : "unknown error", + ); + response.status(401).json({ + error: "access_denied", + error_description: "invalid account or password", + }); + } + } catch (error) { + if (error instanceof RateLimitUnavailableError) { + response.setHeader("Retry-After", "60"); + response.status(503).json({ + error: "service_unavailable", + error_description: "try again later", + }); + return; + } + next(error); + } + }); + + router.post("/auth/logout", async (request, response, next) => { + try { + const auth = await requireAuthentication( + request, + response, + config, + sessions, + adminIds, + ); + if (!auth) return; + if (!validateManagementCsrf(request, config, auth.token)) { + response.status(400).json({ + error: "invalid_request", + error_description: "CSRF validation failed", + }); + return; + } + await sessions.revoke(auth.token); + clearManagementSessionCookie(response, config); + response.status(204).end(); + } catch (error) { + next(error); + } + }); + + router.get("/clients", async (request, response, next) => { + await withActor(request, response, next, async (auth) => { + const viewAll = request.query["view"] === "all"; + response.json({ clients: await clients.list(auth.actor, viewAll) }); + }); + }); + + router.post("/clients", jsonParser, async (request, response, next) => { + await withMutation(request, response, next, async (auth) => { + const limits = [ + { + key: `oidc:management:client-create:subject:${sha256(auth.actor.subjectId)}`, + max: config.managementClientCreateRateLimitSubjectMax, + }, + { + key: `oidc:management:client-create:ip:${auth.actor.sourceIp ?? "unknown"}`, + max: config.managementClientCreateRateLimitIpMax, + }, + ]; + for (const limit of limits) { + const decision = await rateLimitService.consume( + limit.key, + limit.max, + config.managementClientCreateRateLimitWindowSeconds, + ); + if (!decision.allowed) { + response.setHeader("Retry-After", String(decision.retryAfterSeconds)); + response.status(429).json({ + error: "rate_limited", + error_description: "client creation rate limit exceeded", + }); + return; + } + } + const result = await clients.create(auth.actor, request.body); + onClientsChanged(); + response.status(201).json(result); + }); + }); + + router.post( + "/clients/:clientId/submit", + jsonParser, + async (request, response, next) => { + await withMutation(request, response, next, async (auth) => { + const client = await clients.submit( + auth.actor, + param(request, "clientId"), + request.body, + ); + response.json({ client }); + }); + }, + ); + + router.get("/clients/:clientId", async (request, response, next) => { + await withActor(request, response, next, async (auth) => { + response.json({ + client: await clients.get(auth.actor, param(request, "clientId")), + }); + }); + }); + + router.patch( + "/clients/:clientId", + jsonParser, + async (request, response, next) => { + await withMutation(request, response, next, async (auth) => { + const client = await clients.update( + auth.actor, + param(request, "clientId"), + request.body, + ); + onClientsChanged(); + response.json({ client }); + }); + }, + ); + + router.post( + "/clients/:clientId/disable", + jsonParser, + async (request, response, next) => { + await withMutation(request, response, next, async (auth) => { + const client = await clients.disable( + auth.actor, + param(request, "clientId"), + request.body, + ); + onClientsChanged(); + response.json({ client }); + }); + }, + ); + + router.get("/admin/reviews", async (request, response, next) => { + await withActor(request, response, next, async (auth) => { + response.json({ clients: await clients.listPending(auth.actor) }); + }); + }); + + router.post( + "/admin/reviews/:clientId/approve", + jsonParser, + async (request, response, next) => { + await withMutation(request, response, next, async (auth) => { + const client = await clients.approve( + auth.actor, + param(request, "clientId"), + request.body, + ); + onClientsChanged(); + response.json({ client }); + }); + }, + ); + + router.post( + "/admin/reviews/:clientId/reject", + jsonParser, + async (request, response, next) => { + await withMutation(request, response, next, async (auth) => { + const client = await clients.reject( + auth.actor, + param(request, "clientId"), + request.body, + ); + onClientsChanged(); + response.json({ client }); + }); + }, + ); + + router.use( + ( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, + ) => { + const status = (error as { status?: unknown }).status; + if (status === 400) { + response.status(400).json({ + error: "invalid_request", + error_description: "invalid JSON request body", + }); + return; + } + handleManagementError(error, response, next); + }, + ); + + async function withActor( + request: Request, + response: Response, + next: NextFunction, + handler: ( + auth: Awaited> & {}, + ) => Promise, + ) { + try { + const auth = await requireAuthentication( + request, + response, + config, + sessions, + adminIds, + ); + if (!auth) return; + await handler(auth); + } catch (error) { + handleManagementError(error, response, next); + } + } + + async function withMutation( + request: Request, + response: Response, + next: NextFunction, + handler: ( + auth: Awaited> & {}, + ) => Promise, + ) { + await withActor(request, response, next, async (auth) => { + if (!validateManagementCsrf(request, config, auth.token)) { + response.status(400).json({ + error: "invalid_request", + error_description: "CSRF validation failed", + }); + return; + } + await handler(auth); + }); + } + + return router; +} + +async function requireAuthentication( + request: Request, + response: Response, + config: OidcOpConfig, + sessions: ManagementSessionService, + adminIds: Set, +) { + const token = readManagementSessionToken(request, config); + const principal = await sessions.authenticate(token); + if (!principal || !token) { + response.status(401).json({ + error: "login_required", + error_description: "management login is required", + }); + return null; + } + return { + token, + principal, + actor: { + subjectId: principal.subjectId, + isAdmin: adminIds.has(principal.subjectId), + sourceIp: resolveTrustedExpressRequestIp(config, request), + }, + }; +} + +function contextPayload( + config: OidcOpConfig, + principal: AuthenticatedPrincipal, + isAdmin: boolean, + token: string, +) { + return { + authenticated: true, + csrfToken: issueManagementCsrf(config, token), + user: { + subjectId: principal.subjectId, + preferredUsername: principal.preferredUsername, + displayName: principal.displayName ?? principal.preferredUsername, + isAdmin, + }, + }; +} + +function handleManagementError( + error: unknown, + response: Response, + next: NextFunction, +) { + if (error instanceof ClientManagementError) { + response.status(error.status).json({ + error: error.code, + error_description: error.message, + ...(error.field + ? { field_errors: { [error.field]: error.message } } + : {}), + }); + return; + } + if (error instanceof RateLimitUnavailableError) { + response.setHeader("Retry-After", "60"); + response.status(503).json({ + error: "service_unavailable", + error_description: "try again later", + }); + return; + } + next(error); +} + +function param(request: Request, name: string) { + const value = request.params[name]; + return Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); +} diff --git a/src/scripts/seed-oidc-client.ts b/src/scripts/seed-oidc-client.ts index 4bc046d..0bf3837 100644 --- a/src/scripts/seed-oidc-client.ts +++ b/src/scripts/seed-oidc-client.ts @@ -1,13 +1,13 @@ import { readOidcOpConfig } from "../config.js"; import { OidcPersistenceImpl } from "../persistence/persistence.js"; -import { upsertOidcClientsFromConfig } from "../oidc/client-config.js"; +import { initializeOidcClientsFromConfig } from "../oidc/client-config.js"; async function main() { const config = readOidcOpConfig(process.env); const store = new OidcPersistenceImpl(config); await store.init(); - const clients = await upsertOidcClientsFromConfig(store, config); - console.log(JSON.stringify({ upsertedClients: clients.map((item) => item.clientId) })); + const result = await initializeOidcClientsFromConfig(store, config); + console.log(JSON.stringify(result)); await store.close(); } From 8be4def1a6812635063c120c1f07e2071d97b67d Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:17:09 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=92=84=20=E5=A2=9E=E5=8A=A0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E7=AE=A1=E7=90=86=E6=8E=A7=E5=88=B6=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 1 + package.json | 25 +- pnpm-lock.yaml | 1803 ++++++++++++++++++++++++++- scripts/check-single-env-source.mjs | 19 + web/index.html | 13 + web/src/App.test.tsx | 22 + web/src/App.tsx | 740 +++++++++++ web/src/main.tsx | 10 + web/src/styles.css | 360 ++++++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 21 + web/vite.config.ts | 11 + web/vitest.config.ts | 9 + 13 files changed, 2974 insertions(+), 61 deletions(-) create mode 100644 scripts/check-single-env-source.mjs create mode 100644 web/index.html create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts create mode 100644 web/vitest.config.ts diff --git a/Dockerfile b/Dockerfile index 387fd75..5e804de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ WORKDIR /app COPY package.json pnpm-lock.yaml tsconfig.json tsconfig.build.json ./ COPY src ./src +COPY web ./web COPY scripts ./scripts RUN corepack enable && pnpm install --frozen-lockfile diff --git a/package.json b/package.json index 2efd223..d9f1fa8 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,13 @@ "type": "module", "packageManager": "pnpm@10.32.1", "scripts": { - "build": "tsc -p tsconfig.build.json", - "check:env-source": "bash ../scripts/check-single-env-source.sh", - "dev": "tsx watch src/main.ts", + "build": "pnpm build:ui && pnpm build:server", + "build:server": "tsc -p tsconfig.build.json", + "build:ui": "vite build --config web/vite.config.ts", + "check:env-source": "node scripts/check-single-env-source.mjs", + "dev": "concurrently -k \"pnpm dev:server\" \"pnpm dev:ui\"", + "dev:server": "tsx watch src/main.ts", + "dev:ui": "vite build --watch --config web/vite.config.ts", "dev:oidc": "tsx watch src/main.ts", "docker:build": "docker build -t cqut-auth-oidc .", "docker:up": "docker compose -f deploy/docker-compose.yml up -d --build", @@ -15,7 +19,9 @@ "init-env": "node scripts/init-env.mjs", "init-env:stdout": "node scripts/init-env.mjs --stdout", "lint": "pnpm run check:env-source && tsc -p tsconfig.json --noEmit", - "test": "tsx --test test/**/*.test.ts src/**/*.test.ts", + "test": "pnpm test:server && pnpm test:ui", + "test:server": "tsx --test test/**/*.test.ts src/**/*.test.ts", + "test:ui": "vitest run --config web/vitest.config.ts", "seed:client": "tsx src/scripts/seed-oidc-client.ts", "seed:key": "tsx src/scripts/seed-oidc-signing-key.ts", "format": "prettier --write ." @@ -34,6 +40,8 @@ "jose": "^6.2.2", "oidc-provider": "^9.7.1", "pg": "^8.16.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", "resend": "^6.10.0", "tough-cookie": "^5.1.2" }, @@ -41,10 +49,17 @@ "@types/express": "^5.0.3", "@types/node": "^24.6.0", "@types/pg": "^8.15.5", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", "@types/supertest": "^6.0.3", + "@testing-library/react": "^16.3.0", + "concurrently": "^9.2.1", + "jsdom": "^27.0.0", "prettier": "^3.6.2", "supertest": "^7.1.4", "tsx": "4.20.6", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vite": "^7.1.9", + "vitest": "^3.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82a6f91..f9c5747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,12 @@ importers: pg: specifier: ^8.16.3 version: 8.20.0 + react: + specifier: ^19.1.1 + version: 19.2.7 + react-dom: + specifier: ^19.1.1 + version: 19.2.7(react@19.2.7) resend: specifier: ^6.10.0 version: 6.12.2 @@ -40,6 +46,9 @@ importers: specifier: ^5.1.2 version: 5.1.2 devDependencies: + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/express': specifier: ^5.0.3 version: 5.0.6 @@ -49,9 +58,21 @@ importers: '@types/pg': specifier: ^8.15.5 version: 8.20.0 + '@types/react': + specifier: ^19.1.16 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.1.9 + version: 19.2.3(@types/react@19.2.17) '@types/supertest': specifier: ^6.0.3 version: 6.0.3 + concurrently: + specifier: ^9.2.1 + version: 9.2.4 + jsdom: + specifier: ^27.0.0 + version: 27.4.0(@noble/hashes@1.8.0) prettier: specifier: ^3.6.2 version: 3.8.3 @@ -64,168 +85,402 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.3 + vite: + specifier: ^7.1.9 + version: 7.3.6(@types/node@24.12.2)(tsx@4.20.6) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.12.2)(jsdom@27.4.0(@noble/hashes@1.8.0))(tsx@4.20.6) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@koa/cors@5.0.0': resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} engines: {node: '>= 14.0.0'} @@ -243,18 +498,187 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} @@ -279,6 +703,14 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -291,6 +723,35 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -303,9 +764,28 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -319,6 +799,9 @@ packages: axios@1.15.2: resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -327,6 +810,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -335,10 +822,33 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -346,6 +856,11 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concurrently@9.2.4: + resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==} + engines: {node: '>=18'} + hasBin: true + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -373,6 +888,21 @@ packages: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} engines: {node: '>= 0.8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -382,8 +912,15 @@ packages: supports-color: optional: true - deep-equal@1.0.1: - resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} @@ -404,6 +941,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -411,6 +952,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -418,10 +962,17 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -430,6 +981,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -443,9 +997,21 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eta@4.5.1: resolution: {integrity: sha512-EaNCGm+8XEIU7YNcc+THptWAO5NfKBHHARxt+wxZljj9bTr/+arRoOm9/MpGt4n6xn9fLnPFRSoLD0WFYGFUxQ==} engines: {node: '>=20'} @@ -454,6 +1020,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -464,6 +1034,15 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -505,6 +1084,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -520,6 +1103,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -532,6 +1119,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-assert@1.5.0: resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} engines: {node: '>= 0.8'} @@ -554,6 +1145,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -569,12 +1168,34 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -597,10 +1218,27 @@ packages: lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -637,6 +1275,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@5.1.9: resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==} engines: {node: ^18 || >=20} @@ -664,6 +1307,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -671,6 +1317,13 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -705,9 +1358,20 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + postal-mime@2.7.4: resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -729,6 +1393,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -737,6 +1405,10 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -753,6 +1425,18 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -761,6 +1445,14 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resend@6.12.2: resolution: {integrity: sha512-xwgmU4b0OqoabJsIoK/x0Whk0Fcs3bpbK4i/DEWPiE5hYJHyHl0TbB6QbI3gIr+bLdLUJ1GYm/fe41aVFuHXgw==} engines: {node: '>=20'} @@ -773,13 +1465,28 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -791,6 +1498,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -807,10 +1518,20 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -825,6 +1546,20 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -833,16 +1568,56 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + svix@1.90.0: resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -851,6 +1626,21 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} @@ -880,132 +1670,510 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + + '@ioredis/commands@1.5.1': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + '@koa/cors@5.0.0': + dependencies: + vary: 1.1.2 - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} + '@koa/router@15.4.0(koa@3.2.0)': + dependencies: + debug: 4.4.3 + http-errors: 2.0.1 + koa: 3.2.0 + koa-compose: 4.1.0 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color -snapshots: + '@noble/hashes@1.8.0': {} - '@esbuild/aix-ppc64@0.25.12': - optional: true + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 - '@esbuild/android-arm64@0.25.12': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@esbuild/android-arm@0.25.12': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@esbuild/android-x64@0.25.12': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@esbuild/darwin-x64@0.25.12': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@esbuild/linux-arm64@0.25.12': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@esbuild/linux-arm@0.25.12': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@esbuild/linux-ia32@0.25.12': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@esbuild/linux-loong64@0.25.12': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@esbuild/linux-s390x@0.25.12': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@esbuild/linux-x64@0.25.12': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@esbuild/sunos-x64@0.25.12': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@esbuild/win32-arm64@0.25.12': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@esbuild/win32-ia32@0.25.12': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@esbuild/win32-x64@0.25.12': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@ioredis/commands@1.5.1': {} - - '@koa/cors@5.0.0': - dependencies: - vary: 1.1.2 + '@stablelib/base64@1.0.1': {} - '@koa/router@15.4.0(koa@3.2.0)': + '@testing-library/dom@10.4.1': dependencies: - debug: 4.4.3 - http-errors: 2.0.1 - koa: 3.2.0 - koa-compose: 4.1.0 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - '@noble/hashes@1.8.0': {} + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 - '@paralleldrive/cuid2@2.3.1': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@noble/hashes': 1.8.0 + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@stablelib/base64@1.0.1': {} + '@types/aria-query@5.0.4': {} '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 '@types/node': 24.12.2 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 24.12.2 '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': dependencies: '@types/node': 24.12.2 @@ -1037,6 +2205,14 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/send@1.2.1': dependencies: '@types/node': 24.12.2 @@ -1058,6 +2234,48 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.12.2)(tsx@4.20.6))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.12.2)(tsx@4.20.6) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -1070,8 +2288,22 @@ snapshots: agent-base@7.1.4: {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + asap@2.0.6: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} axios-cookiejar-support@6.0.5(axios@1.15.2)(tough-cookie@5.1.2): @@ -1090,6 +2322,10 @@ snapshots: transitivePeerDependencies: - debug + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -1106,6 +2342,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1116,14 +2354,50 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 component-emitter@1.3.1: {} + concurrently@9.2.4: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.9.0 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + content-disposition@1.0.1: {} content-disposition@1.1.0: {} @@ -1141,10 +2415,33 @@ snapshots: depd: 2.0.0 keygrip: 1.1.0 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.2 + + csstype@3.2.3: {} + + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + deep-equal@1.0.1: {} delayed-stream@1.0.0: {} @@ -1157,6 +2454,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} dezalgo@1.0.4: @@ -1164,6 +2463,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + dom-accessibility-api@0.5.16: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1172,12 +2473,18 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + entities@8.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -1218,12 +2525,49 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eta@4.5.1: {} etag@1.8.1: {} + expect-type@1.4.0: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -1261,6 +2605,10 @@ snapshots: fast-sha256@1.3.0: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -1299,6 +2647,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1323,6 +2673,8 @@ snapshots: gopd@1.2.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -1333,6 +2685,12 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + http-assert@1.5.0: dependencies: deep-equal: 1.0.1 @@ -1359,6 +2717,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -1381,10 +2753,46 @@ snapshots: ipaddr.js@1.9.1: {} + is-fullwidth-code-point@3.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} jose@6.2.2: {} + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@27.4.0(@noble/hashes@1.8.0): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} keygrip@1.1.0: @@ -1418,8 +2826,20 @@ snapshots: lodash.isarguments@3.1.0: {} + loupe@3.2.1: {} + + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -1442,6 +2862,8 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.16: {} + nanoid@5.1.9: {} negotiator@0.6.3: {} @@ -1473,10 +2895,18 @@ snapshots: dependencies: wrappy: 1.0.2 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + pg-cloudflare@1.3.0: optional: true @@ -1512,8 +2942,18 @@ snapshots: dependencies: split2: 4.2.0 + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + postal-mime@2.7.4: {} + postcss@8.5.18: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -1526,6 +2966,12 @@ snapshots: prettier@3.8.3: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -1533,6 +2979,8 @@ snapshots: proxy-from-env@2.1.0: {} + punycode@2.3.1: {} + qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -1548,12 +2996,25 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.7: {} + redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + resend@6.12.2: dependencies: postal-mime: 2.7.4 @@ -1561,6 +3022,37 @@ snapshots: resolve-pkg-maps@1.0.0: {} + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -1571,8 +3063,18 @@ snapshots: transitivePeerDependencies: - supports-color + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -1600,6 +3102,8 @@ snapshots: setprototypeof@1.2.0: {} + shell-quote@1.9.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -1628,8 +3132,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} standardwebhooks@1.0.0: @@ -1641,6 +3151,22 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -1663,23 +3189,66 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + svix@1.90.0: dependencies: standardwebhooks: 1.0.0 uuid: 10.0.0 + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + tldts-core@7.4.8: {} + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + toidentifier@1.0.1: {} tough-cookie@5.1.2: dependencies: tldts: 6.1.86 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + tsscmp@1.0.6: {} tsx@4.20.6: @@ -1705,6 +3274,128 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@24.12.2)(tsx@4.20.6): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.12.2)(tsx@4.20.6) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.12.2)(tsx@4.20.6): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.18 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + tsx: 4.20.6 + + vitest@3.2.7(@types/node@24.12.2)(jsdom@27.4.0(@noble/hashes@1.8.0))(tsx@4.20.6): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.12.2)(tsx@4.20.6)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.12.2)(tsx@4.20.6) + vite-node: 3.2.4(@types/node@24.12.2)(tsx@4.20.6) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.2 + jsdom: 27.4.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/scripts/check-single-env-source.mjs b/scripts/check-single-env-source.mjs new file mode 100644 index 0000000..b6af57e --- /dev/null +++ b/scripts/check-single-env-source.mjs @@ -0,0 +1,19 @@ +import { globSync, readFileSync } from "node:fs"; + +const allowed = new Set([ + "src/app.ts", + "src/config.ts", + "src/scripts/seed-oidc-client.ts", + "src/scripts/seed-oidc-signing-key.ts", +]); +const violations = globSync("src/**/*.ts").filter( + (file) => + !allowed.has(file) && readFileSync(file, "utf8").includes("process.env"), +); + +if (violations.length > 0) { + process.stderr.write( + `Environment variables must be read through src/config.ts: ${violations.join(", ")}\n`, + ); + process.exitCode = 1; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..fe5d8e6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + CQUT-Auth 客户端管理 + + +
+ + + diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..04b126b --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,22 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { App } from "./App"; + +test("shows the management login when the session is anonymous", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ authenticated: false, csrfToken: "csrf" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + render(); + await waitFor(() => + expect(screen.getByRole("heading", { name: "客户端管理" })).toBeTruthy(), + ); + expect(screen.getByLabelText("账号")).toBeTruthy(); + expect(screen.getByText(/密码仅用于本次认证/)).toBeTruthy(); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..36a0b10 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,740 @@ +import { FormEvent, useEffect, useState } from "react"; + +type User = { + subjectId: string; + displayName: string; + isAdmin: boolean; +}; +type AuthContext = + | { authenticated: false; csrfToken: string } + | { authenticated: true; csrfToken: string; user: User }; +type Client = { + clientId: string; + displayName: string; + description: string; + clientType: "web" | "spa"; + redirectUris: string[]; + postLogoutRedirectUris: string[]; + scopeWhitelist: string[]; + status: "draft" | "pending" | "active" | "disabled" | "rejected"; + rejectionReason: string | null; + updatedAt: string; + version: number; +}; +type ClientFormValue = Pick< + Client, + | "clientType" + | "displayName" + | "description" + | "redirectUris" + | "postLogoutRedirectUris" + | "scopeWhitelist" +>; + +const emptyClient: ClientFormValue = { + clientType: "web", + displayName: "", + description: "", + redirectUris: [], + postLogoutRedirectUris: [], + scopeWhitelist: ["openid", "profile"], +}; + +class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +async function api( + path: string, + options: RequestInit = {}, + csrfToken?: string, +): Promise { + const headers = new Headers(options.headers); + if (options.body) headers.set("Content-Type", "application/json"); + if (csrfToken) headers.set("X-CSRF-Token", csrfToken); + const response = await fetch(`/api/management${path}`, { + ...options, + headers, + }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error_description?: string; + }; + throw new ApiError( + body.error_description ?? "请求失败,请稍后重试。", + response.status, + ); + } + return response.status === 204 + ? (undefined as T) + : ((await response.json()) as T); +} + +export function App() { + const [context, setContext] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + api("/auth/context") + .then(setContext) + .catch((reason) => setError(reason.message)); + }, []); + + if (error && !context) + return ; + if (!context) return ; + if (!context.authenticated) { + return ; + } + return ; +} + +function Login({ + context, + onLogin, +}: { + context: AuthContext & { authenticated: false }; + onLogin: (value: AuthContext) => void; +}) { + const [account, setAccount] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + onLogin( + await api( + "/auth/login", + { method: "POST", body: JSON.stringify({ account, password }) }, + context.csrfToken, + ), + ); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "登录失败,请重试。"); + } finally { + setBusy(false); + } + } + + return ( +
+
+

CQUT-AUTH

+

客户端管理

+

+ 使用校园统一身份认证账号登录。密码仅用于本次认证,不会被保存。 +

+ {error && {error}} +
+ + + +
+
+
+ ); +} + +function Dashboard({ + context, +}: { + context: AuthContext & { authenticated: true }; +}) { + const [tab, setTab] = useState<"mine" | "all" | "reviews">("mine"); + const [clients, setClients] = useState([]); + const [selected, setSelected] = useState(null); + const [creating, setCreating] = useState(false); + const [secret, setSecret] = useState<{ + clientId: string; + value: string; + } | null>(null); + const [notice, setNotice] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + const endpoint = + tab === "reviews" + ? "/admin/reviews" + : `/clients${tab === "all" ? "?view=all" : ""}`; + async function refresh() { + setLoading(true); + setError(""); + try { + const result = await api<{ clients: Client[] }>(endpoint); + setClients(result.clients); + if (selected) + setSelected( + result.clients.find( + (client) => client.clientId === selected.clientId, + ) ?? null, + ); + } catch (reason) { + setError( + reason instanceof Error ? reason.message : "客户端列表加载失败。", + ); + } finally { + setLoading(false); + } + } + useEffect(() => { + void refresh(); + }, [endpoint]); + + async function mutate(path: string, body: object, success: string) { + setError(""); + try { + await api( + path, + { method: "POST", body: JSON.stringify(body) }, + context.csrfToken, + ); + setNotice(success); + await refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "操作失败。"); + } + } + + async function logout() { + await api("/auth/logout", { method: "POST" }, context.csrfToken); + window.location.assign("/manage"); + } + + return ( +
+
+
+

CQUT-AUTH

+ 客户端管理 +
+
+ + {context.user.displayName} + {context.user.isAdmin && 管理员} + + +
+
+
+ +
+
+

{tab === "reviews" ? "审核客户端" : "OIDC 客户端"}

+

+ Subject ID:{context.user.subjectId} +

+
+ {tab !== "reviews" && ( + + )} +
+ {notice && {notice}} + {error && {error}} + {secret && ( + setSecret(null)} /> + )} + {creating && ( + setCreating(false)} + onSubmit={async (value) => { + const result = await api<{ + client: Client; + clientSecret?: string; + }>( + "/clients", + { method: "POST", body: JSON.stringify(value) }, + context.csrfToken, + ); + setCreating(false); + setNotice("客户端已创建并进入待审核状态。"); + if (result.clientSecret) + setSecret({ + clientId: result.client.clientId, + value: result.clientSecret, + }); + await refresh(); + }} + /> + )} + {selected && !creating && ( + setSelected(null)} + onSubmit={async (value) => { + try { + const result = await api<{ client: Client }>( + `/clients/${encodeURIComponent(selected.clientId)}`, + { + method: "PATCH", + body: JSON.stringify({ + displayName: value.displayName, + description: value.description, + redirectUris: value.redirectUris, + postLogoutRedirectUris: value.postLogoutRedirectUris, + scopeWhitelist: value.scopeWhitelist, + version: selected.version, + }), + }, + context.csrfToken, + ); + setSelected(result.client); + setNotice("客户端设置已保存。"); + await refresh(); + } catch (reason) { + if (reason instanceof ApiError && reason.status === 409) + throw new Error( + "客户端已被其他操作更新,请关闭编辑器并重新加载。", + ); + throw reason; + } + }} + extraActions={ + <> + {selected.status === "draft" && ( + + )} + {selected.status !== "disabled" && ( + + )} + {context.user.isAdmin && selected.status === "pending" && ( + <> + + + + )} + + } + /> + )} + {!creating && !selected && ( + + )} +
+
+ ); +} + +function ClientEditor({ + title, + initial, + submitLabel, + onSubmit, + onCancel, + disabled, + allowTypeChange, + configurationDisabled, + rejectionReason, + extraActions, +}: { + title: string; + initial: ClientFormValue; + submitLabel: string; + onSubmit: (value: ClientFormValue) => Promise; + onCancel: () => void; + disabled?: boolean; + allowTypeChange?: boolean; + configurationDisabled?: boolean; + rejectionReason?: string | null; + extraActions?: React.ReactNode; +}) { + const [value, setValue] = useState(initial); + const [redirects, setRedirects] = useState(initial.redirectUris.join("\n")); + const [logoutRedirects, setLogoutRedirects] = useState( + initial.postLogoutRedirectUris.join("\n"), + ); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const scopes = ["openid", "profile", "email", "student", "offline_access"]; + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + await onSubmit({ + ...value, + redirectUris: lines(redirects), + postLogoutRedirectUris: lines(logoutRedirects), + }); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "保存失败。"); + } finally { + setBusy(false); + } + } + return ( +
+
+

{title}

+ +
+ {rejectionReason && ( + 最近一次拒绝原因:{rejectionReason} + )} + {error && {error}} +
+ + +